From 3d5990130405c270c8938effcc75370943c6f26a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:34:52 +0800 Subject: [PATCH 1/8] =?UTF-8?q?2026.8.26.2=20=E2=80=94=20the=20answer=20wa?= =?UTF-8?q?s=20already=20resolved,=20and=20nothing=20consulted=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the previous release's family does not cover. There the predicate asked a narrower question than the one it was given; here the predicate asked the right one, got the right answer, and the answer never reached a decision. Both are a recorded field with no decision-side reader. `requires` was checked, never applied. `prepare.cppm` has a block titled "the toolchain, resolved now that the graph exists"; it scans `provides` to decide the compiler and does not scan `requires_`, which is collected a thousand lines later and used only to reject the outcome. Measured with llvm@22.1.8 already installed: `mcpp build` refused and advised `mcpp toolchain default llvm` — a global change, for one project's dependency — while `MCPP_TOOLCHAIN=llvm@22.1.8 mcpp build` finished in 1.02s. Selecting it writes nothing, and that is where the decision sits rather than a rule to remember: `resolve_target_toolchain` has two call sites, both after the graph, so the first-run install-and-persist branch and all three `write_default_toolchain` calls are downstream. On a machine with no toolchain the branch is not even entered — its condition is `!tcSpec.has_value()`. Refusal now happens only where the project stated its own compiler, and the advice points at that statement; two packages requiring different families is an error naming both. The tier gate asked about the identity, not the request. `parse` fills a missing env segment lexically so the identity stays total, and `envExplicit` exists to record that it was a fill — the gate did not read it. `--target aarch64-linux` refused as `aarch64-linux-gnu` (planned) while `aarch64-linux-musl` built, and `riscv64-linux` was reported `unknown` although the family is registered. A request that declined the segment is now completed against the vocabulary, with the lexical default tried first so the rule retires itself. `parse()` is unchanged: identity must stay lexical, total and host-independent. Two diagnostics stopped lying. `unknown target` no longer fires when the (arch, os) group is non-empty, refusals quote the spelling the user wrote, and the unknown path finally records a code instead of reporting `other`. `why toolchain --format json` gave two answers for the C library — `cLibrary` said glibc/payload while `layers[].c-abi` said musl/graph, and the artifact (static, no interpreter, no DT_NEEDED, 11 openkal symbols) settled it. Adds `cLibrary.suppliesTarget` rather than renaming a field, per docs/11 §6. Criteria: e2e 299-303 classify through `--format json` and run on all four build hosts via the target-matrix invariants layer. 301's criterion is the sha256 of config.toml, not a successful build — those can both be true, which is the behaviour being removed. 299's second half is the control that `x86_64-linux` is still gnu. --- .../2026-08-26-resolved-but-not-consulted.md | 545 ++++++++++++++++++ .github/workflows/ci-target-matrix.yml | 43 +- CHANGELOG.md | 92 +++ docs/03-toolchains.md | 52 ++ docs/11-machine-output.md | 23 +- docs/16-the-target-triple.md | 40 ++ docs/zh/03-toolchains.md | 48 ++ docs/zh/11-machine-output.md | 20 +- docs/zh/16-the-target-triple.md | 34 ++ mcpp.toml | 2 +- src/build/prepare.cppm | 362 +++++++++++- src/build/refusal.cppm | 7 + src/doctor.cppm | 30 + src/targetside/model.cppm | 42 +- src/toolchain/lifecycle.cppm | 7 + src/toolchain/triple.cppm | 91 +++ src/version.cppm | 2 +- ...c_library_resolves_to_a_row_that_exists.sh | 131 +++++ ...gistered_family_is_not_reported_unknown.sh | 87 +++ ...ompiler_is_taken_and_nothing_is_written.sh | 134 +++++ ...graph_and_two_requirements_do_not_stack.sh | 112 ++++ ...uery_gives_one_answer_for_the_c_library.sh | 109 ++++ tests/unit/test_targetside.cpp | 18 +- tests/unit/test_toolchain_triple.cpp | 83 +++ 24 files changed, 2092 insertions(+), 22 deletions(-) create mode 100644 .agents/docs/2026-08-26-resolved-but-not-consulted.md create mode 100755 tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh create mode 100755 tests/e2e/300_a_registered_family_is_not_reported_unknown.sh create mode 100755 tests/e2e/301_the_graphs_compiler_is_taken_and_nothing_is_written.sh create mode 100755 tests/e2e/302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh create mode 100755 tests/e2e/303_the_query_gives_one_answer_for_the_c_library.sh 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..68ce8ca1 --- /dev/null +++ b/.agents/docs/2026-08-26-resolved-but-not-consulted.md @@ -0,0 +1,545 @@ +# 已经解析出的答案,没有被用来做决定 + +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 ⭐⭐ 这次选择不写任何东西,而且这是位置带来的,不是额外加的开关 + +`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. 顺序与代价 + +| 项 | 依赖 | 触及 | 风险 | +|---|---|---|---| +| 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..e418c81f 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,44 @@ 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 + check 303_the_query_gives_one_answer_for_the_c_library.sh \ + "OK: the query gives one answer for the C library" || fail=1 + + # ⚠️ 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..bd1ca466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,98 @@ > 本文件追踪 `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 | + + 拒绝只剩一种局面:工程自己在 `[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 承诺字段只增不删、含义永不改变。 + +### 判据 + +- 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..fce50826 100644 --- a/docs/11-machine-output.md +++ b/docs/11-machine-output.md @@ -264,15 +264,36 @@ It resolves and reports; it does not build. `data` is: | `reason` | a refusal token, or `none` | | `compiler` | `{family, version, driver}` — the driver that would run | | `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}` | +⚠️ **`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..ce7f1b31 100644 --- a/docs/zh/11-machine-output.md +++ b/docs/zh/11-machine-output.md @@ -233,14 +233,32 @@ mcpp why toolchain [--target ] [--toolchain ] --format json | `reason` | 拒绝的记号,或 `none` | | `compiler` | `{family, version, driver}` —— 真正会跑的驱动器 | | `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}` | +⚠️ **`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..63c5442e 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 }; @@ -494,6 +499,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; } @@ -916,6 +922,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 +1528,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 +1579,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 +2090,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`", @@ -5070,6 +5182,110 @@ 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) + 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". + 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 +5295,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 +5357,97 @@ 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. + if (!reqCompiler.empty()) { + std::string haveFamily; + if (tcSpec.has_value() && *tcSpec != "system") + 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) { + 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" + " 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 `{}`.", + reqCompilerBy, reqCompiler, + targetRowName.empty() ? overrides.target_triple + : targetRowName, + targetPinCandidate, + targetPinIsCapability ? "capability" : "convention", + reqCompiler, reqCompilerBy, + 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.", + reqCompiler)); + } + // 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 +6628,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); } diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 41ddc826..315af3eb 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -41,6 +41,9 @@ 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 + CompilerRequirementConflict, // the graph and the project name different compilers 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 +60,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..60b0e6cb 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -920,12 +920,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/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..b5cad438 --- /dev/null +++ b/tests/e2e/301_the_graphs_compiler_is_taken_and_nothing_is_written.sh @@ -0,0 +1,134 @@ +#!/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" + +cfg="${MCPP_HOME:-$HOME/.mcpp}/config.toml" +before="" +[ -f "$cfg" ] && before="$(sha256sum "$cfg" | cut -d' ' -f1)" + +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="$(sha256sum "$cfg" | cut -d' ' -f1)" +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 status line 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`. +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 the package that asked" +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..2b7a68e6 --- /dev/null +++ b/tests/e2e/302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh @@ -0,0 +1,112 @@ +#!/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 + +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..4a09285d --- /dev/null +++ b/tests/e2e/303_the_query_gives_one_answer_for_the_c_library.sh @@ -0,0 +1,109 @@ +#!/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 +raw="$("$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r' \ + | jq -r '.data.cLibrary.suppliesTarget // "MISSING"')" +if [ "$raw" = MISSING ]; then + echo "FAIL: cLibrary.suppliesTarget is absent — the document still cannot say" + echo " which of its two C-library answers governs" + exit 1 +fi +echo " ok the query says which of its two C-library answers governs" + +# ── Half two: with no graph, the payload governs ───────────────────────── +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. +reason2="$("$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r' \ + | jq -r '.data.reason // "QUERY-FAILED"')" +if [ "$reason2" != none ]; then + echo "FAIL: the query refused ('$reason2') where it had to answer" + "$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r' \ + | jq -r '.diagnostics[].message' | sed 's/^/ /' + exit 1 +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"); From fcbfc68c92f07e15b9cd954f2d9679e07612ca42 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:58:05 +0800 Subject: [PATCH 2/8] the query answers why, and three tests asserted things that were not the claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compiler.chosenBy` carries the selection origin into the machine interface: a consumer asking why a build resolved llvm had to parse the status line, which is the substring matching that document exists to remove. Three test defects found by reading, not by failing: - the capability-row refusal offered "depend on a package that supplies this target's system" — a remedy a capability pin ignores by construction, and the sentence directly above it already said so. Split by row kind. - 303 read `suppliesTarget` through jq's `//`, which returns its right side when the left is null OR FALSE. `false` is an answer; it read as absent, and windows-x86_64 reported the field missing when it was there. - 281 asserted the old global remedy, which the only refusal that still reaches it cannot be fixed by. 303's third half stacks a musl c-abi over the host's own target and not every host stacks that; granted by reason, with linux-x86_64 as the denominator. --- .github/workflows/ci-target-matrix.yml | 15 ++++- CHANGELOG.md | 4 ++ docs/11-machine-output.md | 19 +++++- docs/zh/11-machine-output.md | 18 ++++- src/build/prepare.cppm | 66 +++++++++++++++---- src/doctor.cppm | 10 +++ tests/e2e/281_target_side_rules.sh | 19 +++++- ...ompiler_is_taken_and_nothing_is_written.sh | 27 +++++++- ...graph_and_two_requirements_do_not_stack.sh | 44 +++++++++++++ ...uery_gives_one_answer_for_the_c_library.sh | 62 ++++++++++++----- 10 files changed, 249 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci-target-matrix.yml b/.github/workflows/ci-target-matrix.yml index e418c81f..3e6e0455 100644 --- a/.github/workflows/ci-target-matrix.yml +++ b/.github/workflows/ci-target-matrix.yml @@ -188,8 +188,19 @@ jobs: "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 - check 303_the_query_gives_one_answer_for_the_c_library.sh \ - "OK: the query gives one answer for the C library" || 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1ca466..9d50609f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,10 @@ | 已有 gcc 默认的机器 | 拒绝,要求改全局默认 | 装/用 llvm,`config.toml` 不动 | | 什么都没装的机器 | 装 gcc → 持久化 gcc → 再拒绝 | 首次运行分支根本不进,直接装 llvm | + ⭐ 状态行点名是哪个包要求的、顶掉了什么;`why toolchain --format json` 新增 + `compiler.chosenBy = {origin, requiredBy, replaced}`,让「为什么是 llvm」不必去 + 解析那行提示 —— 那正是机器接口存在的理由所要消除的字符串匹配。 + 拒绝只剩一种局面:工程自己在 `[toolchain]` 或 `[target.X]` 写下了相反的编译器。 ⚠️ 那种局面里全局默认与本次构建无关,因此原来那条 `mcpp toolchain default llvm` 的建议**连问题都解决不了**,已改为指向那条陈述本身。两个包要求不同的族则是错误 diff --git a/docs/11-machine-output.md b/docs/11-machine-output.md index fce50826..83e8612e 100644 --- a/docs/11-machine-output.md +++ b/docs/11-machine-output.md @@ -262,11 +262,28 @@ 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, 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 diff --git a/docs/zh/11-machine-output.md b/docs/zh/11-machine-output.md index ce7f1b31..9a9389d6 100644 --- a/docs/zh/11-machine-output.md +++ b/docs/zh/11-machine-output.md @@ -231,11 +231,27 @@ 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, 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 库时两者分叉,而在 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 63c5442e..eeb812f1 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -581,6 +581,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; @@ -5408,31 +5421,54 @@ prepare_build(bool print_fingerprint, // 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" - " 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 `{}`.", + " {}\n{}", reqCompilerBy, reqCompiler, targetRowName.empty() ? overrides.target_triple : targetRowName, targetPinCandidate, targetPinIsCapability ? "capability" : "convention", reqCompiler, reqCompilerBy, - 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.", - reqCompiler)); + why, remedy)); } // Free to take it. `tcSpec` is either absent (nothing configured // anywhere) or one of mcpp's own remembered answers. @@ -7301,6 +7337,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/doctor.cppm b/src/doctor.cppm index 60b0e6cb..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)}, 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/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 index b5cad438..a7e4e4db 100755 --- 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 @@ -113,18 +113,41 @@ else exit 1 fi -# ── And the status line says who asked ─────────────────────────────────── +# ── 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 the package that asked" + 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/^/ /' 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 index 2b7a68e6..b2e4073b 100755 --- 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 @@ -109,4 +109,48 @@ else 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 index 4a09285d..4e986df9 100755 --- 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 @@ -40,25 +40,46 @@ probe() { # → " " # ⚠️ 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 -raw="$("$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r' \ - | jq -r '.data.cLibrary.suppliesTarget // "MISSING"')" -if [ "$raw" = MISSING ]; then +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 -echo " ok the query says which of its two C-library answers governs" -# ── Half two: with no graph, the payload governs ───────────────────────── +# ── 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 </dev/null | tr -d '\r' \ - | jq -r '.data.reason // "QUERY-FAILED"')" +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 - echo "FAIL: the query refused ('$reason2') where it had to answer" - "$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r' \ - | jq -r '.diagnostics[].message' | sed 's/^/ /' - exit 1 + # ⚠️ 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 < Date: Wed, 26 Aug 2026 16:07:15 +0800 Subject: [PATCH 3/8] a compiler the graph required may never become the machine's default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". A compiler chosen by `requires = ["mcpp:compiler=…"]` satisfies both, so a bare Windows box building ONE llvm-requiring project would have written llvm as the MACHINE's default and handed it to every later project that asked for nothing. Found by reading the two call sites against the rule, not by a run: it needs a Windows machine with no toolchain. `tc_origin_may_persist` gives the rule a name both sites call and a unit test can state — the e2e that hashes config.toml runs where a toolchain is already configured and never reaches either branch. --- src/build/prepare.cppm | 40 +++++++++++++++++++++++++++- tests/unit/test_windows_defaults.cpp | 25 +++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index eeb812f1..913ba9d8 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -490,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. @@ -2322,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)", @@ -2606,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() 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 From f264dacc7217f1b35fd8ecf667bb0609cd4603ca Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:08:28 +0800 Subject: [PATCH 4/8] docs: what implementing corrected in the analysis --- .../2026-08-26-resolved-but-not-consulted.md | 67 ++++++++++++++++++- CHANGELOG.md | 11 +++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-08-26-resolved-but-not-consulted.md b/.agents/docs/2026-08-26-resolved-but-not-consulted.md index 68ce8ca1..baf85200 100644 --- a/.agents/docs/2026-08-26-resolved-but-not-consulted.md +++ b/.agents/docs/2026-08-26-resolved-but-not-consulted.md @@ -533,7 +533,72 @@ EXPECT_EQ(parse("x86_64-linux")->str(), "x86_64-linux-gnu"); --- -## 8. 顺序与代价 +## 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. 顺序与代价 | 项 | 依赖 | 触及 | 风险 | |---|---|---|---| diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d50609f..3e0a602f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,17 @@ `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 判据跑在已配好的环境里,两条分支一条都到不了。 + ### 判据 - e2e `299`–`303`,全部走 `--format json` 分类而非字符串搜索,并接入 From c7981cf7347205647536604a91bf6c2e66381f8c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:10:31 +0800 Subject: [PATCH 5/8] the PATH-compiler escape hatch is not an answer to substitute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[toolchain] default = "system"` means "whatever is on PATH" — a deliberate opt-out of the payload model. Replacing it with a payload because a dependency named a family defeats what was asked for, and mcpp cannot even tell whether the requirement is already met: the family of a PATH compiler is not knowable from the spec. check_requirements reports the mismatch against what the driver turned out to be, which is the only place that answer exists. --- CHANGELOG.md | 8 ++++++++ src/build/prepare.cppm | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0a602f..87e11ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,14 @@ 测陈述它。⚠️ 这条缺陷是**读出来的**:它需要一台没有工具链的 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` 分类而非字符串搜索,并接入 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 913ba9d8..e6bf4f74 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -5425,9 +5425,21 @@ prepare_build(bool print_fingerprint, // 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. - if (!reqCompiler.empty()) { + // ⚠️ `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() && *tcSpec != "system") + if (tcSpec.has_value()) if (auto s = mcpp::toolchain::parse_toolchain_spec(*tcSpec); s) haveFamily = std::string(mcpp::toolchain::family_name(s->family)); From ceee178dae92c758689aada6d6549487515f79c1 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:16:15 +0800 Subject: [PATCH 6/8] the two new refusal paths record their code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unnamed branch reports `other` in the machine interface, and this release exists partly because one of those had a perfectly good name. Both paths in `resolve_required_family` — an unrecognised family, and a family with no version to use — now record `compiler-requirement-conflict`, whose comment is widened to the four situations it covers: what a consumer does about each is the same. --- src/build/prepare.cppm | 8 +++++++- src/build/refusal.cppm | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index e6bf4f74..b7e60d9c 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -5296,12 +5296,14 @@ prepare_build(bool print_fingerprint, [&](const std::string& family) -> std::expected { auto spec = mcpp::toolchain::parse_toolchain_spec(family); - if (!spec) + 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); @@ -5328,6 +5330,10 @@ prepare_build(bool print_fingerprint, // 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" diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 315af3eb..73901ed3 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -43,7 +43,12 @@ 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 - CompilerRequirementConflict, // the graph and the project name different compilers + // 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 From 20bff7e4a91dad813fe493e455f9b1fb5d80fa07 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:21:42 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20the=20plan's=20=C2=A74.1.1=20points?= =?UTF-8?q?=20at=20the=20section=20that=20corrects=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/docs/2026-08-26-resolved-but-not-consulted.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/docs/2026-08-26-resolved-but-not-consulted.md b/.agents/docs/2026-08-26-resolved-but-not-consulted.md index baf85200..da3c9409 100644 --- a/.agents/docs/2026-08-26-resolved-but-not-consulted.md +++ b/.agents/docs/2026-08-26-resolved-but-not-consulted.md @@ -372,6 +372,10 @@ EXPECT_EQ(parse("x86_64-linux")->str(), "x86_64-linux-gnu"); ### 4.1.1 ⭐⭐ 这次选择不写任何东西,而且这是位置带来的,不是额外加的开关 +> ⚠️ **本节的结论对了一半,见 [§8.1](#81-️️41-说不需要新的开关这句话是不完整的)。** +> 首次运行那条分支确实进不去,但 `write_default_toolchain` 有**三个**调用点,另外 +> 两个的条件不是它。以下保留原文,因为它被推翻的方式本身是这份文档的一条判据。 + `resolve_target_toolchain` 在整个 `prepare.cppm` 里**只有两个调用点**: ``` From 2f666a21e82330356e40860cf03f338cc2867ed6 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:26:03 +0800 Subject: [PATCH 8/8] a hash criterion that cannot hash passes by comparing two empty strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 301's whole claim is that `config.toml` is byte-identical. macOS has no `sha256sum` — it is `shasum -a 256` — so on that host the first draft would have compared "" with "", which are equal, and reported the criterion met while measuring nothing. A build can succeed AND rewrite the configuration; that is the pair this half exists to separate. Picks whichever hasher exists, fails when neither does, and refuses an empty result on either side. --- ...ompiler_is_taken_and_nothing_is_written.sh | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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 index a7e4e4db..a4984bea 100755 --- 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 @@ -81,9 +81,26 @@ printf '[package]\nname = "needs-%s"\nversion = "0.1.0"\nrequires = ["mcpp: 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="" -[ -f "$cfg" ] && before="$(sha256sum "$cfg" | cut -d' ' -f1)" +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')" @@ -103,7 +120,14 @@ echo " ok the graph required '$want' and mcpp took it (was '$current')" # ── And it changed no configuration ────────────────────────────────────── after="" -[ -f "$cfg" ] && after="$(sha256sum "$cfg" | cut -d' ' -f1)" +[ -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