From 6cf0142c66f9da7fb2ec8a19266a2268ba8dfac2 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:02:04 +0800 Subject: [PATCH 1/6] feat(targetside): five layers, and the largest of them had no name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次真实使用暴露了十条问题:一个 hello-world 工程,`[dependencies]` 里只加了一行 `openkal-llvm-runtime`,随后在四个目标上连续失败,每一次的错误信息都来自编译器, 没有一条提到 mcpp 作出的决定。 `01d6cef` 把「目标侧从哪来」收敛到了一处解析,但它的消费者仍停在旧模型上。 本次补齐模型本身。 ── 1. 第五层:compiler-runtime ────────────────────────────── 实测 `openkal-llvm-runtime` 编译的 729 个对象里,**498 个是 compiler-rt 的 builtins**、21 个是 libunwind 的。这个包最大的一块此前声明在 `mcpp:c++-abi` 名下 —— 而 `__udivti3` 是一个纯 C 程序需要的东西,与 C++ 无关。 把 builtins 算作 C++ 运行时的一部分,与本文件开头记录的那个缺陷同形: 一个交叉到 macOS 的 C 程序被问「有没有 C++ 运行时」,答「没有」, 链接行因而保留了载荷自带的 libc++。**一个只有部分程序需要的层仍然是层。** `compiler` 同时成为一层,因为规则二需要一个被检查的对象。它是唯一一个 包不能供给的层:族与族之间的差异(flag 拼写、模块模型、BMI 格式、驱动 cfg) 是引擎必须持有的事实,不是数据能描述的。 ⚠️ `compiler` 层上报的是**族名**(`llvm`)而不是驱动名(`clang`)。 使用者书写的每一处都用族名,报告用驱动名会让 `requires = ["mcpp:compiler=llvm"]` 永远不可满足。 ── 2. requires:在引擎里不出现实现名的前提下执行分层规则 ──── requires = ["mcpp:compiler=llvm"] libc++ 的源码、尤其它的 std 模块源,由 clang 编译。把它递给 gcc 会在 libc++ 自己的头文件深处失败。该事实属于包 —— 在引擎里写 `if (stdlib == "libc++" && compiler == gcc)` 会把两个实现名放进引擎。 实测,修复前后同一条命令: error: std module precompile failed (rc=1): …/std.cppm:16: fatal error: __config: No such file or directory error: `openkal-llvm-runtime@0.1.1` requires the compiler to be `llvm`. compiler gcc (16.1.0, payload) required llvm (required by openkal-llvm-runtime@0.1.1) Select that compiler — yours outranks mcpp's own default: mcpp toolchain default llvm ⚠️ 检查在**编译开始之前**运行,这正是声明它的全部意义。 ── 3. 规则一:每层恰好一个供给者 ──────────────────────────── 此前两个包供给同一层时,**图遍历顺序里第一个静默胜出** —— 那个顺序既不是 作者写的,也不是他能预测的 —— 而落选者的 `[build]` 段仍然进入命令行, 于是一份 C 库的头配另一份 C 库的实现。 C 库、内核接口、C++ 运行时是**互斥的选择**,不是可叠加的贡献; `[build] runner` 早已按同一条规则处理。判据是失败模态:选错不会让链接失败, 会得到一个能跑、偶尔崩的程序。 ── 4. std-module* 从 [package] 移入 [build] ──────────────── 模块源是这个包的一个翻译单元 —— 它以包的 include 目录与定义被编译, 引擎自己的注释早就这么写了。放在 `[package]` 下损失的恰恰是位置所决定的那件事: `[build]` 可条件化而 `[package]` 不可,于是一个在多种 C 库之上供给同一 C++ 运行时的包,无法为不同 C 库给出不同的 flag。`-D_GNU_SOURCE` 对 musl 与 glibc 是对的,对 picolibc 是错的,而此前没有拼写能表达这个差别。 `[package]` 写法保留为别名。 ── 5. 报告按需暴露 ──────────────────────────────────────── 零配置构建的五个层全部解析自同一份载荷,五行 `(payload)` 回答的是无人提出的 问题。默认只列出来源不是编译器载荷的层;`MCPP_VERBOSE=1` 列出全部; **诊断始终列出它所依据的每一层**,包括平凡的那些 —— 省略证据的错误信息 无法被读者复核。 Target x86_64-linux-gnu ← 零配置:一行 ── 6. Family 去掉 OpenkalLlvm ────────────────────────────── 它命名同一份 llvm 载荷,携带的是一条关于目标侧的事实,而 `mcpp.targetside` 从包的声明中解析该事实。保留枚举项的代价不止是一条死分支: 可用工具链列表按族枚举,一份载荷挂在两个族名下就出现两次, 而安装状态按族记录 ⇒ **第二份被报成未安装,并被推荐给已经装了它的人。** 拼写归一为 `llvm` 保留(e2e 269 守着)。 ── 7. 词表 pin 替换用户默认时,说出来 ────────────────────── ⚠️ 让全局默认压过词表 pin 的做法被实测否掉:一个无依赖的工程、全局默认 `llvm@22.1.8`、`--target x86_64-windows-gnu`,**从能构建变成不能构建** —— clang 单独不携带该目标的 C 运行时,而行所命名的载荷携带。那是升级把一个 可用的构建变成不可用的。 行所 pin 的不是「偏好的编译器」,而是「供给该目标 C 库的载荷」; 用户的默认能否代替它,取决于是否有别的东西供给目标侧 —— 而那要到依赖图 解析之后才知道。因此保留行为,并补上两条它一直欠缺的话: Resolved gcc@16.1.0 → x86_64-windows-gnu → … target default for x86_64-windows-gnu, replacing your llvm@22.1.8 — override with `[target.x86_64-windows-gnu] toolchain` warning: this project's target side comes from its dependency graph, so llvm@22.1.8 would have served x86_64-windows-gnu. 第二条在图已知之后发出 —— 那是「这次替换本可不必」第一次可判定的时刻。 ⚠️ 结构性修法是把 pin 的决定与目标侧一样后移。未在本次落地:`tc` 在解析后到 图之间被读写 **39 处**(有效三元组、cross flag、目标 sysroot、MSVC 运行时契约), 在那里重解析会让它们乱序重做。 ── 兼容性 ──────────────────────────────────────────────── * `hosted-standard-library` 继续表示 C++ 层; * `openkal-llvm` 拼写继续解析; * `[package]` 下的三个 std-module 键继续被接受; * 实测:**旧引擎(2026.8.24.1)读带 `requires` 的清单构建成功** —— TOML 侧忽略未知键,xpkg 侧警告而非报错。已发布的包因此可以先行声明。 ── 测试 ────────────────────────────────────────────────── 单元:test_targetside 26 个(五层 × 四来源、能力语法五层全覆盖、 规则一二各自的诊断文本),全套 93 passed / 0 failed。 e2e:新增 280(五层与报告收窄)、281(两条规则各自的拒绝 + 包不得供给 compiler); 268/269 的断言改用 MCPP_VERBOSE 读取全栈 —— 它们的意图不变, 变的是默认报告不再打印载荷层。 ⚠️ 本机 e2e 279 条中 26 条红,**逐条与基线二进制对照后全部为既有失败** (本机全局默认是 llvm,而它们断言 GCC 的 `gcm.cache`)。CI 是判据。 设计文档:.agents/docs/2026-08-24-target-side-design.md 规范:docs/spec/target-side.md(SPEC-002) 使用文档:docs/14-target-side.md + docs/zh/14-target-side.md --- ...-24-graph-target-side-optimization-plan.md | 2035 +++++++++++++++++ .../2026-08-24-target-side-architecture.md | 1090 +++++++++ .agents/docs/2026-08-24-target-side-design.md | 598 +++++ docs/05-mcpp-toml.md | 30 + docs/14-target-side.md | 293 +++ docs/README.md | 1 + docs/spec/README.md | 1 + docs/spec/target-side.md | 177 ++ docs/zh/05-mcpp-toml.md | 27 + docs/zh/14-target-side.md | 249 ++ docs/zh/README.md | 1 + mcpp.toml | 2 +- src/build/prepare.cppm | 268 ++- src/manifest/toml.cppm | 32 +- src/manifest/types.cppm | 33 +- src/manifest/xpkg.cppm | 21 +- src/targetside/model.cppm | 309 ++- src/toolchain/compat.cppm | 12 +- src/toolchain/model.cppm | 17 + src/toolchain/registry.cppm | 27 +- src/version.cppm | 2 +- tests/e2e/268_target_side_resolution.sh | 11 +- ...69_openkal_llvm_spelling_still_resolves.sh | 5 +- tests/e2e/280_target_side_layers.sh | 57 + tests/e2e/281_target_side_rules.sh | 134 ++ tests/unit/test_targetside.cpp | 155 +- 26 files changed, 5480 insertions(+), 107 deletions(-) create mode 100644 .agents/docs/2026-08-24-graph-target-side-optimization-plan.md create mode 100644 .agents/docs/2026-08-24-target-side-architecture.md create mode 100644 .agents/docs/2026-08-24-target-side-design.md create mode 100644 docs/14-target-side.md create mode 100644 docs/spec/target-side.md create mode 100644 docs/zh/14-target-side.md create mode 100755 tests/e2e/280_target_side_layers.sh create mode 100755 tests/e2e/281_target_side_rules.sh diff --git a/.agents/docs/2026-08-24-graph-target-side-optimization-plan.md b/.agents/docs/2026-08-24-graph-target-side-optimization-plan.md new file mode 100644 index 00000000..8d411793 --- /dev/null +++ b/.agents/docs/2026-08-24-graph-target-side-optimization-plan.md @@ -0,0 +1,2035 @@ +# 目标侧来自依赖图之后:七项优化方案 + +2026-08-24。本文由一次真实使用暴露的十条问题写成 —— 一个 hello-world 工程, +`[dependencies]` 里只加了一行 `openkal-llvm-runtime`,随后在四个目标上连续失败。 + +**基准版本**:`origin/main` = `2026.8.24.1`(含 `01d6cef`「Resolve the target side +once, per layer, after the dependency graph is known」)。本文所有行号指该版本。 + +**本文的每一条根因都读过码,每一条性能数字都实测过。** 实测环境:Linux x86_64, +`llvm@22.1.8`,工程 `test3`(1 个 `src/main.cpp`,1 条依赖)。 + +--- + +## 0. 一句话论断 + +`01d6cef` 把「目标侧从哪来」这个问题**收敛到了一处解析**,但它的**五个消费者** +仍然停在旧模型上: + +| 消费者 | 仍然假设 | 后果 | 节 | +|---|---|---|---| +| 三元组的命名语义 | `env` 段说明 C 库是谁 | **名字说 `-gnu`,事实是 musl** | §4 | +| 工具链的命名空间 | 目标侧策略可以当编译器族 | `openkal-llvm` 叫你去装一个已装的东西 | §4.4 | +| 工具链轴(选哪个编译器) | 由载荷矩阵决定,图解析之前就知道 | gcc 去编 libc++ 的 std 模块 | §2 | +| 目标词表(哪些三元组存在) | 存在 ⇔ 本机有载荷伺候 | `x86_64-windows-musl` 被拒,而它能跑 | §3 | +| flag 派生(发什么 flag) | 判据是目标格式 | clang-only flag 递给 mingw g++ | §2 | +| 包的 `cfg` 谓词 | 同第一行 | Win32 导入库整组丢失 | §4B | +| 缓存身份 | 键可以含绝对路径与三元组拼写 | 同一份产物存了六份 | §6 | + +⚠️ 这不是七个 bug,是**一次模型迁移只做了一半**。逐条修会把同一句话说七遍; +本文按「让每个消费者也去问那一处」来组织。 + +⭐ **§4 是根**:三元组把「C 库是谁」编进了名字,而 openkal 把它交给了图。 +§2、§3、§4B 都是这一条的下游。 + +⭐⭐ **§12 是这件事的全景**,并且它有一份现成的对照实验: +`mcpp#492` + `xim-pkgindex#677` 正在用**载荷模型**做 openkal 用**图模型** +做过的同一件事 —— 349 行引擎改动 vs 0 行。读 §12 之前先读 §4。 + +⚠️ 另有两项与 openkal 无关、但被同一次使用暴露的**构建性能缺陷**(§5、§6), +它们的量级比上面五条都大。 + +--- + +## 1. 实测数据(先摆事实) + +### 1.1 一次全缓存命中的 clean build:1.81 s + +`mcpp clean && mcpp build`,逐行打时间戳: + +``` + 0 ms | Resolving toolchain + 1 ms | Resolved llvm@22.1.8 → …/xim-x-llvm/22.1.8/bin/clang++ + 53 ms | [VERBOSE] probe: payload paths: … ← 编译器探测 50ms + 57 ms | [VERBOSE] index: openkal-llvm-runtime@0.1.1: resolvable locally + 261 ms | build.mcpp compiling ← ⚠️ 204ms 完全静默 + 311 ms | build.mcpp running + 335 ms | Target x86_64-unknown-linux-gnu + 348 ms | [VERBOSE] scan: scanning module sources + 736 ms | Inferred sources […] ← ⚠️ 388ms 扫描+校验+plan + 740 ms | Cached openkal-llvm-runtime v0.1.1 (243 units) + 1075 ms | [VERBOSE] build/stage: compile-commands: 333ms ← ⚠️ + 1747 ms | [VERBOSE] build/stage: ninja: 662ms ← ⚠️ + 1833 ms | [VERBOSE] build/stage: loader-tags: 37ms +``` + +对照: +- no-op build(fast path 命中):**35 ms** +- 冷构建(缓存未命中):**4.9 s 墙钟 / 44 s CPU** +- `--offline` 与联网**逐毫秒相同** ⇒ 稳态无网络开销 + +### 1.2 构建图的形状 + +``` +$ grep -oE "^build [^:]*: [a-z_]+" build.ninja | awk '{print $NF}' | sort | uniq -c + 1624 stage_file + 1 phony + 1 cxx_scan + 1 cxx_object + 1 cxx_link + 1 cxx_dyndep +``` + +**1629 条边里 1624 条是 `stage_file`**,而 `stage_file` 的 rule 是: + +```ninja +rule stage_file + command = $mcpp stage $verify --output $out $in +``` + +即**每拷贝一个文件启动一次 mcpp 进程**。实测单次进程启动 ~2.1 ms +(200 次 `mcpp --version` = 0.428 s)⇒ 1624 × 2.1 ms ≈ **3.4 s CPU**, +并行摊到多核后就是那 662 ms。核心数越少越难看。 + +### 1.3 `compile_commands.json` + +一个只有 **1 个源文件**的工程,产出 **3235 条、8.4 MB**,每次构建全量重写, +耗时 **333 ms**(占墙钟 18%)。3234 条是用户永远不会打开的依赖 TU。 + +### 1.4 全局缓存的实际状态 + +``` +build-cache/v1 12 GB pkg 7.5 GB (395 条) std 3.9 GB (136 条) tool 48 MB +``` + +⚠️ **缓存本身是好的**:`mcpp clean` 后重建打印 +`Cached openkal-llvm-runtime v0.1.1 (243 units)`,1.02 s 完成。 +用户感受到的「每次都重编」是**键在碎**,不是没缓存。 + +碎在哪(实测,取 `target_triple = x86_64-unknown-linux-gnu` 的 25 条 std 条目 +逐字段做直方图): + +| 字段 | 不同取值数 | 说明 | +|---|---|---| +| `std_module_source_hash`(内容) | **2** | 真正的身份 | +| `std_module_source`(绝对路径) | **6** | ⚠️ 冗余,且它在碎键 | +| `compiler_version` | 2 | 合理 | + +六个路径分别是:本地开发 checkout、git clone、registry 0.1.0、registry 0.1.1、 +llvm 载荷 22.1.8、llvm 载荷 20.1.7 —— **其中四个的 std.cppm 内容完全相同**。 + +同一目标的两套拼写也各自成条目: + +``` +25 条 x86_64-unknown-linux-gnu 18 条 x86_64-windows-gnu +17 条 x86_64-linux-gnu 1 条 x86_64-w64-mingw32 + 1 条 x86_64-windows-musl +``` + +⭐ `x86_64-windows-musl` 与 `x86_64-windows-gnu` 的两条 std 条目 +**除 triple 字段外逐字段相同、各占 32.7 MiB** —— 这是本文 §3 的直接证据: +两个三元组交给编译器的是**同一个 LLVM triple**。 + +pkg 侧同样:`mcpplibs.xpkg@0.0.57` × 14、`compat.gtest@1.15.2` × 11、 +`compat.zlib@1.3.2` × 10、`compat.mbedtls@3.6.1` × 9。 + +另有 **44 条 `(incomplete)` 0 字节条目**,最老 12 天,从不回收。 + +--- + +## 2. 【架构·高】目标侧是四层,不是三层 —— 编译器必须进 `TargetSide` + +### 2.1 成因 + +`src/targetside/model.cppm:224-244` 的 `Inputs` **没有编译器这一项**。 +`resolve()` 因此无法表达「这份 C++ 运行时能不能被这个编译器消费」。 + +`check_layering()`(:309)只守一个方向: + +```cpp +if (ts.cxx.origin == Origin::Payload && ts.cAbi.origin != Origin::Payload && …) +``` + +即「载荷的 C++ 运行时 × 非载荷的 C 库」。用户的情况是 +`cxx.origin == Graph`,直接返回 `nullopt`。 + +而这条约束**在代码里写下来了,没有任何东西执行它** +—— `src/build/prepare.cppm:1522` 的注释原文: + +> Measured 2026-08-23: `--target x86_64-windows-gnu` with an explicit `llvm@22.1.8` +> resolved `x86_64-w64-mingw32-g++`, and **gcc cannot compile libc++'s std module**. + +⚠️ 这是这个仓库反复付过学费的形状:**结论会被复查,理由不会**; +**写在注释里的约束没有任何东西在执行它**。 + +近因还有一层,可定位到一行:`src/toolchain/gcc.cppm:159` +`std_module_build_command()` 拼命令时**从不读 `tc.stdModuleFlags` / +`tc.stdModuleTargetFlags`**,只有 `clang.cppm:205/360` 读。于是 +`prepare.cppm:5872-5884` 辛苦收集的包侧 `-isystem` / `-idirafter` / +defines / `--target=` 在 GCC 后端上全部落地即丢。用户看到的命令行 +一个 `-isystem` 都没有,正是这个。 + +### 2.2 设计 + +给 `CapLayer` 加第四项 `Compiler`,并**引入与 `provides` 对称的 `requires`**: + +```toml +# openkal-llvm-runtime/mcpp.toml +provides = ["mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] +``` + +⭐ 判据不变:**mcpp 硬编码层名,永不硬编码实现名**。`llvm` 出现在包的 +manifest 里,不出现在引擎里 —— 这正是 `targetside/model.cppm:127-138` +自己立的规矩。`parse_capability` 已经是这个形状,加一个 `else if` 即可。 + +`Inputs` 加一个 `std::string compilerFamily`;`resolve()` 填 `ts.compiler`; +`check_layering()` 增加一条:图供给的层若声明了 `mcpp:compiler=X` 而当前 +编译器族不是 X,拒绝并说明。 + +### 2.3 实施步骤 + +1. `targetside/model.cppm`:`CapLayer::Compiler`、`Layer compiler`、 + `Inputs::compilerFamily`、`resolve()` 一分支、`check_layering()` 一条。 +2. `manifest/xpkg.cppm`:解析 `requires` 数组,复用 `parse_capability`。 + ⚠️ **未知键必须被忽略而不是让整份 manifest 加载失败** —— 见 + [`#359 provisions-reexport`] 的教训:老客户端遇到新键会整包解析失败。 +3. `prepare.cppm:5495-5519`:填 `in.compilerFamily`。 +4. `gcc.cppm:159`:消费 `tc.stdModuleFlags`(即使 §2.2 会拒绝这个组合, + 这条对未来「图供给 libstdc++」仍然必要 —— 现在是静默丢弃)。 +5. `tests/unit/test_targetside.cpp`:表驱动新增 4 行 + (gcc×图libc++ / clang×图libc++ / gcc×载荷libstdc++ / 图无 requires)。 + +### 2.4 判据 + +- `mcpp build`,默认 gcc,依赖含 `openkal-llvm-runtime` ⇒ 在**编译开始之前** + 报出「这份 C++ 运行时需要 llvm 族编译器」,并给出可粘贴的修法。 +- 单元测试**不启动任何构建**即可断言该矩阵(这是 `targetside` 模块被拆出来的 + 原因:「everything here can be asserted from a table」)。 + +### 2.5 刻意不做 + +不在引擎里写 `if (stdlib == "libc++" && compiler == gcc)`。那是把产品名放进引擎, +`fam == "openkal-llvm"` 已经因此被删过一次。 + +--- + +## 3. 【架构·高】目标词表拆成两张:能力表 vs 载荷表 + +### 3.1 成因 + +`src/toolchain/triple.cppm:171` 的 `kKnownTargets` 同时承担两个含义: + +1. 「mcpp 认识这个三元组」 +2. 「本机有载荷能产出它」 + +`prepare.cppm:1405` 的硬拒用的是第 1 个含义,而表的内容是按第 2 个含义填的。 +于是 `x86_64-windows-musl` 被拒 —— 尽管: + +⭐ **实测:它能跑。** 按报错自己给的逃生口写 +`[target.x86_64-windows-musl] toolchain = "llvm@22.1.8"`,mcpp 全部编译通过, +只在链接期缺四个导入库(§4)。补上后: + +``` +Finished dev in 2.75s +test3.exe: PE32+ executable (console) x86-64, 14 sections +imports: ntdll.dll / api-ms-win-core-synch-l1-2-0.dll / SHELL32.dll / KERNEL32.dll +wine test3.exe → Hello from test3! +``` + +而且两个三元组交给编译器的是**同一个 LLVM triple**: + +``` +Target x86_64-windows-gnu → x86_64-w64-windows-gnu +Target x86_64-windows-musl → x86_64-w64-windows-gnu +``` + +⚠️ 同一文件里,**这个问题的另一半已经学会了推迟**:`prepare.cppm:1439-1454` +把「本机载荷伺候不了」的拒绝推迟到图已知之后,理由写得很清楚 —— +「a dependency can supply the target's platform interface and C library, and the +dependency graph does not exist yet at this line」。**「未知目标」这条是同一句话, +却还在图之前开火。** + +附带缺陷:`did_you_mean`(:518)是纯 Levenshtein。`musl → msvc` 距离 3, +预算 `max(2, 19/4) = 4` ⇒ 建议了**唯一一个 C 库与 ABI 都相反**的目标。 + +### 3.2 设计 + +- **能力**:`triple::parse()` 已经是答案。`x86_64-windows-musl` 解析成功。 + 解析失败(`x86_64-linuxx-gnu`)仍然立刻拒 —— 打字错误这条守住了。 +- **可服务性**:载荷 **或** 图,只有解析之后才知道。 +- `kKnownTargets` 退回它真正擅长的两列:**约定 pin** 与 **默认链接方式**。 + 没有行 = 没有约定,而这对图供给的目标恰好是正确语义。 + +`--target <可解析三元组>` 一律放行;把 `unservedTargetDiagnosis` 的 +`known` 前置条件去掉,让「无行」也走同一条推迟路径。 + +⭐ 这一处改动同时消掉:未知目标硬拒、错误的 did-you-mean、以及 +「为 windows-musl 加一行」的必要性。并且它泛化 —— `aarch64-linux-musl`、 +`riscv64-linux-musl`、`x86_64-macos` 在图能伺候它们的那天自动可达, +不需要再改表。 + +### 3.3 实施步骤 + +1. `prepare.cppm:1400-1411`:未知但可解析 ⇒ 不再 `return std::unexpected`, + 改为写入 `unservedTargetDiagnosis`。 +2. `prepare.cppm:1459`:去掉 `known &&` 前置条件。 +3. `triple.cppm:518` `did_you_mean`:输入可解析且 `env` 是已知拼写时, + **不建议 `env` 不同的目标**。 +4. `mcpp toolchain list`:Targets 表加 STATUS 值 `graph`(见 §7.2)。 + +### 3.4 判据 + +- Linux 上 `mcpp build --target x86_64-windows-musl`,工程依赖含 openkal ⇒ 构建成功。 +- Linux 上 `mcpp build --target x86_64-macos`,工程**不含** openkal ⇒ 仍然拒绝, + 且报错里出现「nothing in the dependency graph supplies its system side」。 +- `mcpp build --target x86_64-linuxx-gnu` ⇒ 立刻拒(解析失败)。 + +### 3.5 刻意不做 + +不给 `kKnownTargets` 加 `x86_64-windows-musl` 行。加一行只解决一个三元组, +而问题是这张表被当成了两张表用。 + +--- + +## 4. 【架构·最高】三元组与工具链的语义:名字在说谎 + +用户原话:「工具链和 target 在语义设计方面也有点乱,例如 `-gnu` 但是却是 `-musl`」。 + +这一条是本文里**最根本**的一条 —— §2、§3 都是它的下游。 + +### 4.0 现象:名字与事实矛盾,而 mcpp 把两者并排打印出来 + +``` +Target x86_64-windows-gnu → x86_64-w64-windows-gnu + kernel-abi openkal (openkal-windows@0.1.3, graph) + c-abi musl (openkal-musl@0.3.3, graph) ← 名字说 gnu +``` + +⚠️ **这不是 Windows 特有的。** 同一工程在宿主上: + +``` +Target x86_64-unknown-linux-gnu + c-abi musl (openkal-musl@0.3.3, graph) ← 同样说谎 +``` + +### 4.1 成因:三元组用 3 个字段承载 4 条正交的轴 + +真正正交的是四条: + +| 轴 | 取值 | 谁决定 | +|---|---|---| +| 1 机器 | x86_64 / aarch64 / riscv64 | 用户 | +| 2 平台接口 | linux / windows / macos / none | 用户 | +| 3 对象格式 + 调用 ABI | ELF-SysV / PE-GNU / PE-MSVC / Mach-O | 用户 | +| 4 C 库(及其上的 C++ 运行时) | glibc / musl / picolibc / 无 | **载荷 或 图** | + +`--` 只有三个字段,于是 `env` 一人干两份活: + +- Linux 上 `env` 指**第 4 轴**(`gnu` = glibc,`musl` = musl) +- Windows 上 `env` 指**第 3 轴**(`gnu` = PE/GNU ABI,`msvc` = PE/MSVC ABI) + +这个重载是从 GNU/LLVM 三元组继承来的,在载荷模型下一直凑合能用 —— +因为第 4 轴由载荷决定,而载荷是按三元组选的,所以名字碰巧总是对的。 + +⭐ **openkal 把第 4 轴交给了图,重载就此破产**:名字仍然按第 4 轴拼写, +而第 4 轴已经不由名字决定了。 + +### 4.2 根因可以定位到一行 + +`src/toolchain/triple.cppm:514`: + +```cpp +if (t.os == "linux" && t.env.empty()) t.env = "gnu"; // "x86_64-linux" alias +``` + +⭐ **「env 未指定」这个状态在解析时就被销毁了。** 用户写 `x86_64-linux` +(意思是「C 库谁供给谁说了算」),`parse()` 把它改写成 `x86_64-linux-gnu` +(意思是「我要 glibc」),然后图供了 musl,报告就只能自相矛盾。 + +⚠️ 而**这个仓库已经知道正确形状**:`[target.X].sysroot` 就是标准的三态, +理由写在 `triple.cppm:207-217`: + +> `nullptr` -> the project said nothing; the target table's column applies +> `"xim:..."` -> the project named a different C library +> `""` -> the project asked for NO C library + +`env` 需要的是同一个三态,而 :514 把第一态折叠进了第二态。 + +旁证:**macOS 已经是对的**。`:513` `if (t.os == "macos") t.env.clear();` +—— macOS 的三元组本来就没有 env 段,而它工作得很好。空 env 是**已被验证 +可行的状态**,不是新发明。 + +### 4.3 设计:三元组是「请求」,`TargetSide` 是「事实」 + +**规则一:`env` 空 = 未指定,不再自动填充。** + +- `x86_64-linux` = 「C 库由载荷或图决定」← 图模式下这是**正确写法** +- `x86_64-linux-gnu` = 「我要求 glibc」 +- `x86_64-linux-musl` = 「我要求 musl」 +- Windows 同理:`x86_64-windows` = 「ABI 由供给方决定」 + +⚠️ 兼容性:`x86_64-linux-gnu` 在**没有图供给**时行为必须逐字节不变 +(仍然是 glibc 载荷)。这条改的是「空」的含义,不是「非空」的含义。 + +**规则二:请求与事实冲突 ⇒ 拒绝,而不是并排打印。** + +``` +error: this build requests the `gnu` C ABI, and its dependency graph supplies `musl`. + requested gnu (from --target x86_64-linux-gnu) + resolved musl (openkal-musl@0.3.3, graph) + A C library is not interchangeable at this seam: the C++ runtime above it + was configured against one of them. + Write `--target x86_64-linux` to let the graph decide, or remove the + package that supplies the C ABI. +``` + +⭐ 判据方向与 §3 相反,而这是刻意的:§3 是「不可证伪就放行」(未知目标推迟 +到图已知),这里是「已经证伪就拒绝」(名字和事实都已知且矛盾)。 + +**规则三:报告里标出请求,只在它与事实不同时。** + +``` +c-abi musl (openkal-musl@0.3.3, graph) +``` +未指定时如上;若显式请求且一致,加 `, as requested`;不一致则走规则二。 + +**规则四:`cfg()` 按事实求值,不按名字。** + +包想问的是「C 库是不是 musl」,现在只能问 `env == "musl"`,而在图模式下 +那个字段已经不回答这个问题。增加 `cfg(c-abi = …)` / `cfg(kernel-abi = …)` +/ `cfg(c++-abi = …)`,由 `TargetSide` 求值。 + +⚠️ **时序限制,必须显式拒绝而不是留给用户去撞**:`cfg()` 现在在依赖解析 +**期间**求值,`TargetSide` 在解析**之后**才有。新三个维度只能用于 `[build]` +段(flags / ldflags / defines),**不能用于 `[dependencies]`**,否则成环。 +解析器要为此报一条专门的错。 + +### 4.4 工具链侧的同一个病:`openkal-llvm` 不是一个编译器族 + +`src/toolchain/registry.cppm:46` + +```cpp +enum class Family { Gcc, Llvm, Msvc, OpenkalLlvm }; +``` + +而 :411 自己说明: + +> ⭐ **THE SAME PAYLOAD.** `openkal-llvm` downloads nothing of its own and +> installs nothing of its own — it is **a statement about where the TARGET SIDE +> comes from**, and the compiler is the llvm payload either way. + +⇒ 一个**目标侧策略**被塞进了**编译器族**的命名空间。后果直接可见: + +``` +Available toolchains (run `mcpp toolchain install `): + openkal-llvm 22.1.8 / 20.1.7 ← 叫你去装一个已经装好的东西 +``` + +`available_toolchain_indexes()`(:623-631)把同一个 llvm 包**列了两次**, +一次记 `Family::Llvm` 一次记 `Family::OpenkalLlvm`;而 `toolchain list` 的 +「已装」判定比的是 `f.family == idx.family`(`lifecycle.cppm:632`), +于是永远认为 `openkal-llvm` 没装。 + +**修法**: + +- `Family` 回到三个:`Gcc / Llvm / Msvc`。 +- `openkal-llvm` 降级为**纯拼写别名** → `llvm`。 + ⚠️ 已有 e2e `269_openkal_llvm_spelling_still_resolves.sh` 守这条,别删。 +- 目标侧从哪来**不再由工具链名字表达**,它由图表达 —— 那本来就是 + `01d6cef` 的结论,只是命名空间没跟上。 +- Available 段按**载荷包名**去重,不按 Family。 + +### 4.5 实施步骤 + +1. `triple.cppm:514`:删掉自动填充;`Triple::env` 的注释改写为「请求,空 = 未指定」。 + ⚠️ 全仓库搜 `env.empty()` 与 `is_musl()` / `is_windows_gnu()`, + 逐处判断「空」该走哪支 —— 这是本节风险最大的一步。 +2. `targetside/model.cppm`:`Inputs` 加 `requestedCAbi`;新增 `check_request()`。 +3. `prepare.cppm`:在 `check_layering` 旁边调用 `check_request()`。 +4. `manifest/types.cppm`:`cfg()` 语法加三个维度 + `[dependencies]` 里的拒绝。 +5. `registry.cppm`:`Family::OpenkalLlvm` 删除,`openkal-llvm` 走别名表。 +6. `lifecycle.cppm:623-631`:Available 段按包名去重。 + +### 4.6 判据 + +- `mcpp build --target x86_64-linux`,依赖含 openkal ⇒ 成功, + 报告写 `c-abi musl (openkal-musl@…, graph)`,**不出现 `-gnu`**。 +- `mcpp build --target x86_64-linux-gnu`,依赖含 openkal ⇒ **拒绝**, + 报错含「requested gnu / resolved musl」。 +- `mcpp build --target x86_64-linux-gnu`,**不含** openkal ⇒ 与今天逐字节相同。 +- `mcpp toolchain list`:装了 llvm 时,`openkal-llvm` **不出现**在 Available。 +- `mcpp toolchain default openkal-llvm@22.1.8` ⇒ 仍然解析成功(别名)。 + +### 4.7 刻意不做 + +**不发明新的三元组语法**(不搞 `x86_64-linux+musl` 之类)。三元组是用户会 +打字的东西,也是目录名和 `cfg` 的 key;它的价值在于和 LLVM/GNU 的写法互认。 +本节只做两件事:**恢复「空」这一态**,以及**把事实从名字里移到 `TargetSide` 里**。 + +**不改 `llvm_triple()` 的输出。** 交给编译器的仍然是四段 LLVM 拼写; +`x86_64-windows-gnu` 与 `x86_64-windows-musl` 今天就都翻译成 +`x86_64-w64-windows-gnu`(实测),这是对的 —— 第 4 轴本来就不该进 +编译器的 triple。 + +--- + +## 4B. 【跨平台·高】包的 cfg 谓词是上一节的直接受害者 + +### 4B.1 成因(这是本次最干净的一条) + +两份 `build.ninja` 的 `ldflags` 逐 token 对比,差别**只有四个**: + +``` +windows-gnu : … -Wl,-e,okw_start -lntdll -lsynchronization -lshell32 -lkernel32 -L… +windows-musl: … -Wl,-e,okw_start -L… +``` + +来源是包侧,`openkal-windows/mcpp.toml:41`: + +```toml +[target.'cfg(all(windows, env = "gnu"))'.build] +ldflags = ["-lntdll", "-lsynchronization", "-lshell32", "-lkernel32"] +``` + +包的注释说明了意图:这四个是为了区分「GNU/PE ABI」与「MSVC ABI」 +(「the two application binary interfaces this environment has spell a library +differently」)。但它用 `env = "gnu"` 当代理。 + +⭐ **这四个是 Win32 导入库,是 kernel-abi 的性质,不是 C 库的性质。** +而 mcpp 自己在 `x86_64-windows-gnu` 上打印的就是: + +``` +kernel-abi openkal (openkal-windows@0.1.2, graph) +c-abi musl (openkal-musl@0.3.2, graph) +``` + +**三元组说 gnu,实际 C 库是 musl。三元组在说谎,而包的 cfg 在信它。** + +### 4B.2 设计:落在 §4.3 规则四上 + +包想问的是「C 库是不是 musl」/「对象 ABI 是不是 MSVC」。今天它只有 +`env` 一个字段可问,而那个字段在图模式下已经不回答第一个问题。 + +⇒ 修法就是 §4.3 的规则四:`cfg(c-abi = …)` / `cfg(kernel-abi = …)` / +`cfg(c++-abi = …)`,由 `TargetSide` 求值。 + +落点是现成的:`cfg()` 的求值输入**已经是「解析后的 target」而不是 host**, +把 `TargetSide` 的三层灌进求值环境即可。 + +⚠️ 时序限制见 §4.3 规则四:新维度只能用于 `[build]` 段, +`[dependencies]` 里必须显式拒绝而不是静默为假。 + +⚠️ **`env` 本身仍然保留**,因为第 3 轴(对象格式 + 调用 ABI)确实需要一个 +字段,而 `gnu`/`msvc` 在 Windows 上一直就是这个意思。变的是: +**Linux 上的 `env` 不再是 C 库的事实来源** —— 它只是一个请求(§4.3 规则一)。 + +### 4B.3 立即可做(不依赖引擎) + +`openkal-windows` 的 cfg 改成: + +```toml +[target.'cfg(all(windows, not(env = "msvc")))'.build] +``` + +⭐ **已实测**:单 token 改动后 `x86_64-windows-musl` 全绿(§3.1 的产物即出自此)。 +`not()` 在当前 cfg 语法里已支持(`docs/05-mcpp-toml.md:849`)。 +这是包侧 PR,提到 `mcpplibs/openkal-windows`,不排队等引擎。 + +### 4B.4 判据 + +- 引擎侧:`cfg(c-abi = "musl")` 在 `x86_64-windows-gnu`(图供 musl)上求值为真。 +- 引擎侧:`cfg(c-abi = …)` 出现在 `[dependencies]` 里 ⇒ 明确报错,不是静默为假。 +- 包侧:`--target x86_64-windows-gnu` 与 `--target x86_64-windows-musl` + 产出的 `ldflags` 逐 token 相同。 + +--- + +## 5. 【性能·最高】staging:1624 条边 → 每包一条 + +### 5.1 成因 + +`stage_file` 成为独立 ninja 边是**正确的**,理由记录在 PR#317:ninja 判脏包含 +「输出存在但 `.ninja_log` 无该输出的命令行记录 ⇒ dirty」,把 staging 藏在 +`prepare_build` 里拷贝会让**缓存命中也 100% 重编**(那个假 `Cached` 骗了三个月)。 + +但「必须是一条边」不等于「必须是每文件一条边」。当前形态的代价: + +- 1624 次进程启动 × 2.1 ms ≈ **3.4 s CPU** +- 构建图 1629 条边,其中 99.7% 是拷贝 +- ninja 阶段 **662 ms**(本例 4 个包 / 243 units;真实工程会线性放大) + +### 5.2 设计 + +**按包分组:一个包一条 `stage_file` 边,多输出。** + +```ninja +rule stage_pkg + command = $mcpp stage --manifest $out.rsp + rspfile = $out.rsp + rspfile_content = $in_newline + restat = 1 +``` + +保住的性质: +- ninja 仍有可比对的命令行记录(rspfile 内容进哈希)⇒ PR#317 的正确性依据不动。 +- 粒度仍是「包」—— 缓存键变了就重放该包,这正是缓存条目的粒度,没有损失。 +- `restat = 1` 保留(参与 [`issue311-bmi-staging`] 的 mtime 抑制)。 + +⚠️ **必须用 rspfile**:243 个文件的命令行会撞 Windows 的 `cmd.exe` 8191 上限 +(#274 已经踩过)和 Linux 的 `MAX_ARG_STRLEN` 128 KiB(见 +[`link-argv-max-arg-strlen`])。 + +⚠️ **order-only phony 前置不能动**:所有 staged 产物经一个 phony 成为非 staged +边的 `||` 前置,这是为了修「包内模块序丢失」(macOS/Clang 会挂在 +`failed to find module file for module 'pkg:part'`,Linux 赢竞态所以本地全绿)。 +分组不影响这条,但**改完必须在 macOS 上跑**。 + +### 5.3 更进一步:零拷贝 + +同一文件系统上用 hardlink / `FICLONE` reflink 代替字节拷贝,`stage` 的成本从 +O(bytes) 降到 O(1)。⚠️ hardlink 会让缓存条目与 build dir 共享 inode —— +必须确认没有任何一步会**原地修改** staged 产物(`strip`、`elfpatch` 都会), +否则会污染缓存。安全形态:只对**不会被后续步骤改写**的产物(BMI)用链接, +对象文件仍拷贝;或统一改成 copy-on-write reflink。 + +### 5.4 判据 + +- 同一工程 `mcpp clean && mcpp build`,ninja 阶段耗时下降 ≥ 60%。 +- `build.ninja` 边数从 1629 降到 ~10。 +- ⚠️ **回归测试必须先 touch 源码或删产物**,否则工程级 fast path 会把改动 + 整个遮蔽掉(这条已经骗过两次)。 +- macOS + Clang 上跑一遍(模块序竞态只在那里输)。 + +--- + +## 6. 【性能·高】缓存身份:去掉路径,归一拼写,回收空壳 + +### 6.1 成因一:身份键含绝对路径 + +`src/toolchain/stdmod.cppm:157-172` 的 14 个身份字段里同时有: + +```cpp +"std_module_source", // 绝对路径 ← 冗余 +"std_module_source_hash", // 内容哈希 ← 真身份 +``` + +实测(§1.4):25 条同 triple 的条目里,内容哈希 **2 个取值**,路径 **6 个取值**。 +一个 33 MB 的 std 条目,仅仅因为同一份 `std.cppm` 换了个目录就重建一次。 + +同样的形状也在 `std_build_commands`(:171)里 —— 它内嵌包的每一条绝对 +`-I` / `-isystem`。版本变了该失效(对),但**路径变了不该失效**。 + +### 6.2 成因二:三元组两套拼写各自成条目 + +``` +25 条 x86_64-unknown-linux-gnu vs 17 条 x86_64-linux-gnu +18 条 x86_64-windows-gnu vs 1 条 x86_64-w64-mingw32 +``` + +`j["target_triple"] = tc.targetTriple`(:134)存的是**当时手里那个拼写**, +没有先过 `triple::parse(...).str()`。 + +⭐ 已证:`x86_64-windows-musl` 与 `x86_64-windows-gnu` 的两条 std 条目 +除 triple 字段外**逐字段相同**,各占 32.7 MiB。 + +### 6.3 成因三:44 条 0 字节 `(incomplete)` 条目 + +写入不是原子的:先建目录,产物落地失败就留下空壳,`gc` 也不清。 + +### 6.4 设计 + +1. **身份键去掉 `std_module_source` 与 `std_compat_source`**(路径), + 只保留两个 `_hash`。⚠️ 路径仍写进 metadata **供人阅读**,但不进 + `metadata_matches` 的 14 键列表。 +2. **`target_triple` 进键前先归一**:`triple::parse(tc.targetTriple)->str()`。 + 不可解析时保留原串(逃生口)。 +3. **`std_build_commands` 归一化后再进键**:把已知的不可变前缀 + (`$MCPP_HOME/registry/data/xpkgs/`、`$MCPP_HOME/build-cache/`)替换成 + 占位段。⚠️ 这与已有的 `kStdKeyPlaceholder` 自指解法同形,复用它。 +4. **原子提交**:落到 `.tmp-/` 再 `rename`。 + `gc` 无条件清理不含 metadata 的目录。 +5. `pkg` 侧同样审一遍 `cache_key.cppm` 的输入里有没有绝对路径与拼写。 + +### 6.5 判据 + +- 本机 `mcpp cache list`:std 条目从 136 降到 ~40,`build-cache/v1/std` + 从 3.9 GB 降到 ~1.2 GB。 +- 把 `openkal-llvm-runtime` 从 registry 换成同内容的 git clone 再构建 + ⇒ **命中**同一条 std 条目(现在是新建一条)。 +- `--target x86_64-linux-gnu` 与 `--target x86_64-unknown-linux-gnu` + 命中同一条条目。 +- 构建中途 `kill -9` ⇒ `cache list` 里不出现新的 `(incomplete)`。 + +### 6.6 刻意不做 + +不改 pkg 缓存的「每包 Merkle 键 + F 轴递归」策略。那条是对的,理由已经 +用三组手工对照证过(BMI 轴取窄=编译器硬报错,`.o` 轴取窄=**静默错对象** +⇒ 必须取保守侧)。本节只清理键里**不该在的输入**,不动键的**语义范围**。 + +--- + +## 7. 【易用·高】让工具说出它做了什么决定 + +### 7.1.1 实测:全局默认确实被词表 pin 覆盖,而绕法要按目标写一遍 + +核实(2026-08-24): + +``` +~/.mcpp/config.toml [toolchain] default = "llvm@22.1.8" +test3/mcpp.toml [target.x86_64-windows-gnu] toolchain = "llvm@22.1.8" +``` + +⇒ 用户已经把同一个值**写了两遍**才让 `--target x86_64-windows-gnu` 用上 llvm。 +第一遍(全局默认)被词表 pin 忽略,第二遍(`[target.X]`)才被认。 + +⭐ 这就是这条缺陷的实际代价:**绕法必须按目标重复一次**。四个目标就写四段, +而每一段写的都是「请用我已经设为默认的那个」。 + +### 7.1 `Resolved` 那行没说自己覆盖了谁 + +用户跑了 `mcpp toolchain default llvm`,mcpp 回了 +`Default set to llvm@22.1.8 (was: gcc@16.1.0)`。随后 +`mcpp build --target x86_64-windows-gnu` 打印 `Resolved gcc@16.1.0`,**不解释**。 + +成因:`prepare.cppm:433-444` + +```cpp +GlobalDefault, // config.toml [toolchain] default — mcpp's own default +inline bool tc_origin_is_user_explicit(TcOrigin o) { + return o == TcOrigin::ManifestToolchain || o == TcOrigin::TargetSection; +} +``` + +⚠️ 注释里那句「mcpp's own default」**不成立**:枚举里 `FirstRun` 是单独一项, +所以 `GlobalDefault` 只可能来自用户主动执行的 `mcpp toolchain default`。 +2026-08-23 那次修复(`pinWouldOverruleUser`,:1531)修的正是这条,但谓词划窄了。 + +**修法(两步)**: + +- 立即:`tc_origin_is_user_explicit` 纳入 `GlobalDefault`。 +- 结构:pin 与目标侧同批推迟 —— 图供给目标侧时,词表行的 pin 指的那份载荷 + 提供的头文件和 C 库,这个工程一样都不用,pin 本身就失去意义。 +- 无论走哪条,状态行必须写成: + ``` + Resolved gcc@16.1.0 (target pin for x86_64-windows-gnu, overriding default llvm@22.1.8) + ``` + +### 7.2 `mcpp toolchain list` 的三处误导 + +`src/toolchain/lifecycle.cppm:587-605` 的 Targets 表 = 已装载荷 + 词表, +再经 `host_can_serve`(`registry.cppm:583`,纯载荷谓词)过滤。后果: + +| 现象 | 事实 | +|---|---| +| 本机看不到 `aarch64-macos` | mcpp 自己 `5e3e1d9` 的实测表里,Linux 宿主产出过 aarch64 Mach-O | +| `x86_64-windows-gnu` 的 TOOLCHAIN 列写 `gcc 16.1.0` | 恰是这个工程**唯一不能用**的那个 | +| `openkal-llvm 22.1.8` 列在「Available(去装)」 | `registry.cppm:411` 明说它「downloads nothing, installs nothing」,载荷已装 | + +**修法**: +- Targets 表加 SOURCE 列:`payload` / `graph`。 +- 在**工程上下文**里(cwd 有 `mcpp.toml` 且有依赖)按图重算该表。 + 无工程时退回现状并注明「(payload only — run inside a project to see + what your dependencies add)」。 +- `openkal-llvm` 与 `llvm` 共享载荷 ⇒ Available 段按**载荷**去重,不按 Family。 + +### 7.3 「Resolved 之后 204 ms 静默」 + +不是把它变快(它是本地解析,`--offline` 逐毫秒相同),是把它**说出来**。 +当前 204 ms 依赖解析 + 388 ms 扫描校验 plan = **近 600 ms 无任何输出**, +用户感知为卡顿。加两条状态行: + +``` + Resolving dependencies ← 覆盖那 204ms + Scanning module sources (N packages) ← 覆盖那 388ms +``` + +真要变快:依赖解析可按 `(manifest hash, index version)` 记忆化。 +⚠️ 落点应该是 `mcpp.lock` —— 而它现在**只写不读**,构建路径从不读它, +`mcpp update` 因此是空操作。这一条单独立项,不塞进本文。 + +### 7.4 `compile_commands.json`:333 ms / 8.4 MB / 3235 条 + +一个源文件的工程不该产出这个。加 `[build] compile-commands`: + +- `"workspace"`(**新默认**):只写工作区成员的 TU。本例 3235 → 1。 +- `"all"`:现状,给需要跳进依赖读源码的人。 +- `"off"`。 + +外加:内容不变则不重写(现在每次全量重写)。 + +--- + +## 8. 【文档·高】openkal 在 `docs/` 下等于不存在 + +实测统计(`origin/main`): + +| 检索 | 出现次数 | +|---|---| +| `mcpp:kernel-abi` / `mcpp:c-abi` / `mcpp:c++-abi` 在 `docs/` | **0** | +| `openkal` 在 `docs/` | **3**,全在 `13-baremetal.md`,全是讲 `alloc-kal` | +| `examples/` 下 openkal 示例 | **0** | +| `mcpp new` 的 openkal 模板 | **0** | + +而 `docs/03-toolchains.md:115` 与 `README.md:380` 仍把 `x86_64-windows-gnu` +描述为「MinGW-w64 / gcc 16」,**一个字都没提**目标侧来自依赖图时所需的编译器族会变。 + +⇒ 一个用户在 `[dependencies]` 里加一行,就跨进了一套完全不同的解析规则, +而没有任何文档提到这件事发生了。 + +**要写的三样**(⚠️ 中英双份 + 标题层级是 CI 强制的): + +1. `docs/14-openkal.md` / `docs/zh/14-openkal.md` + - 「目标侧从哪来」:载荷模型 vs 图模型,以及 `Target` 报告怎么读 + - `provides = ["mcpp:"]` 语法(§2 落地后加 `requires`) + - 何时需要换编译器族,以及为什么 + - 已知可达的目标矩阵 —— ⚠️ **写实测过的,不写推断的** +2. `examples/06-openkal-cross/`:一份 `mcpp.toml` + 一条命令跨到三个目标, + README 附实测产出(`file` 输出 + 导入表)。 +3. `docs/03-toolchains.md` 与 `README.md` 的目标表加一列「目标侧来源」。 + +⚠️ 文档里的每一条可粘贴命令都是**承诺**,包括其中的版本号。 +这条已经复发过两次(`compat.std-freestanding@0.1.0` 在索引里不存在, +照着粘贴直接失败)。示例里的版本号必须与索引当天的 latest 一致, +并在发布新版时同批更新。 + +--- + +## 9. 【稳定性】测试矩阵漏掉的正是默认路径 + +`aa891f8` 建了 openkal 的 3×3 交叉验证工作流,但它**只跑 openkal-llvm**。 + +用户走的是默认路径:词表 pin ⇒ `gcc@16.1.0`。**「图目标侧 × gcc」这一格 +从来没有被任何测试覆盖过**,而它是零配置用户会走到的第一条路。 + +⚠️ 同一教训在这个仓库已经犯过两次:**断言要放到矩阵的「行」一级**。 + +**要加的格**: + +| 目标 | 编译器 | 期望 | +|---|---|---| +| `x86_64-linux-gnu` | gcc(默认) | ⇒ §2 的拒绝诊断,**不是**编译器原话 | +| `x86_64-windows-gnu` | gcc(词表 pin) | ⇒ §2 的拒绝诊断 | +| `x86_64-windows-musl` | llvm | ⇒ 构建成功(§3 + §4) | +| `aarch64-macos` | llvm,Linux 宿主 | ⇒ 构建成功且出现在 `toolchain list`(§7.2) | + +⚠️ e2e 的 `# requires:` 行必须真的能匹配,否则测试从未跑过而 CI 全绿 +—— `65_*` 曾因死 token 一次都没跑。加完新格后逐条确认 skip 计数。 + +--- + +## 10. 优先级与依赖 + +``` +第 0 批 —— 立即、无依赖、可单独提 PR + §4B.3 openkal-windows 的 cfg (包侧 PR,已实测,一个 token) + §4.4 Family 去掉 OpenkalLlvm (纯命名,别名表兜底,e2e 269 已在守) + §7.1 GlobalDefault 纳入 explicit + 状态行说出覆盖 + §7.4 compile_commands 默认收窄 (333ms + 8.4MB,改动面小) + +第 1 批 —— 性能,与语义解耦,可并行 + §5 staging 按包分组 ← 收益最大(1624 → ~10 条边) + §6 缓存身份去路径 / 归一拼写 / 原子提交 + §7.3 两条状态行(补上 600ms 的静默) + +第 2 批 —— 语义地基(⭐ 这是根,后面都依赖它) + §4 env 恢复三态 + 请求/事实分离 + check_request + §4B.2 cfg 的 c-abi / kernel-abi / c++-abi 维度 + +第 3 批 —— 建立在 §4 之上 + §2 编译器进 TargetSide + requires 语法 + §3 词表拆两张(用 §2 的诊断兜底) + §9 测试矩阵补格 + +第 4 批 —— 随 §2/§3/§4 落地后一起 + §8 文档 + example + 目标表(⚠️ 写实测过的矩阵,不写推断的) + +单独立项、不在本文 + §7.3 尾 mcpp.lock 实际参与解析 (它现在只写不读,`mcpp update` 是空操作) + §5.3 零拷贝 staging (要先确认没有原地改写 staged 产物的步骤) +``` + +⚠️ **§4 排在 §2/§3 之前而不是之后**,尽管 §2/§3 的症状更痛。理由: +§2 的「编译器能不能消费这层」和 §3 的「这个三元组存不存在」都要引用 +「这个目标的 C 库到底是谁」,而在 §4 落地之前,那个问题的答案有两个来源 +(名字 和 `TargetSide`)且它们会矛盾。**先修根,否则 §2/§3 会各自实现一遍 +「以哪个为准」,而这正是 `01d6cef` 刚刚合并掉的那种三处推导。** + +--- + +## 11. 本文刻意没有断言的事 + +- **没有断言 §5 分组后具体快多少。** 1624 × 2.1 ms 是进程启动的下界, + 实际收益取决于文件系统与核数,要改完再测。 +- **没有断言 §6 之后缓存降到多大。** 「136 → ~40」是按内容哈希的取值数 + 推的,归一化命令串后可能还有别的轴在碎。 +- **没有断言 gcc 加上 `-isystem` 之后就能编 libc++ 的 std 模块。** + §2.3 步骤 4 修的是「静默丢弃包侧 flag」这个独立缺陷; + gcc 能否消费 libc++ 的模块是另一个问题,而 §2 的方案是**在此之前就拒绝**, + 正因为答案不确定。⚠️ 若有人要去证明它可行,那是一次实测,不是一次推理。 + +- **没有断言 §4.1(删掉 `env` 自动填充)的改动面有多大。** + 这是本文风险最高的一步:`env.empty()` 今天意味着「macOS」或「刚解析完」, + 改完之后还意味着「用户没指定」。全仓库每一处读 `env` 的地方都要重新判一次。 + ⚠️ 实施前必须先做一次纯统计:`grep -rn "\.env\b\|is_musl()\|is_windows_gnu()"`, + 把命中点逐个分类,**再**决定这一步值不值得。若命中点超过 ~30 处, + 应改为「保留自动填充,但另存一个 `envExplicit` 布尔」的窄改法 —— + 语义等价,改动面小一个量级。 + +*(原本这里留了一条「不确定用户为什么现在选中了 llvm」—— 已核实,见 §7.1.1, +现在是事实而不是猜测。)* + +--- + +## 12. 【架构·全景】目标与目标侧作为数据 —— 一份现成的对照实验 + +本节回答一个直接的问题:**既然 mcpp 已经有了构建期的 target / runtime / ABI +选择,那 libc、编译器运行时、BSP、乃至 target 本身,是不是都可以做成依赖, +而不必每次修改 mcpp?** + +答案是 **四层里三层今天就已经可以了,而且有活证据**;剩下的两处耦合比想象中窄。 +更有意思的是,**同一时刻正好有一份对照实验在跑**。 + +### 12.1 对照实验:两个 PR 在做同一件事 + +| | `mcpp#492` + `xim-pkgindex#677` | openkal | +|---|---|---| +| 要供给什么 | 为 musl 配置过的 libc++/libc++abi/libunwind + `std.cppm` | **同一类东西** | +| 怎么表达 | `kKnownTargets` 新增一列 `llvmSysroot` | 包里一行 `provides = ["mcpp:c++-abi=libc++"]` | +| 引擎改动 | **349 行**,横跨 `triple` / `prepare` / `flags` / `stdmod` | **0 行** | +| 加一个新目标要 | 改 mcpp → 发版 → 等用户升级 | 发一个包 | +| 谁能加 | 只有 mcpp 维护者 | 任何人 | + +⚠️ 这不是说 #492 做错了 —— 在它被写下的时候,那是**当时唯一走得通的路**(§12.3)。 +它的价值恰恰在于:它把「不走图模型的代价」量化成了一个可以读的 diff。 + +### 12.2 逐条对照:#492 的七件事,图模型已经做了六件 + +| # | #492 做的事 | 图模型 | 依据 | +|---|---|---|---| +| 1 | `--no-default-config` 中和 clang.cfg 的宿主绑定 | ✅ 已发 | std 缓存 metadata 实测可见该 flag | +| 2 | `--target=` | ✅ | `crossTargetFlag` | +| 3 | `-nostdinc++ -isystem ` | ✅ | `pkg.publicUsage.includeDirs`(`prepare.cppm:5872`) | +| 4 | `-nostdlib++ -L… -lc++ -lc++abi` | ✅ | 包自己的 `[build] ldflags` | +| 5 | `std.cppm` 取自包而非载荷 | ✅ | `[package] std-module`(`prepare.cppm:5773`) | +| 6 | 覆盖 `tc->targetTriple`,让输出目录/缓存键/flag 层一致 | ✅ | 图路径同样覆盖 | +| 7 | `--gcc-toolchain=` 借 crt/libgcc/libc | ❌ | **唯一真正新的机制** | + +⭐ 第 7 条之所以需要,是因为在 #492 的方案里 **libc 是一份预编译载荷**; +在 openkal 里 libc 是**图里的一个包**(`openkal-musl`),它自带 start files, +所以没有「向另一份载荷借 crt」这个问题。 + +⇒ **#492 的六分之七是在为载荷世界重新实现图路径已经做过的事。** + +### 12.3 ⚠️ 一次实验推翻了本文先前的判断:预编译包**今天就能**认领目标侧 + +本文的初稿在这里写着「二进制分发的 mcpp 包无法声明目标侧能力」,理由是 +`mcpp pack` 不发 `provides`。**那条结论是错的**,而推翻它的是一次实验。 + +#### 实验 + +手写一个**无源码**的包 —— 没有一行可编译的代码,只有预编译资产: + +```toml +[package] +namespace = "probe" +name = "libcxx-prebuilt" +version = "0.1.0" +provides = ["hosted-standard-library", "mcpp:c++-abi=libc++"] +std-module = "modules/std.cppm" +std-compat-module = "modules/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc++", "-isystem", "include-config"] + +[build] +sources = [] # ⭐ 显式空 = 什么都不编 +include_dirs = ["include-config", "include"] +ldflags = ["-L", "lib", "-lc++", "-lc++abi"] +``` + +资产就是 llvm 载荷里现成的 libc++(头、静态库、`std.cppm` 与它的 90 个片段), +一个 `path` 依赖引进来。**实测结果:** + +``` + Target x86_64-unknown-linux-gnu + kernel-abi linux (payload) + c-abi gnu (payload) + c++ libc++ (libcxx-prebuilt@0.1.0, graph) ← ⭐ + Compiling libcxx-prebuilt (path) + Finished dev in 0.40s + Running `target/x86_64-linux-gnu/…/bin/app` + +prebuilt package supplied the c++ layer +``` + +⭐ **一个手写的、零源码的预编译包认领了 `c++-abi` 层、供给了 std 模块、 +把自己的 `std-module-flags` 和 `include_dirs` 送上了命令行 —— 引擎零改动。** + +#### 所以真正的缺口是什么 + +`sources = []` 是**已支持的显式拼写**,`types.cppm:374-390` 的注释写得很清楚: +「A binary distribution package needs that spelling」。`provides` / `std-module` +是 `[package]` 级的键,与有没有源码无关。**模型完全能表达。** + +缺的只有一件事:`src/pack/manifest_emit.cppm` **从零重建 manifest**, +发的十余个键里不含 `provides` / `std-module` / `std-compat-module` / +`std-module-flags`。 + +⇒ 准确的说法是:**`mcpp pack` 这条自动生产线会把能力声明丢掉, +但手写 manifest 不受影响。** 这是**工效缺陷,不是能力缺陷**, +优先级应从「阻塞」降到「补齐」。 + +⚠️ 顺带发现(这条仍然成立):`manifest_emit.cppm:151-158` 的 +`cfg_predicate_for(triple)` 按 `arch/os/env` 三段生成谓词, +**把 `env` 段烙进了 mcpp 产出的每一个二进制包**。§4 的问题因此不止影响 +手写的包,它影响 `mcpp pack` 的全部产物。 + +#### 顺带复现:§2 的缺陷不是 openkal 特有的 + +同一个 probe 包 `--target x86_64-linux-musl`:musl 行的 pin `gcc@16.1.0` +盖掉 llvm 默认,然后 `gcc.cppm:159` 把包侧 flag **全部丢弃** —— + +``` +x86_64-linux-musl-g++ -std=c++23 -fmodules -O2 --sysroot=… -c …/modules/std.cppm + ↑ 没有 -isystem、没有 --no-default-config、没有 -nostdinc++ +error: __config: No such file or directory +``` + +⭐ 一个与 openkal 毫无关系的包,走到了**逐字相同**的失败。§2 和 §7.1 +因此各多一条独立证据。 + +### 12.4 ⚠️ #492 引入的是第四条并行判据 + +`src/targetside/model.cppm` 开篇列出了它存在的理由 —— 三条互相矛盾的判据: + +``` +prepare openkalTargetSide 工具链族名是 "openkal-llvm" +flags graphTargetSide targetCxxRuntime && !crossTargetFlag.empty() +dist graphCxxRuntime targetCxxRuntime +``` + +`#492` 的 `isLlvmMusl` 是**第四条**,而且又一次在三个文件里各推一遍 +(`flags.cppm` 三处 `if`、`prepare.cppm` 一处、`stdmod.cppm` 一处)。它的定义是: + +```cpp +is_clang(tc) && is_musl_target(tc) && !tc.targetSysrootInclude.empty() +``` + +⭐ 这句话说的正是 **`TargetSide::cxx.origin != Payload`** —— 一个 `01d6cef` +刚刚建立起来的、有单一真源的事实,被用三个代理变量重新拼了出来。 + +⚠️ 这不是批评作者:`TargetSide` 只解析**图**供给的层,而 #492 供给的是**载荷**, +`Origin::Xpkg` 那一支今天没有任何消费者去问。**缺口在 `TargetSide` 的覆盖面, +不在 #492 的写法。** 正确修法是让 `Origin::Xpkg` 也成为一等来源, +于是 `isLlvmMusl` 消失,变成 `ts.cxx.origin != Origin::Payload`。 + +### 12.5 直接回答:哪几层已经可以是依赖 + +| 层 | 能否作为依赖 | 证据 / 阻塞点 | +|---|---|---| +| 普通库 | ✅ | 一直如此 | +| **BSP / 平台接口**(kernel-abi) | ✅ **0 引擎改动** | `openkal-linux` / `-windows` / `-macos` / `-opensbi` | +| **C 库**(c-abi) | ✅ **0 引擎改动** | `openkal-musl` | +| **编译器运行时**(c++-abi) | ✅ **0 引擎改动** | `openkal-llvm-runtime`(libc++ + libc++abi + libunwind + compiler-rt builtins) | +| 同上,但**以预编译资产分发** | ✅ **实测,0 引擎改动** | §12.3 的 `libcxx-prebuilt` 实验 | +| 同上,但由 **`mcpp pack` 自动产出** | ⚠️ | pack 不生成那四个键(工效,非能力) | +| **target 的「组合」**(已有 token 的新排列) | ⚠️ 假耦合 | §12.6(a),被 `kKnownTargets` 当门挡着 | +| **target 的「词汇」**(全新 os/env token) | ❌ | §12.6(b),`parse()` 闭集 | +| **编译器族** | ❌ | `Family` 枚举 —— 而这条**应当保持关闭**(§12.5.1) | + +⇒ 你的判断成立,而且**比预想的还成立**:连预编译资产都不需要引擎改动。 +openkal 不是「绕过构建体系」,它是**唯一一个把构建体系当接口用**的生态, +而那个接口是 `01d6cef` 建的。 + +### 12.5.1 判据:「组合」该是数据,「词汇」该在引擎 + +三个 ❌ 不该用同一个答案,而区分它们的判据 `targetside/model.cppm:135-138` +已经写下来了,只是当时只用在「层名 vs 实现名」一个维度上: + +> Layer names may be hardcoded because the layers are fixed by the C and C++ +> build model and do not grow. Implementations may not, because growing is +> precisely what they do: **the ecosystem's combinations are 2×N×M while its +> packages are 2+N+M.** + +把这句话推广开,三个问题各自的答案就出来了: + +| | 性质 | 增长 | 谁赋予语义 | 结论 | +|---|---|---|---|---| +| 哪个 libc 配哪个 kernel 配哪个运行时配哪个 arch | **组合** | 乘法 | 包自己 | **必须是数据** ✅ 已是 | +| 有哪些层(kernel-abi/c-abi/c++-abi) | 词汇 | 不增长 | 引擎 | 保持硬编码 ✅ | +| 有哪些 os / env token | 词汇 | 加法,一年一两个 | **引擎**(`is_pe()`、`cfg(windows)`、库命名、strip 适用性…十余处) | 保持闭集 | +| 有哪些编译器族 | 词汇 | 加法,极慢 | **引擎**(flag 拼写、模块模型、BMI 格式、cfg 文件) | 保持闭集 | + +⭐ 结论:**`Family` 是闭集这件事没有错,错的是有人往里面塞了一个不是编译器的东西** +(`OpenkalLlvm` = 目标侧策略)。删掉它(§4.4),闭集就是对的。 + +同理 `parse()` 的 token 表:开放它不是「让 target 成为数据」,而是 +**把语义责任推给包作者** —— `os` 字段今天被十余处代码赋予行为, +一个包定义的 opaque `os` 无法回答其中任何一处。 + +### 12.6 target 要成为数据,耦合点只有两处(而且不是同一处) + +「加一个 target 要改 mcpp」听起来像一件事,读码后是两件,难度差一个量级: + +**(a) `kKnownTargets`(`triple.cppm:171`)—— 假的耦合。** +这张表现在同时当**门**和**约定表**用。§3 已经论证:它只该是后者。 +表里没有行 = 没有约定,而这对图供给的目标恰好正确。 +⭐ **实测:`x86_64-windows-musl` 用 `[target.X]` 逃生口全程走通并在 wine 下运行** +—— 也就是说,这一层的「必须改 mcpp」**今天就已经不成立了**,只是默认路径被门挡着。 + +**(b) `parse()` 的 os/env token 表(`triple.cppm:474-509`)—— 真的耦合。** + +```cpp +if (k == "linux") { t.os = "linux"; … } +if (k == "windows"){ t.os = "windows"; … } +… +// Unrecognized segment (androideabi, wasi, …): not in mcpp's target +// language — treat as unparseable rather than guessing. +return std::nullopt; +``` + +`arch` 是 `tok[0]`,自由透传;**`os` 和 `env` 是闭集**。所以: + +- `riscv64-linux-musl`、`aarch64-windows-gnu` 这类**已有 token 的新组合** + —— (a) 落地后即可用,**不需要动 mcpp**。 +- `x86_64-haiku`、`wasm32-wasi`、`aarch64-linux-ohos` 这类**新 token** + —— 必须动 `parse()`。⚠️ HarmonyOS 那次适配踩的正是这一处 + (而且发现「`ohos` 是 env 不是 os」)。 + +**建议的形状**:未知 token **不再让整份三元组解析失败**,而是原样保留为 +`opaque` 段,并交给图去认领 —— 与 §3 的判据一致(「不可证伪就放行」)。 +若没有任何包 `provides` 这个目标,就在图已知之后拒绝,报错里说得出 +「没有任何依赖实现这个平台」。 + +⚠️ 这条比 (a) 风险高:`os` 字段今天被 `cfg(windows)`、`is_pe()`、 +`is_freestanding()`、库命名规则、strip 适用性等**十几处**读。opaque 段进来后 +每一处都要有确定答案。**建议排在 §4 之后单独立项,不与 §3 同批。** + +### 12.7 「规范 target」放哪里:SPEC-002 + +`docs/spec/` 已有 SPEC-001(包身份),README 明确了规范文档的性质: +「定义机制的语义与约束」「同时描述目标规范与当前实现,每条规则都标注实现状态」。 + +⇒ **SPEC-002「目标身份与目标侧能力模型」**,内容: + +1. **三元组的语法与语义** —— 四条轴、三个字段、`env` 是请求不是事实(§4)。 + 每条标 ✅ / ⚠️ / ❌,因为其中一半今天还不成立。 +2. **`mcpp:` 保留命名空间** —— `provides` / `requires` 的语法、当前的三个层名、 + 「引擎硬编码层名、永不硬编码实现名」这条约束本身。 +3. **目标侧的四种来源** —— `Payload` / `Xpkg` / `Graph` / `None`,以及分层规则 + (下层被谁配置过、上层能被谁消费)。 +4. **一个包要供给某一层,必须发什么** —— 含 §12.3 的二进制分发通路。 +5. **兼容性条款** —— ⚠️ 老客户端遇到未知 `mcpp:` 层名/未知键**必须降级而不是 + 整份 manifest 加载失败**。这条已经付过学费(#359),规范里必须写死。 + +### 12.8 落地顺序(与 §10 合并) + +``` +不需要排队 —— 今天就能做,零引擎改动 + §12.3 llvm-musl-libcxx 改为手写 manifest 的 mcpp 预编译包 + ← 实验已证形状可行;#492 的三处引擎改动因此可以撤掉 + +第 0 批 追加 + §12.3尾 mcpp pack 补发 provides / std-module / std-compat-module / std-module-flags + ← 工效而非能力(手写不受影响),但自动生产线不该丢语义 + §4.4 Family 删掉 OpenkalLlvm(§12.5.1:闭集是对的,内容错了) + +第 1 批 追加 + §12.4 TargetSide 覆盖 Origin::Xpkg,消掉第四条并行判据 + ← 做完之后 #492 的 isLlvmMusl 变成 ts.cxx.origin != ts.cAbi.origin + §12.9-2 「clang 交叉到载荷供给 libc 的宿主目标」的 C 运行时接线 + ← #492 唯一不可约的部分,换成通用判据后落在这里 + +第 3 批 追加(§3 之后) + §12.6(a) 已由 §3 覆盖,无额外工作 + +明确不做 + §12.6(b) parse() 开放 os/env token ← 见 §12.5.1:那是把语义责任推给包作者 + 编译器族开放 ← 同上 + +单独立项 + §12.7 SPEC-002 +``` + +### 12.9 给 #492 的建议(如果它还没合) + +不建议直接否掉 —— 它解决的是一个真实且当下的阻塞(GCC 16 modules ICE)。 +但 §12.3 的实验说明它的**不可约核心比 349 行小得多**。 + +#### 实测:把 §12.3 的 probe 包交叉到 musl,看还剩什么 + +`[target.x86_64-linux-musl] toolchain = "llvm@22.1.8"` + 那个手写预编译包: + +| 阶段 | 结果 | +|---|---| +| 目标侧解析 | ✅ `c++ libc++ (libcxx-prebuilt@0.1.0, graph)` | +| **std 模块** | ✅ **编过了**,命令行带 `--target=x86_64-unknown-linux-musl`、`--no-default-config`、`-nostdinc++`、包的 `-isystem` | +| 产物 | ✅ `std.o` + `std.compat.o` + `pcm.cache` 落盘 | +| **链接** | ❌ mcpp 自己的 hermetic 检查拦下:`crt1.o / crti.o / crtbeginT.o` 解析到了**宿主** | + +⭐ 也就是说 #492 的七件事里,**第 1–6 条图路径确实全都做到了**(不是推测, +是这次实测走过去的),而第 7 条被 mcpp 自己的完整性检查精确地指了出来。 + +⇒ **#492 的不可约核心只有一件事:把 clang 指向目标的 C 运行时** +(crt / libgcc / libc,来自另一份载荷)。manifest 无法可移植地命名另一份载荷的前缀, +所以这件事必须留在引擎。 + +#### 建议 + +1. **`llvm-musl-libcxx` 改为 mcpp 包**(预编译资产 + `provides` + `std-module`), + **不需要等 pack 补键,手写 manifest 今天就能发**。 + ⇒ #492 的 `triple` 新列、`prepare` 的解析块、`stdmod` 的分支**全部不需要**。 +2. **保留并推广第 7 条**,但换判据。现在是 `is_clang && is_musl_target`, + 应该是**「C 库来自载荷,而 C++ 运行时不来自同一份载荷」** —— + 即 `ts.cAbi.origin == Payload/Xpkg && ts.cxx.origin != ts.cAbi.origin`。 + 这不是 musl 特有的形状,是「clang 交叉到任何载荷供给 libc 的宿主目标」。 + ⚠️ 落点应是 §12.4 说的 `Origin::Xpkg` 一等化,而不是第四条并行判据。 +3. **保留** `toolchainFromFlag`,但**与 §7.1 合并成一次修改**:#492 只覆盖了 + `MCPP_TOOLCHAIN` 与命令行 flag,仍未覆盖 `mcpp toolchain default` 写下的 + 全局默认(§7.1.1 实测:用户被迫把同一个值写了两遍)。 +4. ⭐ **顺带**:§12.3 尾的复现说明,#492 的 musl 目标在**默认路径**(词表 pin = + gcc)上会撞进 §2 的缺陷。#492 合入后应补一格 e2e: + `--target x86_64-linux-musl` 不带任何 toolchain 覆盖,断言得到的是 + **mcpp 的诊断而不是编译器原话**。 + +⚠️ **仍未实测的部分**:本节没有跑过 #492 的分支本身,也没有构造一份真正 +为 musl 配置过的 libc++(§12.3 的 probe 用的是宿主 libc++,所以它的 std 模块 +虽然编过了,链接必然失败)。上面第 1 条建议若要执行,**先把 +`llvm-musl-libcxx` 按 mcpp 包形态发一份试装** —— 那是一次实验,不是一次推理。 + +--- + +## 13. 目标侧包的分发形态与身份 + +§12 回答了「能不能作为依赖」。本节回答两个紧接着的问题: +**这些包该以什么形态分发**,以及**怎么知道是谁在供给我的 libc**。 + +### 13.1 目标侧包应当是**源码包**,预构建是退步 + +⚠️ **本节初稿写反了,主张目标侧包应当预构建。以下是修正后的结论。** + +现状是:**编译器相关的东西被迫走预构建载荷**(`xim:llvm-musl-libcxx` 是 +一份 xim 资产 + 引擎硬编码一列),而 openkal 走的是图里的源码包。 +正确的方向是后者,理由有五条,其中第三条是决定性的。 + +**1. 可维护性。** 源码包可以被任何人 fork、打补丁、发版本。预构建资产要求 +发布者为每个 (目标 × 架构) 构建、托管、sha256 固定 —— `xim-pkgindex#677` +为此写了 122 行 Lua,资产还挂在贡献者 fork 的 release 上等待迁移。 + +**2. 正确性由构造保证。** 源码包用**你的**编译器、对着**你的**图里的 C 库、 +按**你的** flag 编译。预制归档是对着别的东西编的,匹配与否只能靠信任 —— +而这正是本仓库已经记录过的失败模态:`__config_site` 描述的是一份配置, +混用不会在选择处失败,会在头文件深处失败。 + +**3. ⭐ 引擎自己的判据指向源码包,而预构建把你推回乘法侧。** +`targetside/model.cppm:135-138`: + +> the ecosystem's **combinations are 2×N×M** while its **packages are 2+N+M** + +预构建的资产数 = 目标 × 编译器版本 × C 库 —— 那是 N×M 那一侧。 +源码包是 1。**这条判据是这套设计的地基,它不支持预构建。** + +**4. 冷构建的代价被高估了 —— 实测。** + +``` +第一次(冷) 4.9 s 墙钟 / 44 s CPU / 243 units +mcpp clean 后 Cached openkal-llvm-runtime v0.1.1 (243 units) 1.02 s +全新工程、不同包名、不同版本号、从未构建过 + Cached openkal-llvm-runtime v0.1.1 (243 units) 1.655 s +``` + +⭐ 第三行是关键:每包 Merkle 键**跨工程命中**。所以冷构建是 +**每台机器、每 (包版本 × 目标) 一次**,不是每工程一次,更不是每次构建一次。 +交叉四个目标 ≈ 3 分钟 CPU,一次。这不构成采用预构建的理由。 + +**5. 冷构建若真成为问题,正确答案是共享构建缓存,不是预构建包。** +远程/共享 cache 保留源码包的全部性质(可维护、由构造保证正确、包数是加法), +同时消掉重算。预构建包用**放弃前三条**来换第四条,而第四条已经很便宜了。 + +#### ⭐ 这也正好解释了 349 行 vs 0 行 + +`openkal` 零引擎改动而 `#492` 要 349 行,差别不在实现质量,在**边界画在哪**: + +| | libc 在哪 | 引擎要做什么 | +|---|---|---| +| openkal | **图里**(`openkal-musl`,源码包) | 什么都不用做 —— 两层都在图里,关系由图表达 | +| #492 | **载荷里**(musl-gcc payload) | 必须去接线「载荷 A 的 libc」与「载荷 B 的 libc++」 | + +⇒ **一旦把 libc 也拿进图,那条不可约的引擎改动就消失了。** +§12.9 实测剩下的唯一一件事(把 clang 指向目标的 C 运行时)之所以存在, +正是因为 #492 把 libc 留在了载荷里。 + +#### ⚠️ 但预构建这条路仍需堵一个坑(因为 `pack` 已经能走) + +即使不推荐,`mcpp pack` today 已经能产出多目标包,而 +`manifest_emit.cppm:151-158`: + +```cpp +p += std::format(", env = \"{}\"", seg[2]); +``` + +为 `x86_64-windows-gnu` 打的腿生成 +`cfg(all(arch="x86_64", os="windows", env="gnu"))` —— +**与 `openkal-windows/mcpp.toml:41` 手写的那条逐字同形**, +于是在 `x86_64-windows-musl` 上重演 §4B 的失败。 + +⇒ §4 落地时,`cfg_predicate_for` 必须一起改。这不是为预构建服务, +是因为**每一个 `mcpp pack` 产出的多目标库都带着这个缺陷**,与目标侧无关。 + +### 13.2 ⚠️ 两个包同时供给一层时,图遍历顺序里第一个静默胜出 + +`prepare.cppm:5465-5493` 的 `provider_of`: + +```cpp +for (auto const& pkg : packages) { + for (auto const& entry : pkg.manifest.provides) { + … + if (!found || (found->interfaceName.empty() && !p.interfaceName.empty())) + found = p; + } +} +``` + +⇒ **没有冲突检测。** 两个包都声明 `mcpp:c-abi`,`packages` 的遍历顺序决定谁赢, +而那个顺序既不是用户写的,也不是用户能预测的。第二个包被静默忽略, +它的 `[build]` 段却仍然参与构建 —— 一个 libc 的头文件配另一个 libc 的实现。 + +⚠️ 这一层的失败模态特别糟:C 库/内核接口/C++ 运行时**不是可叠加的贡献**, +是**互斥的选择**。选错不会链接失败,会得到一个能跑、偶尔崩的程序。 + +#### 现成的正确形状:Cargo 的 `links` 键 + +Cargo 对「我代表这个原生库」有完全同构的约束:**全图至多一个包可以声明 +某个 `links` 值**,否则解析期直接报错并指出冲突双方。 + +mcpp 需要同一条不变量,而且更强 —— 三个层各自至多一个供给者: + +``` +error: two packages supply the C ABI, and it is a choice rather than a contribution. + mcpplibs/openkal-musl@0.3.3 (a dependency of openkal-llvm-runtime) + acme/tinylibc@0.2.0 (a direct dependency) + A build has exactly one C library. Remove one, or pin the layer with + `[target.] c-abi = "…"`. +``` + +⭐ 判据方向与 §3 相反且刻意如此:§3 是「不可证伪就放行」, +这里是「两个都摆在眼前且互斥,必须拒绝」。 + +### 13.3 「官方包加命名空间前缀」——⚠️ 该做成显示,不该做成准入 + +#### 实测:报告显示的是一个不唯一的标识符 + +`prepare.cppm:5478`: + +```cpp +p.name = pkg.manifest.package.name; +``` + +⇒ 报告打的是 `openkal-windows@0.1.3`,**不含 namespace**。 +而 SPEC-001 明确:`name` 是**单一原子段**,层级由 `namespace` 承载。 +所以今天的情况是:**一个能改写整个构建的特权角色,正在用一个不保证唯一的 +标识符显示自己。** 任何人都可以发一个 `name = "openkal-windows"`。 + +**修法(零成本)**:`Provider::id()` 打全限定名。 + +``` +kernel-abi openkal (mcpplibs/openkal-windows@0.1.3, graph) +c-abi musl (mcpplibs/openkal-musl@0.3.3, graph) +c++ libc++ (acme/experimental-libcxx@0.0.1, graph) ← 一眼看出 +``` + +⇒ 「官方 vs 第三方」这个需求,**在显示层就解决了**,不需要任何权限机制。 + +#### 为什么不该做成准入门槛 + +把 `provides = ["mcpp:…"]` 限制到 `mcpplibs:` 之类的命名空间,代价是: + +1. **它把守门人从 mcpp 挪到 mcpplibs,瓶颈没消失,只是换了个仓库。** + 而本文 §12 全部的出发点就是「不必每次修改 mcpp」。 +2. **它违反 `targetside/model.cppm:127-138` 自己立的规矩** —— + 「硬编码层名,永不硬编码实现」。按所有者建白名单,就是按所有者硬编码实现。 +3. **它挡不住真正的风险。** 一个恶意包不需要 `provides` 就能破坏构建 + (`[build] cxxflags` 就够了)。特权不在这个键上,在「能进依赖图」这件事上。 + +#### 那什么才是真正缺的:知情 + +真正的风险不是「谁能供给」,是**一个传递依赖可以在 root 工程不知情的情况下 +换掉它的 libc**。这与 Cargo 对 build script 的处理是同一类问题。 + +建议(按强度递增,选一): + +- **A(推荐)**:报告打全限定名 + 标注**直接还是传递**: + `c-abi musl (acme/tinylibc@0.2.0, graph — via openkal-llvm-runtime)`。 +- **B**:传递依赖认领层时打一条 `warning`,直接依赖不打。 +- **C**:root 工程可显式确认 `[target-side] allow = ["acme/tinylibc"]`, + 未列出即拒绝。⚠️ C 的代价是每个用户都要写一遍,而绝大多数人只想用默认组合。 + +⭐ 建议 A + §13.2 的唯一性约束。两者合起来覆盖了「语义清晰」的全部需求, +而**不引入任何准入门槛**。 + +### 13.4 按你给的三条判据核一遍 + +| | 功能实现 | 语义清晰 | 生态兼容性 | +|---|---|---|---| +| **目标侧走源码包**(推荐) | ✅ openkal 已经是,零引擎改动 | ⭐ 正确性由构造保证,不靠信任预制件的匹配 | ✅ 包数是加法(2+N+M),不随目标矩阵爆炸 | +| 目标侧走预构建 | ✅ 形状也可行(§12.3) | ⚠️ `__config_site` 类的错配只在头文件深处暴露 | ❌ 资产数 = 目标 × 编译器版本 × C 库,回到乘法侧 | +| 冷构建成本 | 实测 44 s CPU,**跨工程命中**后为零 | — | 若真成问题 ⇒ **共享构建缓存**,而不是预构建包 | +| 层供给唯一性(§13.2) | 解析期一次检查 | ⭐ 把「静默选一个」变成「明确拒绝」 | ⚠️ 可能打破现存的图 —— 上线前先扫索引 | +| 全限定名显示(§13.3) | 一行改动 | ⭐ 特权角色用唯一标识符显示 | ✅ 纯输出变化 | +| 命名空间准入门槛 | — | — | ❌ **不做**:把瓶颈换个仓库,违反引擎自己的规矩,且挡不住真风险 | +| `pack` 的 `cfg_predicate_for` 用 env 段 | — | ⚠️ 与 §4B 同形的缺陷 | ❌ **每个多目标预构建库都带着它**,随 §4 一起修 | + +⚠️ **唯一性约束的兼容性风险是本节唯一的真风险**:如果索引里已经存在 +两个包在同一层声明能力(比如新旧两代 openkal 实现),打开检查会让现存工程 +构建失败。**上线前必须先扫一遍索引**,而不是直接合。 + +--- + +## 14. 定稿:四层四来源模型 + +本节是前面所有章节的收敛。判据是一句可验收的话: + +> ⭐ **#492 的开发者只维护一个类似 `openkal-llvm-runtime` 的包,就能满足需求。** + +三个维度分别定下来,然后用这句话验收。 + +--- + +### 14.1 架构设计 + +#### 模型:四层 × 四来源 + +**四个层**(引擎硬编码,因为它们由 C/C++ 构建模型固定、不增长): + +``` +compiler 谁在编译 ← 今天不在 TargetSide 里(§2) +kernel-abi 平台接口 +c-abi C 库 +c++-abi C++ 运行时 +``` + +**四种来源**(引擎硬编码,`targetside::Origin` 已经是这四个): + +``` +Payload 编译器载荷自带 +Xpkg 被点名的一份预制载荷(target 表 sysroot 列 / [target.X].sysroot) +Graph 依赖图里的包 +None 没有(零 libc 档、裸机无 kernel) +``` + +**实现名永不硬编码。** `openkal` / `musl` / `libc++` / `picolibc` 出现在包的 +manifest 与索引里,不出现在引擎任何一行代码里。 + +#### 三条规则 + +**规则一 —— 每层恰好一个供给者。** +C 库、内核接口、C++ 运行时不是可叠加的贡献,是互斥的选择。两个供给者是错误 +(§13.2 实测:今天是图遍历顺序里第一个静默胜出)。 + +**规则二 —— 上层必须为下层配置过。** +`check_layering` 已经守住一半(载荷 C++ 运行时 × 非载荷 C 库), +另一半(图供 C++ 运行时 × 编译器族)缺失(§2)。 + +**规则三 ⭐ —— 引擎只在「跨来源」时接线。** + +| 组合 | 谁表达关系 | 引擎改动 | +|---|---|---| +| 两层都在 `Graph` | 包之间用普通依赖表达 | **0 行** ← openkal | +| 两层都在 `Payload` | 载荷自洽 | **0 行** ← gcc-musl | +| 一层 `Payload`/`Xpkg`,一层 `Graph` | **只有引擎知道两边的地址** | 必须接线 ← #492 | + +⇒ **#492 需要的不是一个新分支,是规则三第三种情形被实现。** + +#### 实测:模型已经完备,缺的只是消费 + +```toml +[target.x86_64-linux-musl] +toolchain = "llvm@22.1.8" +sysroot = "xim:musl-gcc@16.1.0" # 已存在的键 +``` + +``` +c-abi musl-gcc (xim:musl-gcc@16.1.0, prebuilt) ⭐ Origin::Xpkg,模型认了 +c++ libc++ (libcxx-prebuilt@0.1.0, graph) ⭐ Origin::Graph,模型认了 +``` + +而生成的链接行: + +``` +ldflags = -static --target=x86_64-unknown-linux-musl --no-default-config + -fuse-ld=lld -L/lib/... -Wl,-rpath,... + ↑ 没有 --sysroot,没有 --gcc-toolchain +``` + +⇒ hermetic 检查开火:`crt1.o / crti.o / crtbeginT.o` 解析到宿主。 + +**诊断:`sysrootXpkg` 被解析、被报告,却没有被渲染。** 原因是 +`prepare.cppm` 里把 xpkg 物化(`xpkg_payload()` → `targetSysroot*`)那一段 +**限定在 `is_freestanding()`**,而 `flags.cppm` 只在 freestanding 分支消费它。 + +#### 架构结论(三条,按重要性) + +**A1. 删掉 `llvmSysroot` 列。** `sysroot` 列的注释自己写着: + +> The TARGET's C library, resolved at compile time exactly the way `pin` +> resolves the compiler. … A hosted target gets its libc automatically — +> `x86_64-linux-musl` carries musl inside its **gcc payload** + +⭐ 「hosted 目标自动拿到 libc」这条假设**只在编译器是 gcc 时成立**。 +换成 clang 就不成立 —— 而那正是 #492 的场景。列的**含义**已经覆盖了它, +只有**消费范围**没有。#492 加第二列,是把一个作用域问题当成了缺列问题。 + +**A2. 把 sysroot 列的物化与渲染从 freestanding 解绑。** 一处 `if` 的作用域, +外加 clang 的 `--gcc-toolchain` 渲染。⚠️ 后者需要一次实测确认(下方 §14.4)。 + +**A3. `Family` 保持闭集,删掉 `OpenkalLlvm`。** +「目标侧从哪来」由 `TargetSide` 回答,不由工具链名字回答(§4.4、§12.5.1)。 + +--- + +### 14.2 语义一致性 + +五条,每条一句话,每条都有一个当前违反它的实测。 + +| # | 语义 | 当前违反 | +|---|---|---| +| S1 | **三元组是请求,`TargetSide` 是事实** | `x86_64-windows-gnu` 报告 `c-abi musl`(§4) | +| S2 | **`cfg()` 按事实求值,不按名字** | `openkal-windows` 的 `env="gnu"` 丢掉四个导入库(§4B) | +| S3 | **每层恰好一个供给者** | 冲突时图遍历顺序第一个静默胜出(§13.2) | +| S4 | **特权角色用唯一标识符显示** | 报告只打 `name`,不打 `namespace`(§13.3) | +| S5 | **`provides` / `requires` 对称** | `requires` 不存在;编译器要求无法声明(§2) | + +**S1 的推论**:请求与事实矛盾 ⇒ **拒绝**,不并排打印。 +`env` 空 = 未指定(恢复 `triple.cppm:514` 抹掉的那一态)。 + +**S3 的形状**:与 Cargo 的 `links` 键同构 —— 全图至多一个包声明某个值, +冲突即解析期报错并指出双方。 + +**S4 的修法**:`Provider::id()` 打全限定名。⭐ 「官方 vs 第三方」是**显示问题**, +不是权限问题;命名空间准入门槛明确不做(§13.3)。 + +**S5 的语法**: + +```toml +provides = ["mcpp:c++-abi=libc++"] # 我供给哪层 +requires = ["mcpp:compiler=llvm"] # 我需要哪层由谁供给 +``` + +⚠️ 老引擎遇到未知 `mcpp:` 层名或未知键**必须降级,不得让整份 manifest +加载失败**(#359 已付过学费)。这条进 SPEC-002。 + +--- + +### 14.3 用户侧使用 + +#### 零配置到交叉,四条命令 + +```bash +mcpp build # 宿主,载荷自洽 +mcpp toolchain default llvm # 换编译器 —— ⚠️ 今天会被词表 pin 盖掉(§7.1) +mcpp build --target x86_64-linux-musl # 交叉 +mcpp build --target x86_64-linux # ⭐ S1 之后:C 库谁供给谁说了算 +``` + +加一层 = 加一条依赖: + +```toml +[dependencies] +llvm-musl-runtime = "0.1.0" +``` + +#### 报告显示四层,带全限定名 + +``` + Compiling app v0.1.0 (.) + Target x86_64-linux-musl → x86_64-unknown-linux-musl + compiler llvm@22.1.8 (payload) + kernel-abi linux (payload) + c-abi musl (xim:musl-gcc@16.1.0, prebuilt) + c++ libc++ (mcpplibs/llvm-musl-runtime@0.1.0, graph) +``` + +⭐ 三处变化:**加 compiler 行**(S5)、**全限定名**(S4)、 +**来源词与层对齐**(payload / prebuilt / graph / —)。 + +#### 四条诊断,替换今天的编译器原话 + +``` +error: this C++ runtime requires an llvm-family compiler. + c++ libc++ (mcpplibs/llvm-musl-runtime@0.1.0, graph) + compiler gcc@16.1.0 (target pin for x86_64-linux-musl) + The target row's pin is mcpp's own default and yours outranks it: + mcpp toolchain default llvm@22.1.8 + or pin it for this target only: + [target.x86_64-linux-musl] + toolchain = "llvm@22.1.8" +``` + +``` +error: two packages supply the C ABI, and it is a choice rather than a contribution. + mcpplibs/openkal-musl@0.3.3 (via mcpplibs/openkal-llvm-runtime) + acme/tinylibc@0.2.0 (a direct dependency) +``` + +``` +error: this build requests the `gnu` C ABI, and its graph supplies `musl`. + Write `--target x86_64-linux` to let the graph decide. +``` + +``` +error: nothing supplies this target's C library. + c-abi — + The compiler payload does not carry one for x86_64-linux-musl, and no + dependency provides `mcpp:c-abi`. Name a prebuilt one: + [target.x86_64-linux-musl] + sysroot = "xim:musl-gcc@16.1.0" + or depend on a package that implements it. +``` + +⚠️ 每条可粘贴的行都是**承诺**,含其中的版本号。示例里的版本必须与索引当天的 +latest 一致,并在发布新版时同批更新(已复发过两次)。 + +--- + +### 14.4 #492 的验收清单 + +| # | 开发者需要什么 | 现状 | 谁来做 | +|---|---|---|---| +| 1 | 包能声明 `mcpp:c++-abi` | ✅ **已实测** | — | +| 2 | 包能供 `std.cppm` + 自己的 flags | ✅ **已实测** | — | +| 3 | std 模块能带 `--target=` 交叉编译 | ✅ **已实测**(产出 `std.o`+`std.compat.o`) | — | +| 4 | `[target.X].sysroot` 能在 hosted 目标上被**解析** | ✅ **已实测**(报告显示 `prebuilt`) | — | +| 5 | 同一份 sysroot 被**渲染**到编译/链接行 | ❌ | **A2 —— 唯一有分量的一条** | +| 6 | `--toolchain llvm` 不被词表 pin 盖掉 | ❌ | §7.1(#492 已修一半,漏了全局默认) | +| 7 | 图供 libc++ × gcc ⇒ 拒绝而不是乱编 | ❌ | §2 | +| 8 | 冲突与身份可见 | ❌ | S3 / S4 | + +⭐ **1–4 今天就成立。引擎侧真正要做的是 5,其余三条是诊断质量。** +而第 5 条是**已有列的解绑**,不是新机制 —— 与 #492 的 349 行相比, +它同时服务 freestanding、hosted-musl 和未来任何「编译器不带目标 libc」的组合。 + +#### 做完之后,#492 的开发者做什么 + +发一个包,形如: + +```toml +[package] +namespace = "" +name = "llvm-musl-runtime" +version = "0.1.0" +provides = ["mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc", "-nostdinc++", "-D_GNU_SOURCE"] + +[build] +sources = [ ... libc++ / libc++abi / libunwind 的源码 ... ] +include_dirs = [ ... ] +``` + +**引擎一行不改,索引一行不加。** 使用者写两行: + +```toml +[dependencies] +llvm-musl-runtime = "0.1.0" + +[target.x86_64-linux-musl] +toolchain = "llvm@22.1.8" +sysroot = "xim:musl-gcc@16.1.0" +``` + +⚠️ 最后两行将来可以由 target 表的列提供默认(那时使用者只写第一行), +但**列是便利,不是机制** —— 机制是 `[target.X]` 那两个键,它们今天就存在。 + +#### ⚠️ A2 需要先做的一次实测 + +clang 找 crt/libgcc 靠 `--gcc-toolchain`,而 picolibc 那类是纯 sysroot +(只有 include/lib)。**提案**:sysroot 来源是 `Xpkg` 且编译器是 clang 时, +无条件同时给 `--sysroot=` 与 `--gcc-toolchain=` —— +若载荷不是 gcc 形状,clang 在那里找不到 GCC 安装,退回 `--sysroot`, +即 picolibc 今天已经在走的路。 + +⚠️ **这是设计假设,不是实测结论。** 落地前必须先验证两件事: +(a) 给 picolibc 的 sysroot 额外加 `--gcc-toolchain` 是否零影响(裸机四个目标全跑); +(b) 给 musl-gcc 载荷加上后 crt/libgcc 是否真的解析到载荷内(hermetic 检查转绿)。 +若 (a) 不成立,退化方案是给 sysroot 列加一个形状标记 —— 但那是数据,仍不是分支。 + +--- + +### 14.5 落地顺序(替换 §10 与 §12.8) + +``` +第 0 批 · 低风险 · 各自可独立提 PR + S4 Provider::id() 打全限定名 一行,纯输出 + A3 Family 删 OpenkalLlvm(别名兜底) e2e 269 已在守 + §7.1 GlobalDefault 纳入 user-explicit + 状态行说明覆盖 + §4B.3 openkal-windows 的 cfg 改 not(env="msvc") 包侧 PR,已实测 + §7.4 compile_commands 默认收窄 333ms + 8.4MB + +第 1 批 · 性能 · 与语义解耦 + §5 staging 按包分组(1624 → ~10 条边) ← 收益最大 + §6 缓存身份去路径 / 归一拼写 / 原子提交 + §7.3 补上那 600ms 静默的两条状态行 + +第 2 批 · ⭐ 解锁 #492 + A2 sysroot 列的物化与渲染从 freestanding 解绑 ← 先做 §14.4 的实测 + S3 每层唯一供给者 ← 先扫索引确认无现存冲突 + §2 compiler 进 TargetSide + requires 语法 + +第 3 批 · 语义地基 + S1 env 三态 + 请求/事实分离 ⚠️ 改动面最大,先做统计 + S2 cfg 的 c-abi / kernel-abi / c++-abi 维度 + §13.1尾 pack 的 cfg_predicate_for 一起改 + §3 词表不再是门(依赖 §2 的诊断兜底) + A1 删 llvmSysroot(此时 #492 的其余部分可撤) + +第 4 批 + §8 文档 + example + 目标表 + SPEC-002 + §9 测试矩阵补格 + +明确不做 + parse() 开放 os/env token 把语义责任推给包作者(§12.5.1) + Family 开放 同上 + 命名空间准入门槛 把瓶颈换个仓库(§13.3) + 目标侧包预构建 违反 2+N+M 判据(§13.1) +``` + +⭐ **第 2 批做完,验收判据即成立** —— #492 的开发者只维护一个包。 +第 3 批是让这件事在语义上也说得通;第 0/1 批与它正交,可以并行。 + +--- + +## 15. `sysroot = "xim:musl-gcc@16.1.0"` 为什么读起来是错的 + +§14.3 的示例里有一行: + +```toml +[target.x86_64-linux-musl] +toolchain = "llvm@22.1.8" +sysroot = "xim:musl-gcc@16.1.0" +``` + +**llvm 的构建里出现一个 gcc 包 —— 这行读起来是错的,而它确实是错的。** +三层混乱叠在一起,每一层都可定位。 + +### 15.1 报告自己就是证据 + +同一个字段位置,两条来源给出性质不同的东西(⭐ 两行都是实测): + +``` +c-abi musl-gcc (xim:musl-gcc@16.1.0, prebuilt) ← 载荷来源:包名 +c-abi musl (openkal-musl@0.3.3, graph) ← 图来源:接口名 +``` + +`targetside/model.cppm` 逐行对照: + +```cpp +// 图来源 —— 包自己声明的 +ts.cAbi = { Origin::Graph, in.cAbi->display_interface(), in.cAbi->id(), false }; +// └─ provides = ["mcpp:c-abi=musl"] 里的 "musl" + +// 载荷来源 —— 从包名切出来的 +ts.cAbi = { Origin::Xpkg, xpkg_interface(in.sysrootXpkg), in.sysrootXpkg, false }; +// └─ "xim:musl-gcc@16.1.0" → 砍掉 ns 和版本 → "musl-gcc" +``` + +⭐ **`Graph` 来源有「接口 / 实现」分离,`Xpkg` 来源没有。** +`xpkg_interface()` 的注释写着「the interface a reader wants to see is the name」—— +它把**包的名字**和**接口的名字**当成了同一个东西,而这正是 +`Layer` 结构体开篇花二十行论证过的、必须分开的两样: + +> `openkal` is an interface; `openkal-macos`, `openkal-windows` … are +> implementations of it. Collapsing them would hide the fact that one source +> reaches four machines because four packages answer to one name. + +⚠️ 同一个缺陷在裸机行上也成立(读码推得,未实测): +`xim:picolibc-riscv@1.8.12` → 接口名会显示成 `picolibc-riscv`, +而接口是 `picolibc`,`-riscv` 是打包细节。 + +### 15.2 三层混乱,逐层拆开 + +**(1) 键名是 flag 名,不是角色名。** +`sysroot` 是 `--sysroot` 这个编译器选项的名字。而这个字段的**文档含义** +(`triple.cppm:101`)是「**The TARGET's C library**」。角色叫 `c-abi`, +机制才叫 sysroot。用机制命名角色,读者只能靠猜。 + +**(2) 值点名一个包,而不是一种能力。** +用户想说的是「这个目标的 C 库是 musl」。`xim:musl-gcc@16.1.0` 说的是 +「那个碰巧装着我要的东西的包」。`musl-gcc` 这个载荷里有 gcc、musl、libgcc、 +libstdc++ 四样,而这里只想要中间两样 —— 名字里却是第一样。 + +**(3) 版本是错的那个东西的版本。** +`@16.1.0` 是 **GCC** 的版本号。musl 自己的版本(1.2.x)在这行里根本不可见, +也无法指定。⇒ 「我要 musl 1.2.5」在这套语法里说不出来。 + +### 15.3 修法:让载荷也声明,而不是被猜 + +⭐ **根本修法只有一条:`Xpkg` 来源使用与 `Graph` 来源相同的能力声明。** + +xim 描述符增加同一套 `mcpp:` 能力键: + +```lua +-- xim-pkgindex/pkgs/m/musl-gcc.lua +provides = { "mcpp:c-abi=musl" } -- 我供给哪一层,接口叫什么 +provides_version = { ["mcpp:c-abi"] = "1.2.5" } -- 那一层的真实版本 +``` + +于是: + +- `xpkg_interface()` 这个**猜测函数被删掉**,两条通路收敛到一处; +- 报告变成 `c-abi musl (xim:musl-gcc@16.1.0, prebuilt)` —— 接口正确, + 实现地址仍然可见,这正是 `Layer` 结构体想要的两个字段; +- 「哪个包供给 musl」变成**可查询的数据**,而不是使用者要记住的知识。 + +### 15.4 用户侧的最终形态:那一行根本不该出现 + +分离之后,`[target.X]` 的键按**角色**命名,值按**接口**书写: + +```toml +[target.x86_64-linux-musl] +toolchain = "llvm@22.1.8" +c-abi = "musl" # 说的是「要 musl」 +``` + +⚠️ 三态必须保留(与 `[target.X].sysroot` 今天的三态一一对应, +理由见 `triple.cppm:207-217`): + +```toml +# 键缺席 → 目标表的列说了算 +c-abi = "musl" # → 我要 musl,由索引解析成载荷 +c-abi = "none" # → 零 libc 档(今天的 sysroot = "") +c-abi = { prebuilt = "xim:musl-gcc@16.1.0" } # → 逃生口:直接点名 +``` + +⭐ **而绝大多数情况下这一行也不该出现** —— 目标表的行本来就该给出默认: + +```toml +[target.x86_64-linux-musl] +toolchain = "llvm@22.1.8" +``` + +`xim:musl-gcc` 这个字符串留在 mcpp 自己的表里,那是 mcpp 的事,不是使用者的事。 +使用者只在**想换掉默认**时才写 `c-abi`。 + +⚠️ 兼容性:`sysroot` 作为**已废弃别名**保留,语义不变。裸机生态已经有清单在用 +`sysroot = "xim:picolibc-riscv@1.8.12"` 与 `sysroot = ""`,不得失效。 + +### 15.5 这条与 §14 的关系 + +§14 说「模型完备,缺的只是消费」—— 那是就**机制**而言。 +本节说的是:**机制完备不等于语义清晰**。`Origin::Xpkg` 这一支能跑通, +但它在两个地方比 `Origin::Graph` 弱一级: + +| | `Graph` | `Xpkg`(今天) | 修法 | +|---|---|---|---| +| 接口名 | 包声明 | **从包名猜** | §15.3 | +| 层的版本 | 包的版本即该层版本 | **是载体的版本** | §15.3 | +| 用户如何指定 | 一条普通依赖 | **点名一个载荷地址** | §15.4 | + +⇒ 这三条进 §14.5 的**第 2 批**(与 A2 同批):A2 让 `Xpkg` 能被渲染, +§15 让它读起来是对的。**只做 A2 会得到一个能用但语义混乱的机制** —— +而语义混乱正是本文开头那十条问题的共同来源。 + +### 15.6 落地顺序增补 + +``` +第 2 批 · 解锁 #492(增补) + A2 sysroot 列的物化与渲染从 freestanding 解绑 + §15.3 xim 描述符声明 mcpp: 能力;删掉 xpkg_interface() 的猜测 + §15.4 [target.X] 的键改为 c-abi(sysroot 降级为兼容别名) + S3 每层唯一供给者 + §2 compiler 进 TargetSide + requires 语法 +``` + +⚠️ §15.3 需要 xim 侧配合(描述符新增键),排期上要与 `xim-pkgindex` 协调; +而 §15.4 是纯 mcpp 侧,可以先行 —— 先行时 `c-abi = "musl"` 暂时只接受 +`{ prebuilt = "…" }` 形式,接口名解析等 §15.3 到位。 + +--- + +## 16. ⚠️ 撤回 A2:`--gcc-toolchain` 的借用是错的 + +§14.4 把「把 clang 指向目标的 C 运行时」定为 #492 唯一不可约的引擎改动, +并按 #492 的写法采用了 `--gcc-toolchain= -rtlib=libgcc +-unwindlib=libgcc`。**这个方案作废。** 它为了少改一点东西,把架构和语义弄乱了。 + +### 16.1 借的到底是什么:编译器运行时 + +实测 `openkal-llvm-runtime` 编了什么,按子系统: + +``` +compiler-rt 498 个对象 ← 68% +libcxx 159 +libcxxabi 51 +libunwind 21 +``` + +⭐ **这个包最大的一块是 compiler-rt,而它声明的能力只有 `mcpp:c++-abi=libc++`。** +compiler-rt builtins 是**C 程序**就需要的东西(`__udivti3`、`__muloti4`…), +与 C++ 毫无关系。 + +而 `#492` 借的正是这一块的 GCC 版本: + +| | 编译器运行时(builtins + unwinder) | +|---|---| +| openkal | LLVM 的 compiler-rt + libunwind —— **自己编,498 + 21 个对象** | +| #492 | **GCC 的 libgcc / libgcc_eh** —— 从 musl-gcc 载荷借 | + +⇒ 「llvm 怎么和 musl-gcc 混到一起」的准确答案: +**#492 让 clang 从 GCC 拿走它自己的编译器运行时。** + +### 16.2 代码自己就反对这件事 + +`src/toolchain/linkmodel.cppm:188-196`: + +```cpp +static constexpr std::string_view kLinkDriverFlags = + " -stdlib=libc++ -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind"; + +// The same selection for a link that has no C++ in it (mcpp#426). Only +// `-stdlib=` comes off: **the compiler runtime and the unwinder are just as +// much a C decision**, and dropping them would make a C link resolve +// __udivti3 differently from every other link in the same build. +static constexpr std::string_view kLinkDriverFlagsC = + " -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind"; +``` + +⭐ mcpp#426 已经把「编译器运行时是一条独立的、全构建必须一致的轴」写下来了 —— +理由就是「否则一次 C 链接解析 `__udivti3` 的方式会和同一次构建里其它链接不同」。 + +**而 A2 允许某一个目标把这条轴静默翻成 libgcc,正是那条注释在防的事。** + +⚠️ 并且这条轴今天有**三条互不相干的通道**: + +``` +引擎 linkmodel.cppm 的字符串常量 --rtlib=compiler-rt --unwindlib=libunwind +#492 它自己分支里的字符串 -rtlib=libgcc -unwindlib=libgcc +openkal 一个包里的 498 个对象 声明成 mcpp:c++-abi=libc++(名不副实) +``` + +「一个事实三条通道」正是 `targetside` 模块被创建出来消掉的形状。 + +### 16.3 模型缺第五层 + +四层里没有编译器运行时的位置,所以它只能藏在 flag 字符串里, +于是「llvm 配 libgcc」这种不协调**只有人眼能看出来,机器看不出来**。 + +**五层模型**: + +``` +compiler 谁在编译 llvm / gcc / msvc +compiler-rt 编译器自己的运行时 compiler-rt+libunwind / libgcc / MSVC 的 +kernel-abi 平台接口 linux / windows / openkal +c-abi C 库 glibc / musl / picolibc / 无 +c++-abi C++ 库 libc++ / libstdc++ / MSVC STL +``` + +⚠️ **compiler-rt 必须与 c++-abi 分开,而这已经被实测过一次**: +`targetside/model.cppm` 开篇记录的那个缺陷,就是「一个纯 C 程序交叉到 macOS, +图里没有 C++ 运行时」。那个程序**仍然需要 builtins**。 +把 builtins 塞在 `c++-abi` 名下,等于说一个 C 程序不需要 `__udivti3`。 + +加上这一层之后: + +``` +compiler llvm@22.1.8 (payload) +compiler-rt libgcc (xim:musl-gcc@16.1.0, prebuilt) ← ⚠️ 一眼看出不对 +c-abi musl (xim:musl-gcc@16.1.0, prebuilt) +c++-abi libc++ (…/llvm-musl-runtime@0.1.0, graph) +``` + +**规则二(上层必须为下层配置过)可以直接拒绝这一格** —— +不协调从「靠人眼」变成「机器可检查」。 + +### 16.4 替代方案:只读 musl 自己的文件,不需要重新打包 + +实测 `xim-x-musl-gcc/16.1.0` 的布局: + +``` +x86_64-linux-musl/ ← 纯 musl sysroot,零 GCC +├── include/ musl 的头 +└── lib/crt1.o crti.o crtn.o ⭐ 这三个是 musl 自己的 +lib/gcc/x86_64-linux-musl/16.1.0/ +├── libgcc.a ← GCC 的,在 sysroot 之外 +└── libgcc_eh.a +``` + +⭐ **musl 的 sysroot 子目录里一行 GCC 都没有。** `--gcc-toolchain=<载荷根>` +是刻意伸到那个子目录**之外**去拿 libgcc 的。 + +⇒ 正确的接线: + +``` +--sysroot=<载荷>/x86_64-linux-musl 只读 musl 的头、libc.a、crt1/crti/crtn +--rtlib=compiler-rt --unwindlib=libunwind 编译器运行时来自图(或 llvm 载荷) + ⇒ 消费的每一个文件都属于它声称的那一层 +``` + +**没有 `--gcc-toolchain`,没有 `-rtlib=libgcc`,不需要重新打包 xim。** + +配合 §15.3(xim 描述符声明自己供给哪层、在哪个子目录), +`xim:musl-gcc` 这个名字也不再出现在使用者面前: + +```lua +-- xim-pkgindex/pkgs/m/musl-gcc.lua +provides = { "mcpp:c-abi=musl" } +provides_path = { ["mcpp:c-abi"] = "${arch}-linux-musl" } -- 层在载荷里的位置 +provides_version = { ["mcpp:c-abi"] = "1.2.5" } +``` + +⇒ 报告写 `c-abi musl (xim:musl-gcc@16.1.0, prebuilt)` —— 诚实: +musl,由那个包供给。载荷叫什么名字是打包的事,不是语义的事。 + +### 16.5 修订后的 #492 验收清单 + +| # | 需要什么 | 现状 | 谁做 | +|---|---|---|---| +| 1–4 | 包声明能力 / 供 std 模块 / 交叉编译 / xpkg 被解析 | ✅ **已实测** | — | +| 5 | **单一角色的 sysroot 被渲染**(只 `--sysroot`,不 `--gcc-toolchain`) | ❌ | 引擎 | +| 5b | xim 描述符声明层与子目录 | ❌ | xim-pkgindex(§15.3) | +| 6 | `compiler-rt` 成为第五层并被检查 | ❌ | 引擎(§16.3) | +| 7 | `--toolchain` / 全局默认不被词表 pin 盖掉 | ❌ | §7.1 | +| 8 | 图供 libc++ × gcc ⇒ 拒绝 | ❌ | §2 | +| 9 | 冲突与身份可见 | ❌ | S3 / S4 | + +而**开发者维护的那个包**要供给两层,不是一层: + +```toml +provides = ["mcpp:compiler-rt=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] +``` + +⭐ 这正是 `openkal-llvm-runtime` 今天已经在**做**的事(498 + 21 + 159 + 51), +只是它今天只**说**了其中一半。 + +### 16.6 ⚠️ 唯一需要先实测的一件事 + +去掉 `--gcc-toolchain` 之后,clang 静态链接 musl 是否还需要 +`crtbegin.o` / `crtend.o`。本机检查:llvm 载荷里**没有** `clang_rt.crt*.o`。 +两种可能: + +- clang 在 musl/静态下根本不发它们(musl 用 `.init_array`,不需要)⇒ 直接可行; +- 需要,则由图里的运行时包供给(compiler-rt 本来就能构建它们)⇒ 包多编两个文件。 + +⚠️ **这是本节唯一的未知数,必须先测再动手。** 测法:手工拼一条 +`clang --target=x86_64-unknown-linux-musl --sysroot=<载荷>/x86_64-linux-musl +--rtlib=compiler-rt --unwindlib=libunwind -static` 的链接,看能否产出可运行的 ELF。 + +### 16.7 对 §14.5 落地顺序的修订 + +``` +第 2 批 · 解锁 #492(修订) + §16.6 先实测:去掉 --gcc-toolchain 后 musl 静态链接是否成立 + §16.3 compiler-rt 成为第五层(模型 + 报告 + 规则二检查) + A2' 单一角色 sysroot 的渲染(⚠️ 不是 A2:不发 --gcc-toolchain,不改 --rtlib) + §15.3 xim 描述符声明层 / 子目录 / 层版本 + §15.4 [target.X] 的键改为 c-abi + S3 每层唯一供给者 + §2 compiler 进 TargetSide + requires + +明确不做(增补) + --gcc-toolchain 借用 / -rtlib=libgcc + ← 让一次构建里的 __udivti3 与其它链接不一致(mcpp#426 已论证); + 为省一次打包/一层建模而牺牲语义,是本文反对的那类捷径 +``` diff --git a/.agents/docs/2026-08-24-target-side-architecture.md b/.agents/docs/2026-08-24-target-side-architecture.md new file mode 100644 index 00000000..336cb1ac --- /dev/null +++ b/.agents/docs/2026-08-24-target-side-architecture.md @@ -0,0 +1,1090 @@ +> ⚠️ **本文是推导过程中的工作稿,已被 `2026-08-24-target-side-design.md` 取代。** +> 它保留了几次自我修正的痕迹(`[layer.X]` 段、`sysroot` 保留论、报告打全限定名), +> 那些结论**都已被推翻**。要读设计,读定稿;本文只在想知道「为什么不那样」时有用。 + +# 目标侧架构:五层、四来源、四规则 + +2026-08-24。**这是设计文档,不是缺陷清单。** + +`.agents/docs/2026-08-24-graph-target-side-optimization-plan.md` 记录了导出这份 +设计的十余条实测与缺陷考古;本文只陈述**设计本身**,正面表述,不引用任何一个 +具体 PR。它是 `docs/spec/SPEC-002` 的种子。 + +三个维度分别在 §1 / §2 / §3:**架构**、**语义一致性**、**使用侧**。 + +--- + +## 0. 一句话 + +> **一次构建的目标侧由五个层构成;每层恰好一个供给者;供给者可以是载荷、 +> 预制包或依赖图;引擎知道有哪五层,永远不知道有哪些实现。** + +这句话里每一个词都承重,§1 逐个展开。 + +--- + +## 1. 架构 + +### 1.1 五层 + +一次 C/C++ 构建的目标侧,是五样东西的组合。它们是**层**,不是**选项**: +每一层都为它下面那层配置过,换掉任何一层都要求上面的层同意。 + +| 层 | 是什么 | 实现举例 | +|---|---|---| +| `compiler` | 谁在编译 | `llvm` / `gcc` / `msvc` | +| `compiler-runtime` | **编译器自己的运行时**:整数/浮点 builtins、展开器 | `compiler-rt`+`libunwind` / `libgcc` / MSVC 的 | +| `kernel-abi` | 平台接口:系统调用或它的等价物 | `linux` / `windows` / `darwin` / `openkal` / 无 | +| `c-abi` | C 库 | `glibc` / `musl` / `picolibc` / 无 | +| `c++-abi` | C++ 库与它的 ABI 运行时 | `libc++`+`libc++abi` / `libstdc++` / MSVC STL / 无 | + +**为什么恰好是这五个。** 每一层都满足三个条件,少一个就不该是层: + +1. **野外至少有两个可互换的实现** —— 否则它不是接缝,是常量; +2. **可以独立于其它层被替换** —— 否则它属于相邻的层; +3. **对下一层有明确的「被谁配置过」关系** —— 这是规则二能成立的前提。 + +⚠️ **`compiler-runtime` 是独立的一层,不是 `c++-abi` 的一部分。** +builtins(`__udivti3`、`__muloti4`…)是**一个 C 程序**就需要的东西。把它算作 +C++ 运行时的一部分,等于说一个纯 C 程序不需要整数除法 —— 而这个错误已经 +被实测过:一个纯 C 程序交叉到 macOS 时,判据问「有没有 C++ 运行时」并答「没有」, +于是链接行保留了载荷自己的 libc++ 并把一个 Linux 共享对象递给了 Mach-O 链接器。 + +⚠️ **`kernel-abi` 是独立的一层,而在传统栈上它没有名字。** +一个 C 库直接发系统调用或直接调平台入口,那道缝没有被命名。命名它, +才使得一份 C 库源码能坐在四个平台上 —— 这是这套生态的核心贡献。 + +### 1.2 四来源 + +每一层的供给者来自四个地方之一(`targetside::Origin`): + +| 来源 | 含义 | 何时可知 | +|---|---|---| +| `Payload` | 编译器载荷自带 | 依赖解析**之前** | +| `Xpkg` | 一份被点名的预制载荷 | 依赖解析**之前** | +| `Graph` | 依赖图里的包 | 依赖解析**之后** | +| `None` | 没有,且这是一个陈述 | — | + +⚠️ **`None` 是答案,不是缺口。** 裸机没有内核,零 libc 档没有 C 库。 +「这一层没有供给者」与「这一层还没解析」必须是两个可区分的状态。 + +⭐ **四来源里两个在图之前可知、两个在图之后才可知,所以目标侧只能在 +依赖解析之后解析一次。** 在那之前作任何猜测,都是对一个尚不存在的事实猜测; +多处猜测必然互相矛盾。 + +### 1.3 四规则 + +**规则一 —— 每层恰好一个供给者。** + +C 库、内核接口、C++ 运行时不是**可叠加的贡献**,是**互斥的选择**。 +两个供给者是错误,必须在解析期拒绝并指出双方。 + +> 与 Cargo 的 `links` 键同构:全图至多一个包声明某个值。 +> ⚠️ 失败模态是这条规则存在的理由:选错不会链接失败,会得到一个能跑、 +> 偶尔崩的程序。 + +**规则二 —— 每层必须为它下面那层配置过。** + +一份 libc++ 的 `__config_site` 记录了它是对着哪个 C 库配置的; +一份 `libgcc` 是为 gcc 配置的。**「为谁配置过」是可声明的事实,不是可推断的。** + +推论: +- 载荷的 C++ 运行时只在 C 库也来自同一载荷时可用; +- `compiler-runtime` 必须与 `compiler` 同族 —— 否则一次链接解析 `__udivti3` 的方式 + 会与同一次构建里其它链接不同; +- 图供给的 C++ 运行时若声明 `requires = ["mcpp:compiler=llvm"]`,gcc 必须被拒绝。 + +**规则三 —— 引擎只在「跨来源」时接线。** + +| 组合 | 谁表达两层之间的关系 | 引擎 | +|---|---|---| +| 两层都在 `Graph` | 包之间的普通依赖 | 不介入 | +| 两层都在 `Payload` | 载荷自洽 | 不介入 | +| 一层预制、一层在图 | **只有引擎同时知道两边的地址** | 必须接线 | + +⇒ **把一层从预制挪进图,引擎要做的事就变少一件。** 这不是巧合, +是规则三的直接推论,也是「一份源码到达四个平台却不需要改引擎」的原因。 + +**规则四 —— 引擎硬编码层名,永不硬编码实现名。** + +`compiler` / `compiler-runtime` / `kernel-abi` / `c-abi` / `c++-abi` 是编译进引擎的 +闭集。`openkal` / `musl` / `libc++` / `picolibc` 不出现在引擎任何一行代码里。 + +**层名可以硬编码,因为层由 C/C++ 构建模型固定、不增长。 +实现不可以,因为增长正是它们要做的事:生态的组合是 N×M,而包数是 N+M。** + +⚠️ 这条规则的推论之一:**目标侧包不该走预构建分发。** +预构建资产数 = 目标 × 编译器版本 × C 库,那是 N×M 那一侧;源码包是 1。 +冷构建的代价已实测为「每台机器每 (包版本 × 目标) 一次」(跨工程命中), +不构成放弃这条规则的理由。若它将来成为问题,答案是**共享构建缓存**, +不是预构建包 —— 那保留全部三条性质而只消掉重算。 + +### 1.4 目标三元组在这个模型里是什么 + +**三元组是一个请求,`TargetSide` 是事实。** + +`--` 三个字段承载四条正交的轴:机器、平台接口、对象格式+调用 ABI、 +C 库。第四条在图模型下不再由名字决定,所以: + +- `env` **空 = 未指定**,由供给方决定 —— 图模式下这是正确写法; +- `env` **非空 = 一条请求**,若与解析出的事实矛盾,**拒绝**,不并排打印; +- 三元组永远不是「C 库是谁」的答案,`TargetSide.cAbi` 才是。 + +⚠️ macOS 今天已经是这个形状(三元组没有 env 段),它是这条设计已被验证可行的证据。 + +--- + +## 2. 语义一致性 + +七条。每条是一句「某样东西恰好表示一件事」。 + +### S1 — 三元组是请求,`TargetSide` 是事实 + +`env` 三态:缺席(未指定)/ 指定 / 与事实矛盾(拒绝)。 +与 `[target.X]` 的 C 库三态一一对应,不是新发明。 + +### S2 — `cfg()` 按事实求值,不按名字 + +包想问的是「C 库是不是 musl」,不是「三元组的第三段是不是 `musl`」。 + +```toml +[target.'cfg(c-abi = "musl")'.build] +[target.'cfg(kernel-abi = "openkal")'.build] +[target.'cfg(c++-abi = "libc++")'.build] +``` + +⚠️ 这三个维度只能用于 `[build]` 段。`[dependencies]` 里必须**显式拒绝** —— +`TargetSide` 在依赖解析之后才有,用它决定依赖会成环。这条限制要报错, +不能留给使用者去撞。 + +### S3 — 每层恰好一个供给者 + +规则一的语义面。冲突在解析期报错,错误里指出双方及各自的引入路径。 + +### S4 — 能力由**声明**得来,永不由**名字**猜 + +⭐ 这条是本设计与现状差距最大的一条。 + +一个供给者必须**声明**它供给哪一层、接口叫什么、那一层的版本是多少。 +图里的包与预制载荷用**同一套声明**: + +```toml +# 依赖图里的包 +provides = ["mcpp:c++-abi=libc++", "mcpp:compiler-runtime=compiler-rt"] +``` + +```lua +-- 预制载荷的描述符 —— 布局描述,不是构建输入(见 S5 末) +provides = { "mcpp:c-abi=musl" } +layer = { + ["c-abi"] = { + path = "${arch}-linux-musl", -- 这一层在载荷里的位置 + version = "1.2.5", -- 这一层的真实版本(不是载体的) + }, +} +``` + +三个后果,每个都消掉一处今天的混乱: + +1. **接口名不再从包名切出来。** 一个叫 `musl-gcc` 的载荷可以诚实地声明 + 「我供给 `c-abi`,接口是 `musl`」,报告写 `c-abi musl (…, prebuilt)`。 + **载荷叫什么名字是打包的事,不是语义的事。** +2. **层的版本不再是载体的版本。** musl 1.2.5 可以被说出来、被约束。 +3. **载荷里哪一部分属于哪一层,是被声明的。** ⇒ 引擎只消费被声明的那个子目录, + 不会伸到别处去拿本不属于这一层的东西。 + +⚠️ 第 3 点是规则二的执行手段:**一个载荷同时装着两层的实现时, +消费其中一层不得连带消费另一层。** + +### S5 — `provides` 说供给什么;细节放在它本来属于的地方 + +```toml +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] + +[build] +sources = ["llvm/libcxx/src/**/*.cpp", "..."] +include_dirs = ["llvm/libcxx/include", "..."] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc", "-nostdinc++"] +``` + +⚠️ **本文初稿在这里发明了一个 `[layer.<名>]` 段,那是多余的。** + +`std-module` 就是一个 `.cppm` 文件,`std-module-flags` 就是一组 flag —— +它们与 `sources` / `include_dirs` 是同一类东西,`[build]` 就是它们的家。 +引擎自己的注释早就这么说了: + +> The std module source is **one of this package's translation units in every +> way that matters**, and it reaches the C library's headers the same way the +> rest of them do. + +⭐ **而放进 `[build]` 还白得一样能力:它立刻可以按目标侧条件化。** + +```toml +[target.'cfg(c-abi = "musl")'.build] +std-module-flags = ["-D_GNU_SOURCE"] # musl 的 locale 层需要 + +[target.'cfg(c-abi = "picolibc")'.build] +std-module-flags = ["-D_LIBCPP_HAS_NO_THREADS"] +``` + +这不是假想需求:`-D_GNU_SOURCE` 今天就写在一个包的 std-module-flags 里, +而它对 picolibc 是错的。`[layer.<名>]` 要重新发明一遍 `[target.'cfg(…)'.…]` +才能表达同一件事 —— **新段的代价不只是多一个段,是多一套条件化机制。** + +⚠️ 保留的只有那条**校验规则**,它与放在哪一段无关: +**声明了 `std-module` 却没有对应的 `provides` 条目,是错误,不是被忽略。** +(今天是被静默跳过,于是一个写错的清单构建成功而行为不对。) + +**预制载荷的描述符形状不同,而这是对的。** 一个包**被构建**,所以它的层细节 +是构建输入;一份载荷**不被构建**,所以它的层细节是**布局描述**: + +```lua +provides = { "mcpp:c-abi=musl" } +layer = { ["c-abi"] = { path = "${arch}-linux-musl", version = "1.2.5" } } +``` + +两者形状不同,因为它们性质不同 —— 强行对称会让其中一边说不出自己要说的话。 + +`requires` 是规则二在包侧的表达。引擎硬编码键名与层名,实现名来自清单。 + +### S6 — 报告用使用者自己的拼写 + +⚠️ **本文初稿在这里主张「报告一律打全限定名」,那是错的。** + +理由是:使用者在 `[dependencies]` 里写的是**短名**(`openkal-llvm-runtime`), +命名空间由索引解析、由 `mcpp.lock` 记录,他在任何地方都不打那个前缀。 +报告里引入一个他不使用的拼写,是在制造第二套词汇 —— 而消除第二套词汇 +正是本设计的目的。 + +``` +c++-abi libc++ (openkal-llvm-runtime@0.1.1, graph) ← 与清单里的写法一致 +``` + +**「谁供给了我的 libc」这个信任问题不属于目标侧报告。** 它属于依赖解析: +一个短名解析到了错误的命名空间,影响的是**整个构建**而不只是目标侧; +`mcpp.lock` 已经逐条记录 `namespace`,那才是该看的地方。 + +全限定名出现在三处,且只在这三处: + +- `-v` / `MCPP_VERBOSE=1`; +- **同一次构建里两个短名相同**时(此时短名不再是标识符); +- 诊断信息里(错误必须无歧义)。 + +### S7 — 层名硬编码,实现名永不硬编码 + +规则四的语义面,并给出边界: + +| | 性质 | 增长 | 谁赋予语义 | 结论 | +|---|---|---|---|---| +| 层的组合 | 组合 | N×M | 包自己 | **数据** | +| 有哪些层 | 词汇 | 不增长 | 引擎 | 闭集 | +| 有哪些 os / env token | 词汇 | 加法,一年一两个 | **引擎**(对象格式、库命名、strip 适用性…十余处行为) | 闭集 | +| 有哪些编译器族 | 词汇 | 加法 | **引擎**(flag 拼写、模块模型、BMI 格式) | 闭集 | + +⚠️ 闭集不是保守,是**语义责任的归属**:一个包定义的 opaque `os`, +回答不了引擎那十余处要问它的问题。 + +⚠️ 推论:**「目标侧从哪来」不得由工具链的名字表达。** +工具链族的命名空间里只能有编译器。 + +### S8 — 三种受众,三套词汇,互不外溢 + +⭐ 本设计有**三个**表面,不是两个。混淆它们是「语义不清晰」的主要来源。 + +| 受众 | 需要理解什么 | 词汇 | +|---|---|---| +| **使用者** | 什么都不需要 | 三元组、`[toolchain]`、`[dependencies]` | +| **普通库作者** | 传统的平台轴 | `cfg(os / arch / family / env)`、`[lib]`、`sources` | +| **运行时 / C 库 / 平台包作者** | 全部五层 | `provides` / `requires` / `cfg(kernel-abi / c-abi / c++-abi)` | + +三条边界规则: + +1. **层名不出现在使用者的清单里。** 使用者用三元组和依赖表达一切(§3.0)。 +2. **层名不出现在普通库作者的清单里。** 一个 JSON 解析器写零个 `cfg`; + 一个有平台后端的库写 `cfg(windows)` / `cfg(os = "linux")` —— + 那是它一直在用的轴,不需要学任何新东西。 +3. **层名只属于第三类作者。** 一个库只有在**刻意要为某个内核接口写后端**时 + 才写 `cfg(kernel-abi = "openkal")` —— 那一刻它就不是普通库了, + 它是这套生态的参与者,理解分层是它的工作内容。 + +⚠️ 判断一个新语法该放进哪一层,问:**最不懂行的那一类受众会不会被迫看到它?** +会,就放错了。 + +--- + +### S9 — 一个包适配已解析的目标侧,而不是被告知 + +一个供给某层的包常常支持**多种**下层实现:同一份 libc++ 源码可以配 musl、 +配 glibc、配 picolibc。**它不需要被告诉是哪一种,它去问。** + +```toml +# acme/llvm-runtime —— 一份源码,三种 C 库 +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] + +[target.'cfg(c-abi = "musl")'.build] +cxxflags = ["-D_GNU_SOURCE"] +include_dirs = ["config/musl"] + +[target.'cfg(c-abi = "glibc")'.build] +include_dirs = ["config/glibc"] + +[target.'cfg(c-abi = "picolibc")'.build] +cxxflags = ["-D_LIBCPP_HAS_NO_THREADS"] +include_dirs = ["config/picolibc"] +``` + +⭐ **使用者一个字都不用多写。** 他写了 `--target x86_64-linux-musl`, +或者他的图里有一个 `provides = ["mcpp:c-abi=musl"]` 的包 —— 两种情况下 +`c-abi` 都已经被解析出来了,包直接读那个结果。 + +⚠️ **这是「不必重复自己」在包作者面的形式**:如果这里要求一个 +`features = ["musl"]`,使用者就得把已经说过的话再说一遍,而且两处可能说得不一致。 + +**限制,以及它的形状。** `cfg(c-abi = …)` 只能用于 `[build]`,不能用于 +`[dependencies]`(S2:目标侧在依赖解析之后才有,用它决定依赖会成环)。 +所以「不同 C 库需要不同的**依赖**」表达不了。两个出路: + +- 把那部分拆成每 C 库一个包,由使用者或上层选择; +- 依赖并集,在 `[build]` 里按 `cfg` 选择编译哪些源码 —— 多数情况够用。 + +⚠️ 这条限制是**平的**:不因为 `c-abi` 恰好来自三元组(解析前可知)而放宽。 +一个清单不应该「有时能写、有时不能写」,那比一律禁止更难理解。 + +**真正的 feature 仍然是 feature。** 「要不要异常」「要不要 filesystem」 +「哪种线程模型」不由目标侧决定,它们是 `features`,由使用者选择 —— +必要时按目标选择,用已有的条件依赖表: + +```toml +[target.'cfg(os = "none")'.dependencies] +acme-llvm-runtime = { version = "0.1", features = ["no-exceptions"] } +``` + +判据:**能从目标侧推出来的,不做成 feature;推不出来的,才是 feature。** + +--- + +### S10 — C 库是一条依赖,不是一个键;`sysroot` 退休 + +⚠️ 本文先前把 `[target.X].sysroot` 定为「明知的疤,保留」。**那个结论太保守了。** + +#### 今天为什么是这个形状 + +引擎的目标表里有 `sysroot` 一列,它的注释说明了理由: + +> 这条轴存在,是因为裸机是唯一一类没有它的目标 …… 在此之前每个裸机包都得自己 +> 声明 `[xlings] deps = ["xim:picolibc-riscv@1.8.12"]`。**那不是这个包的依赖, +> 那是这个目标的性质**,而按包声明会把一个板级支持包绑死到一个 libc、 +> 一个指令集、一个版本上。 + +诊断是对的,**解法把知识挪错了地方**:它从「每个包」挪到了「引擎的表」。 +后果是 —— **一块新板子要想指定它的 C 库,需要发一个 mcpp 版本。** + +#### 知识本来属于板级支持包 + +一块板子需要哪个 C 库,是**这块板子的性质**,而**板级支持包就是描述这块板子的 +那个东西**。它既不是引擎的知识,也不该由每个使用者重复书写。 + +```toml +# acme/riscv-virt-rt —— 一块板子的支持包 +[package] +namespace = "acme" +name = "riscv-virt-rt" +version = "0.4.0" + +[dependencies] +picolibc = "1.8" # ⭐ 这块板子要 picolibc。一行,包自己说。 +``` + +使用者侧因此**一个字都不用写**: + +```toml +[dependencies] +riscv-virt-rt = "0.4" +``` + +⭐ 一条依赖,而不是「一条依赖 + 一个 `[target.riscv64-none-elf].sysroot`」。 +这正是「每多一个需求只多一行」。 + +#### 缺的那一块机制:包不能引用一份载荷的路径 + +⚠️ **实测:清单里不存在任何指向 xim 载荷的替换。** `${mcpp.out_dir}` 一类是 +构建输出路径,而且属于 build.mcpp;没有 `${xim:…}`。 +`[xlings] deps` 只声明「要装」,不给出「装在哪」。 + +⇒ 一个想把现成 `xim:picolibc-riscv` 载荷包装成能力供给者的包,写不出来。 + +**提案:一个替换,`${xim:<名>}`。** + +```toml +[package] +namespace = "acme" +name = "picolibc" +version = "1.8.12" +provides = ["mcpp:c-abi=picolibc"] + +[xlings] +deps = ["xim:picolibc-riscv@1.8.12"] + +[build] +sources = [] # 不编译任何东西 +include_dirs = ["${xim:picolibc-riscv}/include"] +ldflags = ["-L${xim:picolibc-riscv}/lib", "-lc"] +``` + +一个机制,四处收益: + +1. **任何现成载荷都能被包装成能力供给者** —— 不需要重新打包,不需要改引擎表; +2. **板级支持包能说出自己要哪个 C 库**,新板子不再需要 mcpp 发版; +3. **`sysroot` 列失去存在理由** —— 它做的事现在由一条普通依赖完成; +4. 与 §12 的结论一致:**把一层从预制挪进图,引擎要做的事就少一件**(规则三)。 + +⚠️ 这条机制还有一个前置缺陷要一起修:**依赖包的 `[xlings] deps` 今天一个都不装** +(只有根工程的会装)。判据必须是「把它拿走再装回来」,不能只看构建是否绿。 + +#### 零 libc 档不再需要拼写 + +今天 `sysroot = ""` 存在,是因为目标表**默认给了**一个 C 库,项目需要一种方式说「不要」。 + +C 库改由依赖供给之后:**不依赖 = 没有。** 零 libc 档变成**默认**, +而想要 picolibc 的人加一条依赖。 + +``` +不写任何依赖 → c-abi — 零 libc 档 +[dependencies] picolibc → c-abi picolibc +``` + +⭐ **一个键、一种特殊拼写、一条「空字符串是有意义的」规则,同时消失。** + +#### 迁移 + +⚠️ 目标表里现有的 `sysroot` 行不能直接删 —— 已发布的裸机工程会突然失去 C 库。 +分两步: + +1. 保留现有行,但命中时打一条提示: + 「这个 C 库来自目标表的默认;把它声明成依赖会更明确,并且让这块板子 + 在没有 mcpp 新版本的情况下也能换 libc」; +2. 生态迁移完毕后删除该列,`[target.X].sysroot` 与 `sysroot = ""` 同时退休。 + +在此之前 `sysroot` 仍是逃生口 —— 但它不再是**推荐路径**,文档要明说。 + +--- + +--- + +## 3. 使用侧 + +设计目标按优先级:**零配置最短 → 每多一个需求只多一行 → 不必重复自己 → +报告即文档 → 诊断即修法。** + +### 3.0 ⭐⭐ 支配性原则:五层是引擎的词汇,不是使用者的 + +**§1 的五层是内部模型。它出现在报告和诊断里,不出现在清单里。** + +> 报告是**诊断输出**,诊断输出可以、也应该用精确的内部词汇 —— +> 那正是它有用的原因。 +> 清单是**配置输入**,配置输入必须用使用者本来就有的词。 + +⚠️ 把层名做成清单键(`c-abi = …`、`compiler = …`)是一个具体的错误, +它有两个可指认的坏处: + +1. **它要求使用者学会引擎的分层**才能写清单。而使用者要表达的东西, + 用他已有的三样就能说完。 +2. **它制造重复。** `[target.x86_64-linux-musl]` 里再写 `c-abi = "musl"`, + 是同一句话说两遍 —— 三元组的第三段**就是**那句话。 + +### 3.1 使用者只有三样东西 + +| 使用者写什么 | 它决定哪些层 | +|---|---| +| **目标三元组** `--target` / `[build] target` | `kernel-abi`(os 段)、`c-abi` 的**请求**(env 段) | +| **`[toolchain]`** / `mcpp toolchain default` | `compiler` | +| **`[dependencies]`** | 其余一切:`kernel-abi` / `c-abi` / `compiler-runtime` / `c++-abi` 的实现 | + +```bash +mcpp build # 宿主。什么都不写 +mcpp toolchain default llvm # 换编译器。一条命令,全局 +mcpp build --target x86_64-linux-musl # 交叉,并请求 musl +mcpp build --target x86_64-linux # 交叉,C 库由供给方决定(S1 的空 env) +``` + +换掉任何一层 = **加一条依赖**: + +```toml +[dependencies] +openkal-llvm-runtime = "0.1" # 供给 compiler-runtime + c++-abi +openkal-linux = "0.5" # 供给 kernel-abi +openkal-musl = "0.3" # 供给 c-abi +``` + +⭐ **三元组的 `env` 段就是使用者表达 C 库请求的方式,不需要第二个键。** +`x86_64-linux-musl` = 「我要 musl」;`x86_64-linux` = 「谁供给谁说了算」。 +⇒ §1.4 的三态是**三元组自己的**三态,不是某个新键的三态。 + +⭐ **`toolchain` 保持它现在的名字。** 它是使用者已经有的词。 +把它改叫 `compiler` 只为了与内部层名对齐,正是本节反对的那种越界。 + +### 3.2 逃生口:两个键,不属于日常表面 + +极少数情况需要点名,它们放在 `[target.]` 里,**是逃生口而不是接口**: + +```toml +[target.x86_64-linux-musl] +toolchain = "llvm@22.1.8" # 只给这个目标换编译器 + +[target.riscv64-none-elf] +sysroot = "" # 零 libc 档 —— 三元组表达不了的项目决定 +``` + +```toml +[target.x86_64-linux-musl] +sysroot = "xim:some-musl@1.2.5" # 点名一份预制供给者,覆盖目标表的默认 +``` + +判据:**一个键能进日常表面,当且仅当它表达的东西三元组、工具链、依赖三样都说不出来。** + +⚠️ 按这条判据,`sysroot` **今天勉强及格,而它不该及格** —— 它表达的东西 +(「这个目标用哪个 C 库」)本来就该由一条依赖说出来。它之所以还在, +是因为一个包引用不到载荷的路径(S10)。**S10 落地后 `sysroot` 退休**, +逃生口只剩 `toolchain` 一个。 + +⭐ **绝大多数工程一行都不写。** 目标表的行给出默认,`xim:` 地址留在引擎的表里 —— +那是引擎的事,不是使用者的事。 + +### 3.3 报告:唯一暴露五层的地方,而且按需暴露 + +报告是诊断输出,所以它用精确的内部词汇。但「零配置最短」同样适用于输出: +**五行里有五行是 `(payload)` 时,那五行没有信息。** + +**默认:只显示不来自编译器载荷的层。** 全部来自载荷 ⇒ 只有目标那一行。 + +``` + Compiling app v0.1.0 (.) + Target x86_64-linux-gnu ← 零配置工程:一行 +``` + +``` + Compiling app v0.1.0 (.) + Target x86_64-linux-musl → x86_64-unknown-linux-musl + compiler-runtime compiler-rt (mcpplibs/llvm-musl-runtime@0.1.0, graph) + c-abi musl (xim:some-musl@1.2.5, prebuilt) + c++-abi libc++ (mcpplibs/llvm-musl-runtime@0.1.0, graph) + ← compiler 与 kernel-abi 来自载荷,不占行 +``` + +`-v` / `MCPP_VERBOSE=1` 显示全部五层,含 `(payload)` 的那些。 + +四条设计约束: + +- **打的是解析结果,不是清单里的意图。** 清单会过期,结果不会。 + 这也是本设计不为同一信息新增任何 manifest 字段的原因。 +- **接口与实现是两列。** `openkal` 是接口,`openkal-windows` 是实现; + 合并它们会掩盖「一份源码到达四个平台,因为四个包应答同一个名字」。 +- **来源词与层对齐。** `payload` / `prebuilt` / `graph` / `—`。 +- **只显示与默认不同的那些**(上文);⚠️ 但**诊断不受此限** —— + 一条错误必须打印它所依据的全部层,包括来自载荷的。 + +### 3.4 诊断:说决定,不说后果 + +⚠️ **一条编译器或链接器的原话,几乎总是一次诊断缺失。** +凡是引擎已经知道结果不成立的组合,必须在编译开始之前说出来。 + +``` +error: this C++ runtime requires an llvm-family compiler. + c++-abi libc++ (mcpplibs/llvm-musl-runtime@0.1.0, graph) + compiler gcc@16.1.0 (target default for x86_64-linux-musl) + Yours outranks mcpp's default: + mcpp toolchain default llvm@22.1.8 + or, for this target only: + [target.x86_64-linux-musl] + toolchain = "llvm@22.1.8" +``` + +``` +error: two packages supply the C ABI, and it is a choice rather than a contribution. + mcpplibs/openkal-musl@0.3.3 (via mcpplibs/openkal-llvm-runtime) + acme/tinylibc@0.2.0 (a direct dependency) + A build has exactly one C library. +``` + +``` +error: this build requests the `gnu` C ABI, and its graph supplies `musl`. + Write `--target x86_64-linux` to let the graph decide. +``` + +``` +error: nothing supplies this target's C library. + c-abi — + The compiler payload carries none for x86_64-linux-musl, and no + dependency provides `mcpp:c-abi`. + Name one for this target, or depend on a package that implements it: + [target.x86_64-linux-musl] + sysroot = "xim:some-musl@1.2.5" +``` + +``` +error: the compiler runtime is not the compiler's own. + compiler llvm@22.1.8 (payload) + compiler-runtime libgcc (xim:musl-gcc@16.1.0, prebuilt) + Every translation unit in a build must agree on what a `throw` and an + integer division compile into. Supply llvm's own (compiler-rt + + libunwind) from the graph, or build with gcc. +``` + +⚠️ **每一条可粘贴的行都是承诺,包括其中的版本号。** 示例里的版本必须与索引 +当天的 latest 一致,并在发版时同批更新。 + +### 3.5 不必重复自己 + +一条明确的产品承诺: + +> **mcpp 修订它自己的默认,永不修订你的。** + +推论:一个用户用明确命令设下的全局默认,**不得**被目标表的行推翻。 +目标表的行是 mcpp 自己的默认,它的位次低于任何用户陈述。 + +若一次解析确实覆盖了什么,状态行必须说出来: + +``` +Resolved gcc@16.1.0 (target default for x86_64-windows-gnu, overriding your default llvm@22.1.8) +``` + +### 3.6 `mcpp toolchain list` 显示什么 + +⚠️ 一张按载荷构成的表,结构上无法列出图能到达的目标。 + +- Targets 表加 **SOURCE** 列:`payload` / `graph`; +- 在**工程上下文**里(cwd 有清单且有依赖)按图重算该表; + 无工程时退回载荷视图并注明; +- Available 段按**载荷包**去重,不按族 —— 同一份载荷不得因为有两个名字 + 而被报成「未安装」。 + +--- + +## 4. 兼容性 + +⚠️ 三条硬约束,违反其中任何一条都会让已发布的包或已有的工程失效。 + +1. **老引擎遇到未知 `mcpp:` 层名或未知清单键,必须降级而不是让整份清单 + 加载失败。** 这条已经付过学费:一个不认识的键曾让整份 manifest 无法加载, + 于是「给已发布的包加一个新键」成为不可能。 +2. **旧拼写保留为别名。** `[target.X].sysroot` → `c-abi`; + `[target.X].toolchain` → `compiler`;`hosted-standard-library` → + `mcpp:c++-abi`。语义不变,只是不再是首选写法。 +3. **规则一(唯一供给者)上线前必须先扫索引。** 若已存在两个包在同一层 + 声明能力,打开检查会让现存工程直接构建失败。 + +--- + +## 5. 判据:怎么知道这个设计对了 + +不是「测试通过」,是**这五条同时成立**: + +1. **一份新平台的实现,发一个包就能被使用**,引擎零改动、索引零改动。 +2. **报告里的每一行都能被一个包或一份载荷的声明解释**,没有一行是猜出来的。 +3. **每一个「编译器/链接器原话」的失败,都有一条更早的 mcpp 诊断。** +4. **零配置工程的清单里没有任何一行提到目标侧。** +5. **同一个事实在代码里只有一处推导。** 新增一个消费者时,它去问那一处, + 而不是新增第 N 条并行判据。 + +⚠️ 第 5 条是最容易退化的一条,也是最贵的一条:目标侧的判据曾在三处 +各推一遍并互相矛盾,而收敛它们花掉的代价远大于当初写对的代价。 + +--- + +## 6. 明确不做 + +| 不做 | 理由 | +|---|---| +| 开放 `os` / `env` token 表 | 把语义责任推给包作者;引擎有十余处行为要问这个字段(S7) | +| 开放编译器族 | 同上;族的差异是引擎必须知道的,不是数据能描述的 | +| 命名空间准入门槛 | 把瓶颈换个地方;违反规则四;挡不住真风险(S6) | +| 目标侧包预构建分发 | 资产数回到 N×M 那一侧(规则四);冷构建成本已实测可接受 | +| 跨族借用编译器运行时 | 让同一次构建里的链接互相不一致;规则二的直接违反 | +| 为报告里的信息新增清单字段 | 清单陈述意图会过期,报告陈述结果不会(§3.2) | +| 发明新的三元组语法 | 三元组要与 LLVM/GNU 写法互认;要改的是「空」的含义,不是语法 | + +--- + +## 7. 与当前实现的差距 + +本文是设计;差距、成因与实测证据在 +`2026-08-24-graph-target-side-optimization-plan.md`。摘要: + +| 设计条款 | 现状 | +|---|---| +| §1.1 五层 | 三层。`compiler` 与 `compiler-runtime` 未建模 | +| §1.2 四来源 | ✅ 已有(`targetside::Origin`) | +| §1.3 规则一 | ❌ 冲突时图遍历顺序第一个静默胜出 | +| §1.3 规则二 | ⚠️ 半条(只守「载荷 C++ 运行时 × 非载荷 C 库」) | +| §1.3 规则三 | ⚠️ 图×图、载荷×载荷成立;跨来源未接线 | +| §1.3 规则四 | ✅ 层名侧成立;⚠️ 工具链族里混入了一个目标侧策略 | +| §1.4 三元组是请求 | ❌ 解析时把「未指定」折叠成了「gnu」 | +| S2 `cfg` 按事实 | ❌ 只能问三元组的字段 | +| S4 声明而非猜 | ⚠️ 图侧声明,载荷侧从包名切 | +| S5 `requires` | ❌ 不存在 | +| S6 全限定名 | ❌ 报告只打 `name` | +| §3.4 不重复自己 | ❌ 全局默认会被目标表的行推翻 | +| §3.5 list 的 SOURCE 列 | ❌ 纯载荷视图 | + +--- +## 8. 使用示例 + +⚠️ 每个示例给出**该受众写的全部内容**。没写出来的,就是没写。 +示例按 S8 的三种受众分组。 + +--- + +### 甲 · 使用者(什么都不需要理解) + +#### 8.1 零配置 + +```toml +[package] +name = "hello" +version = "0.1.0" +``` + +``` +$ mcpp build + Target x86_64-linux-gnu + Compiling hello v0.1.0 (.) + Finished dev [unoptimized + debuginfo] in 0.4s +``` + +⭐ **一行目标,零层。** 五层全部来自编译器载荷,一层都不打印。 + +#### 8.2 交叉到静态 musl —— 清单一个字不加 + +``` +$ mcpp build --target x86_64-linux-musl + Target x86_64-linux-musl → x86_64-unknown-linux-musl +``` + +仍然一行:musl 的 gcc 载荷自洽。使用者只多打了一个 `--target`。 + +#### 8.3 换 C++ 运行时(要 llvm 而不是 gcc) + +```toml +[package] +name = "hello" +version = "0.1.0" + +[dependencies] +llvm-runtime = "0.1" +``` + +``` +$ mcpp toolchain default llvm + Default set to llvm@22.1.8 (was: gcc@16.1.0) + +$ mcpp build --target x86_64-linux-musl + Target x86_64-linux-musl → x86_64-unknown-linux-musl + compiler-runtime compiler-rt (llvm-runtime@0.1.0, graph) + c-abi musl (xim:musl@1.2.5, prebuilt) + c++-abi libc++ (llvm-runtime@0.1.0, graph) +``` + +使用者写的**全部**内容:一条依赖 + 一条命令。 + +- 没有 `[target.…]` 段,没有任何层名,没有任何 `xim:` 地址; +- 包名用短名,与 `[dependencies]` 里的写法一致(S6); +- `c-abi` 那行的地址来自目标表,是引擎的默认; +- `compiler` 与 `kernel-abi` 来自载荷,不占行。 + +⚠️ 全局默认设过一次就够,目标表的行**不得**推翻它(§3.5)。 + +#### 8.4 openkal 全栈,交叉到 Windows + +```toml +[dependencies] +openkal-llvm-runtime = "0.1" +``` + +``` +$ mcpp build --target x86_64-windows + Target x86_64-windows → x86_64-w64-windows-gnu + kernel-abi openkal (openkal-windows@0.1.3, graph) + compiler-runtime compiler-rt (openkal-llvm-runtime@0.1.1, graph) + c-abi musl (openkal-musl@0.3.3, graph) + c++-abi libc++ (openkal-llvm-runtime@0.1.1, graph) +``` + +⭐ **`--target x86_64-windows` 没有 env 段,而这是正确写法** —— C 库由图决定, +使用者不该在三元组里对它作断言。写 `x86_64-windows-gnu` 会被拒绝(§8.13b)。 + +⭐ 一条依赖换掉了四层里的三层,而使用者不需要知道这件事。 + +#### 8.5 裸机:要 C 库和不要 C 库,都是「有没有那条依赖」 + +```toml +[dependencies] +riscv-virt-rt = "0.4" # 板级支持包,它自己依赖 picolibc(S10) +``` + +``` +$ mcpp build --target riscv64-none-elf + Target riscv64-none-elf + kernel-abi — + c-abi picolibc (picolibc@1.8.12, graph) +``` + +⚠️ `kernel-abi —` 是**陈述而不是缺口**:裸机没有内核。同一个目标在 openkal +的实现进入图时这一行会变成 `openkal` —— 这正是一份源码能同时到达裸机与宿主的原因。 + +不要任何 C 库 —— **不写就是不要**,没有特殊拼写: + +```toml +[dependencies] +openarch = "0.7" # 只要机器机制,不引任何 C 库 +``` + +``` + Target riscv64-none-elf + kernel-abi — + c-abi — +``` + +⭐ 使用者不需要知道「零 libc 档」这个概念存在(S10)。 + +--- + +### 乙 · 普通库作者(用他一直在用的轴) + +#### 8.6 一个纯算法库:零个 `cfg` + +```toml +[package] +namespace = "acme" +name = "json" +version = "1.0.0" +``` + +#### 8.7 一个有平台后端的库:传统的 os 轴,不需要学任何新东西 + +```toml +[package] +namespace = "acme" +name = "netkit" +version = "1.0.0" + +[target.'cfg(os = "linux")'.build] +sources = ["src/backend_epoll.cpp"] + +[target.windows.build] +sources = ["src/backend_iocp.cpp"] +``` + +⭐ **这里没有一个层名。** 一个普通库作者永远不需要知道 mcpp 有五层。 + +⚠️ 只有当他**刻意要为某个内核接口写后端**时,才会进入丙类的词汇 —— +那一刻他就不是在写普通库,而是在参与这套生态(S8 规则三)。 + +--- + +### 丙 · 运行时 / C 库 / 平台包作者(需要理解五层) + +#### 8.8 平台实现者:供给 `kernel-abi` + +```toml +[package] +namespace = "acme" +name = "openkal-freertos" +version = "0.1.0" +description = "An implementation of openkal on FreeRTOS." + +provides = ["mcpp:kernel-abi=openkal"] + +[dependencies] +openkal = "0.6" # 规范包,声明接口 + +[build] +sources = ["src/**/*.cpp"] +``` + +**引擎零改动,索引零新增,不需要任何人 review mcpp 的代码。** +使用者加一条依赖就到达了一个 mcpp 从未听说过的平台。 + +#### 8.9 运行时实现者:供给两层,并支持三种 C 库 + +```toml +[package] +namespace = "acme" +name = "llvm-runtime" +version = "0.1.0" + +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] + +[build] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc", "-nostdinc++"] +sources = [ + "llvm/compiler-rt/lib/builtins/**/*.c", + "llvm/libunwind/src/**/*.cpp", + "llvm/libcxxabi/src/**/*.cpp", + "llvm/libcxx/src/**/*.cpp", +] +include_dirs = ["llvm/libcxx/include", "llvm/libcxxabi/include", "llvm/libunwind/include"] + +# ⭐ 一份源码,三种 C 库 —— 去问已解析的目标侧,不要求使用者说第二遍(S9) +[target.'cfg(c-abi = "musl")'.build] +include_dirs = ["config/musl"] +std-module-flags = ["-D_GNU_SOURCE"] # musl 的 locale 层需要 + +[target.'cfg(c-abi = "glibc")'.build] +include_dirs = ["config/glibc"] + +[target.'cfg(c-abi = "picolibc")'.build] +include_dirs = ["config/picolibc"] +cxxflags = ["-D_LIBCPP_HAS_NO_THREADS"] +std-module-flags = ["-D_LIBCPP_HAS_NO_THREADS"] # ⭐ 白得的条件化(S5) +``` + +三处设计落点: + +- **两个 `provides`**,因为它确实供给两层 —— builtins 是 C 程序也要的东西; +- **`requires`** 让「用 gcc 编它」在编译开始之前被拒绝,而不是在 libc++ 的 + 头文件深处失败; +- **std 模块的三个键在 `[build]` 里**,与 `sources` / `include_dirs` 并列 —— + 它们是同一类东西,而且因此可以按目标侧条件化(下面三个 cfg 段之一)。 + +#### 8.10 板级支持包:说出这块板子要哪个 C 库 + +```toml +[package] +namespace = "acme" +name = "riscv-virt-rt" +version = "0.4.0" + +provides = ["mcpp:kernel-abi=openkal"] # 这块板子的 openkal 后端 + +[dependencies] +openkal = "0.6" +picolibc = "1.8" # ⭐ 板子的 C 库,一行,包自己说 + +[build] +sources = ["src/**/*.cpp", "src/start.S"] +``` + +⭐ **新板子不需要 mcpp 发版。** 板子的 C 库是板子的性质,写在描述板子的那个包里。 + +#### 8.11 把一份现成载荷包装成能力供给者 + +```toml +[package] +namespace = "acme" +name = "picolibc" +version = "1.8.12" + +provides = ["mcpp:c-abi=picolibc"] + +[xlings] +deps = ["xim:picolibc-riscv@1.8.12"] + +[build] +sources = [] # 不编译任何东西 +include_dirs = ["${xim:picolibc-riscv}/include"] +ldflags = ["-L${xim:picolibc-riscv}/lib", "-lc"] +``` + +⚠️ `${xim:<名>}` 是 S10 提案的新机制,今天不存在 —— 这个示例描述的是目标状态。 + +#### 8.12 C 库实现者:供给 `c-abi` + +```toml +[package] +namespace = "acme" +name = "tinylibc" +version = "0.2.0" + +provides = ["mcpp:c-abi=tinylibc"] + +[dependencies] +openkal = "0.6" # 坐在 openkal 之上,而不是直接发系统调用 + +[build] +sources = ["src/**/*.c"] +``` + +⚠️ 使用者若同时引入了另一个供给 `c-abi` 的包,构建在解析期就会失败并指出双方 +(规则一 / S3)—— 而不是链接成功、偶尔崩。 + +--- + +### 8.13 四种出错,以及它们各自的下一步 + +**(a) 编译器与运行时不匹配** + +``` +$ mcpp build # 默认 gcc,依赖含 llvm-runtime +error: this C++ runtime requires an llvm-family compiler. + c++-abi libc++ (llvm-runtime@0.1.0, graph) + compiler gcc@16.1.0 (mcpp's default) + Yours outranks mcpp's default: + mcpp toolchain default llvm@22.1.8 +``` + +**(b) 三元组的请求与事实矛盾** + +``` +$ mcpp build --target x86_64-windows-gnu # 依赖含 openkal +error: this build requests the `gnu` C ABI, and its graph supplies `musl`. + requested gnu (from --target x86_64-windows-gnu) + resolved musl (openkal-musl@0.3.3, graph) + Write `--target x86_64-windows` to let the graph decide. +``` + +**(c) 两个包供给同一层** + +``` +error: two packages supply the C ABI, and it is a choice rather than a contribution. + mcpplibs/openkal-musl@0.3.3 (via openkal-llvm-runtime) + acme/tinylibc@0.2.0 (a direct dependency) + A build has exactly one C library. +``` + +⚠️ 这一条**打全限定名**,因为两个短名并列时短名不再是标识符(S6 第二种情形)。 + +**(d) 没有人供给某一层** + +``` +$ mcpp build --target x86_64-linux-musl # toolchain llvm,没有相应依赖 +error: nothing supplies this target's C library. + compiler llvm@22.1.8 (payload) + c-abi — + The llvm payload carries no C library for this target, and no dependency + provides `mcpp:c-abi`. Depend on a package that implements it, or name a + prebuilt one: + [target.x86_64-linux-musl] + sysroot = "xim:musl@1.2.5" +``` + +⚠️ (d) 打印了一行 `(payload)`,而 §3.3 说平时不显示载荷层。 +**诊断是例外,且这是明写的规则**:一条错误必须打印它所依据的全部层, +否则读者看不到判断的依据。 + +--- + +### 8.14 一张表:每类人一共要学几个概念 + +| 受众 | 概念 | 形式 | +|---|---|---| +| 使用者 | 什么都不学 | `mcpp build` | +| 使用者 | 目标三元组 | `--target x86_64-linux-musl` | +| 使用者 | 工具链 | `mcpp toolchain default llvm` | +| 使用者 | 依赖 | `[dependencies]` 一行 | +| 使用者(逃生口) | 按目标换编译器 | `[target.X].toolchain` | +| 使用者(逃生口) | 点名预制件(S10 后退休) | `[target.X].sysroot` | +| 普通库作者 | 传统平台轴 | `cfg(os / arch / family / env)` | +| 运行时/ABI 作者 | 供给与需求 | `provides` / `requires` | +| 运行时/ABI 作者 | 供给的细节 | `[build]` 里的 `std-module*`(与 sources 并列) | +| 运行时/ABI 作者 | 按目标侧适配 | `cfg(kernel-abi / c-abi / c++-abi)` | + +⭐ **前六行是使用者的全部,其中后两行是逃生口。五个层名一次都没出现。** +⭐ **第七行是普通库作者的全部** —— 与今天完全一样,没有新东西要学。 +⭐ **只有最后三行需要理解五层**,而写这类包的人本来就在做这件事。 diff --git a/.agents/docs/2026-08-24-target-side-design.md b/.agents/docs/2026-08-24-target-side-design.md new file mode 100644 index 00000000..99767fdf --- /dev/null +++ b/.agents/docs/2026-08-24-target-side-design.md @@ -0,0 +1,598 @@ +# mcpp 目标侧设计 + +2026-08-24 · 设计定稿 + +**读者**:mcpp 贡献者、索引作者、运行时/平台包作者。 +**推导过程**(十余条实测、缺陷考古、落地顺序)在 +`2026-08-24-graph-target-side-optimization-plan.md`;本文只讲设计。 + +--- + +## 0. 一句话 + +> **一次构建的目标侧由五个层构成;每层恰好一个供给者;供给者可以是编译器载荷、 +> 预制载荷或依赖图;引擎知道有哪五层,永远不知道有哪些实现。** + +--- + +## 1. 架构 + +### 1.1 五层 + +| 层 | 是什么 | 实现举例 | +|---|---|---| +| `compiler` | 谁在编译 | `llvm` / `gcc` / `msvc` | +| `compiler-runtime` | 编译器自己的运行时:builtins、展开器 | `compiler-rt`+`libunwind` / `libgcc` | +| `kernel-abi` | 平台接口:系统调用或它的等价物 | `linux` / `windows` / `darwin` / `openkal` / 无 | +| `c-abi` | C 库 | `glibc` / `musl` / `picolibc` / 无 | +| `c++-abi` | C++ 库与它的 ABI 运行时 | `libc++`+`libc++abi` / `libstdc++` / MSVC STL / 无 | + +**是层的三个条件**,少一个就不该是层:野外至少有两个可互换的实现; +可以独立于其它层被替换;对下一层有明确的「被谁配置过」关系。 + +⚠️ **`compiler-runtime` 独立于 `c++-abi`。** builtins(`__udivti3` 一类) +是一个**纯 C 程序**就需要的东西。把它算进 C++ 运行时,等于说 C 程序不需要整数除法。 + +⚠️ **`kernel-abi` 在传统栈上没有名字。** 一个 C 库直接发系统调用,那道缝没被命名。 +命名它,才使得一份 C 库源码能坐在四个平台上。 + +### 1.2 四来源 + +| 来源 | 含义 | 何时可知 | +|---|---|---| +| `Payload` | 编译器载荷自带 | 依赖解析**之前** | +| `Xpkg` | 一份被点名的预制载荷 | 依赖解析**之前** | +| `Graph` | 依赖图里的包 | 依赖解析**之后** | +| `None` | 没有,且这是一个陈述 | — | + +⚠️ **`None` 是答案不是缺口。** 裸机没有内核;不依赖 C 库就是没有 C 库。 + +⭐ **两个来源在图之前可知、两个在图之后才可知 ⇒ 目标侧只能在依赖解析之后 +解析一次。** 在那之前的任何推断,都是对一个尚不存在的事实作推断; +多处推断必然互相矛盾。 + +### 1.3 四规则 + +**规则一 · 每层恰好一个供给者。** +C 库、内核接口、C++ 运行时是**互斥的选择**,不是可叠加的贡献。 +两个供给者在解析期报错并指出双方。 +⚠️ 失败模态是这条规则的理由:选错不会链接失败,会得到一个能跑、偶尔崩的程序。 + +**规则二 · 每层必须为它下面那层配置过。** +一份 libc++ 的 `__config_site` 记录了它对着哪个 C 库配置;`libgcc` 是为 gcc 配置的。 +**「为谁配置过」是可声明的事实,不是可推断的。** + +推论:载荷的 C++ 运行时只在 C 库也来自同一载荷时可用;`compiler-runtime` +必须与 `compiler` 同族;声明了 `requires` 的包遇到不匹配的编译器必须被拒绝。 + +**规则三 · 引擎只在跨来源时接线。** + +| 组合 | 谁表达两层的关系 | 引擎 | +|---|---|---| +| 两层都在 `Graph` | 包之间的普通依赖 | 不介入 | +| 两层都在 `Payload` | 载荷自洽 | 不介入 | +| 一层预制、一层在图 | 只有引擎同时知道两边的地址 | 必须接线 | + +⇒ **把一层从预制挪进图,引擎要做的事就少一件。** 这是「一份源码到达四个平台 +却不需要改引擎」的机制原因。 + +**规则四 · 引擎硬编码层名,永不硬编码实现名。** +五个层名是编译进引擎的闭集;`openkal` / `musl` / `libc++` / `picolibc` +不出现在引擎任何一行代码里。 + +> 层由 C/C++ 构建模型固定、不增长,所以可以硬编码。 +> 实现不可以,因为增长正是它们要做的事:**生态的组合是 N×M,包数是 N+M。** + +⚠️ 推论一:**目标侧包不走预构建分发** —— 预构建资产数 = 目标 × 编译器版本 × C 库, +回到 N×M 那一侧。冷构建代价实测为「每台机器每(包版本 × 目标)一次」,可接受; +若将来成为问题,答案是**共享构建缓存**而不是预构建包。 + +⚠️ 推论二:**「目标侧从哪来」不得由工具链的名字表达。** +工具链族的命名空间里只能有编译器。 + +### 1.4 三元组是请求,`TargetSide` 是事实 + +`--` 三个字段承载四条正交的轴:机器、平台接口、对象格式+调用 ABI、 +C 库。第四条在图模型下不由名字决定,所以: + +- `env` **空 = 未指定**,由供给方决定; +- `env` **非空 = 一条请求**,与解析出的事实矛盾时**拒绝**,不并排打印; +- 三元组永远不是「C 库是谁」的答案,`TargetSide.cAbi` 才是。 + +⚠️ macOS 今天就是这个形状(三元组没有 env 段),它是这条设计已被验证可行的证据。 + +--- + +## 2. 三个表面 + +⭐⭐ **五层是引擎的词汇,不是使用者的。** 它出现在报告与诊断里,不出现在清单里。 + +> 报告是**诊断输出** —— 可以也应该用精确的内部词汇,那正是它有用的原因。 +> 清单是**配置输入** —— 必须用使用者本来就有的词。 + +| 受众 | 需要理解什么 | 词汇 | +|---|---|---| +| **使用者** | 什么都不需要 | 三元组、`[toolchain]`、`[dependencies]` | +| **普通库作者** | 传统平台轴(与今天一样) | `cfg(os / arch / family / env)`、`sources` | +| **运行时 / C 库 / 平台包作者** | 全部五层 | `provides` / `requires` / `cfg(kernel-abi / c-abi / c++-abi)` | + +三条边界: + +1. 层名不出现在**使用者**的清单里 —— 三元组和依赖表达一切; +2. 层名不出现在**普通库作者**的清单里 —— 他写 `cfg(windows)`,不需要学新东西; +3. 层名只属于**第三类作者**。一个库只有在刻意为某个内核接口写后端时才进入 + 这套词汇 —— 那一刻它已经是这套生态的参与者。 + +⚠️ 判断新语法放哪一层,问:**最不懂行的那类受众会不会被迫看到它?** 会,就放错了。 + +--- + +## 3. 语义 + +### 语义一 · 每一层都是一条依赖 + +**使用者换掉任何一层的方式,永远是加一条依赖。** 没有第二种。 + +```toml +[dependencies] +openkal-llvm-runtime = "0.1" # 一条依赖换掉三层 +``` + +推论:**没有 `c-abi = …` 这类键。** `x86_64-linux-musl` 已经说了 musl, +再写一遍是同一句话说两遍。 + +推论:**零 libc 档不需要拼写。** 不依赖 = 没有。 +使用者不需要知道「零 libc 档」这个概念存在。 + +推论:**一块板子要哪个 C 库,写在描述这块板子的那个包里。** + +```toml +# acme/riscv-virt-rt +[dependencies] +openkal = "0.6" +picolibc = "1.8" # ⭐ 板子的性质,包自己说;新板子不需要 mcpp 发版 +``` + +### 语义二 · 能力由声明得来,永不由名字猜 + +供给者**声明**它供给哪一层、接口叫什么、那一层的版本是多少。 +图里的包与预制载荷用同一套语义: + +```toml +# 依赖图里的包 +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] +``` + +```lua +-- 预制载荷的描述符:布局描述而非构建输入,所以形状不同(它不被构建) +provides = { "mcpp:c-abi=musl" } +layer = { ["c-abi"] = { path = "${arch}-linux-musl", version = "1.2.5" } } +``` + +三个后果:接口名不再从包名切出来(载荷叫什么是打包的事);层的版本不再是 +载体的版本;**载荷里哪一部分属于哪一层是被声明的** ⇒ 消费一层不会连带消费另一层。 + +### 语义三 · 供给的细节属于 `[build]` + +`std-module` 是一个 `.cppm`,`std-module-flags` 是一组 flag —— +与 `sources` / `include_dirs` 同类,`[build]` 就是它们的家。 + +```toml +[build] +sources = ["llvm/libcxx/src/**/*.cpp", "…"] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc", "-nostdinc++"] +``` + +⭐ 放进 `[build]` 白得条件化能力: + +```toml +[target.'cfg(c-abi = "musl")'.build] +std-module-flags = ["-D_GNU_SOURCE"] # musl 的 locale 层需要 +``` + +⚠️ 校验:**声明了 `std-module` 却没有对应的 `provides` 条目,是错误,不是被忽略。** + +### 语义四 · 包适配已解析的目标侧,而不是被告知 + +一份 libc++ 源码可以配 musl、glibc、picolibc。**它不被告诉是哪一种,它去问。** + +```toml +[target.'cfg(c-abi = "musl")'.build] +include_dirs = ["config/musl"] + +[target.'cfg(c-abi = "picolibc")'.build] +include_dirs = ["config/picolibc"] +``` + +⇒ 使用者一个字都不用多写。要求一个 `features = ["musl"]`, +就是让他把已经说过的话再说一遍,而且两处可能不一致。 + +⚠️ **限制**:`cfg(*-abi = …)` 只能用于 `[build]`,不能用于 `[dependencies]` +(目标侧在依赖解析之后才有,用它决定依赖会成环)。 +「不同 C 库要不同**依赖**」的出路是拆包,或依赖并集在 `[build]` 里选源码。 +⚠️ 这条限制是**平的** —— 不因为 `c-abi` 恰好来自三元组就放宽: +一个清单不该「有时能写有时不能」。 + +**真正的 feature 仍然是 feature。** 判据: +**能从目标侧推出来的不做 feature;推不出来的才是 feature。** + +### 语义五 · 报告用使用者自己的拼写 + +使用者在 `[dependencies]` 里写短名,命名空间由索引解析、`mcpp.lock` 记录。 +报告引入他不使用的拼写,是在制造第二套词汇。 + +``` +c++-abi libc++ (openkal-llvm-runtime@0.1.1, graph) +``` + +全限定名只出现在三处:`-v`;**同一次构建里两个短名相同时**(此时短名不再是标识符); +诊断信息里。 + +--- + +## 4. 使用侧 + +### 4.1 报告:按需暴露 + +**默认只显示不来自编译器载荷的层。** 全部来自载荷 ⇒ 只有目标那一行。 + +``` + Target x86_64-linux-gnu ← 零配置:一行 +``` + +``` + Target x86_64-windows → x86_64-w64-windows-gnu + kernel-abi openkal (openkal-windows@0.1.3, graph) + compiler-runtime compiler-rt (openkal-llvm-runtime@0.1.1, graph) + c-abi musl (openkal-musl@0.3.3, graph) + c++-abi libc++ (openkal-llvm-runtime@0.1.1, graph) +``` + +`-v` 显示全部五层。⚠️ **诊断是例外**:一条错误必须打印它所依据的全部层, +包括来自载荷的,否则读者看不到判断的依据。 + +约束:打**解析结果**不打清单意图(清单会过期,结果不会); +**接口与实现是两列**(`openkal` 是接口,`openkal-windows` 是实现)。 + +### 4.2 诊断:说决定,不说后果 + +⚠️ **一条编译器或链接器的原话,几乎总是一次诊断缺失。** + +### 4.3 不必重复自己 + +> **mcpp 修订它自己的默认,永不修订你的。** + +用户用明确命令设下的全局默认,不得被目标表的行推翻。真覆盖了,状态行要说出来。 + +### 4.4 逃生口 + +`[target.].toolchain` —— 只给某个目标换编译器。**这是唯一一个。** + +判据:**一个键能进使用者表面,当且仅当它表达的东西三元组、工具链、依赖 +三样都说不出来。** + +--- + +## 5. 使用示例 + +### 甲 · 使用者(什么都不需要理解) + +**5.1 零配置** + +```toml +[package] +name = "hello" +version = "0.1.0" +``` +``` +$ mcpp build + Target x86_64-linux-gnu + Finished dev [unoptimized + debuginfo] in 0.4s +``` + +**5.2 交叉 —— 清单一个字不加** + +``` +$ mcpp build --target x86_64-linux-musl + Target x86_64-linux-musl → x86_64-unknown-linux-musl +``` + +**5.3 换 C++ 运行时(要 llvm 而不是 gcc)** + +```toml +[dependencies] +llvm-runtime = "0.1" +``` +``` +$ mcpp toolchain default llvm +$ mcpp build --target x86_64-linux-musl + Target x86_64-linux-musl → x86_64-unknown-linux-musl + compiler-runtime compiler-rt (llvm-runtime@0.1.0, graph) + c-abi musl (musl@1.2.5, graph) + c++-abi libc++ (llvm-runtime@0.1.0, graph) +``` + +写的**全部**内容:一条依赖 + 一条命令。没有 `[target.…]`,没有层名,没有地址。 + +**5.4 openkal 全栈交叉到 Windows** + +```toml +[dependencies] +openkal-llvm-runtime = "0.1" +``` +``` +$ mcpp build --target x86_64-windows +``` + +⭐ `--target x86_64-windows` **没有 env 段,这是正确写法** —— C 库由图决定, +使用者不该在三元组里对它作断言。写 `x86_64-windows-gnu` 会被拒绝(§5.12b)。 + +**5.5 裸机:要与不要 C 库,都只是「有没有那条依赖」** + +```toml +[dependencies] +riscv-virt-rt = "0.4" # 板级支持包,它自己依赖 picolibc +``` +``` + Target riscv64-none-elf + kernel-abi — + c-abi picolibc (picolibc@1.8.12, graph) +``` + +不要任何 C 库 —— **不写就是不要**: + +```toml +[dependencies] +openarch = "0.7" # 只要机器机制 +``` +``` + kernel-abi — + c-abi — +``` + +### 乙 · 普通库作者(用他一直在用的轴) + +**5.6 纯算法库:零个 `cfg`** + +```toml +[package] +namespace = "acme" +name = "json" +version = "1.0.0" +``` + +**5.7 有平台后端的库:传统 os 轴** + +```toml +[target.'cfg(os = "linux")'.build] +sources = ["src/backend_epoll.cpp"] + +[target.windows.build] +sources = ["src/backend_iocp.cpp"] +``` + +⭐ **一个层名都没有。** 普通库作者永远不需要知道 mcpp 有五层。 + +### 丙 · 运行时 / C 库 / 平台包作者(需要理解五层) + +**5.8 平台实现者:供给 `kernel-abi`** + +```toml +[package] +namespace = "acme" +name = "openkal-freertos" +version = "0.1.0" + +provides = ["mcpp:kernel-abi=openkal"] + +[dependencies] +openkal = "0.6" # 规范包,声明接口 + +[build] +sources = ["src/**/*.cpp"] +``` + +**引擎零改动,索引零新增。** 使用者加一条依赖就到达了一个 mcpp 从未听说过的平台。 + +**5.9 运行时实现者:供给两层,支持三种 C 库** + +```toml +[package] +namespace = "acme" +name = "llvm-runtime" +version = "0.1.0" + +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] + +[build] +sources = [ + "llvm/compiler-rt/lib/builtins/**/*.c", + "llvm/libunwind/src/**/*.cpp", + "llvm/libcxxabi/src/**/*.cpp", + "llvm/libcxx/src/**/*.cpp", +] +include_dirs = ["llvm/libcxx/include", "…"] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc", "-nostdinc++"] + +[target.'cfg(c-abi = "musl")'.build] +include_dirs = ["config/musl"] +std-module-flags = ["-D_GNU_SOURCE"] + +[target.'cfg(c-abi = "picolibc")'.build] +include_dirs = ["config/picolibc"] +std-module-flags = ["-D_LIBCPP_HAS_NO_THREADS"] +``` + +三处落点:**两个 `provides`**(它确实供给两层);**`requires`** 让「用 gcc 编它」 +在编译开始前被拒;**std 模块的键在 `[build]` 里**,因此可以按 C 库条件化。 + +**5.10 板级支持包:说出这块板子要哪个 C 库** + +```toml +[package] +namespace = "acme" +name = "riscv-virt-rt" +version = "0.4.0" + +provides = ["mcpp:kernel-abi=openkal"] + +[dependencies] +openkal = "0.6" +picolibc = "1.8" # ⭐ 板子的性质,一行 + +[build] +sources = ["src/**/*.cpp", "src/start.S"] +``` + +**5.11 把一份现成载荷包装成能力供给者** + +```toml +[package] +namespace = "acme" +name = "picolibc" +version = "1.8.12" + +provides = ["mcpp:c-abi=picolibc"] + +[xlings] +deps = ["xim:picolibc-riscv@1.8.12"] + +[build] +sources = [] # 不编译任何东西 +include_dirs = ["${xim:picolibc-riscv}/include"] +ldflags = ["-L${xim:picolibc-riscv}/lib", "-lc"] +``` + +⚠️ `${xim:<名>}` 是本设计新增的机制(§6),今天不存在。 +名字取 `[xlings] deps` 里的**包名**;同一份清单不可能列出同名包的两个版本, +所以无歧义。 + +### 5.12 四种出错 + +**(a) 编译器与运行时不匹配** +``` +error: this C++ runtime requires an llvm-family compiler. + c++-abi libc++ (llvm-runtime@0.1.0, graph) + compiler gcc@16.1.0 (mcpp's default) + Yours outranks mcpp's default: + mcpp toolchain default llvm@22.1.8 +``` + +**(b) 三元组的请求与事实矛盾** +``` +error: this build requests the `gnu` C ABI, and its graph supplies `musl`. + requested gnu (from --target x86_64-windows-gnu) + resolved musl (openkal-musl@0.3.3, graph) + Write `--target x86_64-windows` to let the graph decide. +``` + +**(c) 两个包供给同一层** +``` +error: two packages supply the C ABI, and it is a choice rather than a contribution. + mcpplibs/openkal-musl@0.3.3 (via openkal-llvm-runtime) + acme/tinylibc@0.2.0 (a direct dependency) +``` +⚠️ 这一条打全限定名 —— 两个短名并列时短名不再是标识符(语义五)。 + +**(d) 没有人供给某一层** +``` +error: nothing supplies this target's C library. + compiler llvm@22.1.8 (payload) + c-abi — + Depend on a package that provides `mcpp:c-abi`. +``` +⚠️ (d) 打印了一行 `(payload)` —— 诊断必须展示判断依据(§4.1)。 + +### 5.13 每类人要学几个概念 + +| 受众 | 概念 | 形式 | +|---|---|---| +| 使用者 | 什么都不学 | `mcpp build` | +| 使用者 | 目标三元组 | `--target x86_64-linux-musl` | +| 使用者 | 工具链 | `mcpp toolchain default llvm` | +| 使用者 | 依赖 | `[dependencies]` 一行 | +| 使用者(逃生口) | 按目标换编译器 | `[target.X].toolchain` | +| 普通库作者 | 传统平台轴 | `cfg(os / arch / family / env)` | +| 运行时/ABI 作者 | 供给与需求 | `provides` / `requires` | +| 运行时/ABI 作者 | 按目标侧适配 | `cfg(kernel-abi / c-abi / c++-abi)` | + +⭐ 使用者:**4 个日常概念 + 1 个逃生口,零个层名**。 +⭐ 普通库作者:**1 个,与今天完全一样**。 +⭐ 只有最后两行需要理解五层,而写这类包的人本来就在做这件事。 + +--- + +## 6. 本设计新增的机制 + +| # | 机制 | 用途 | +|---|---|---| +| M1 | `mcpp:compiler` / `mcpp:compiler-runtime` 两个层名 | 五层完整 | +| M2 | `requires = ["mcpp:<层>=<实现>"]` | 规则二在包侧的表达 | +| M3 | 载荷描述符的 `provides` + `layer.<名>.{path,version}` | 语义二在预制侧 | +| M4 | `cfg(kernel-abi / c-abi / c++-abi = …)`,仅限 `[build]` | 语义四 | +| M5 | `${xim:<名>}` 路径替换 | 包能引用一份载荷 ⇒ 语义一的板级包形态 | +| M6 | `std-module*` 从 `[package]` 移入 `[build]` | 语义三 | + +⚠️ M5 有一个前置缺陷要一起修:**依赖包的 `[xlings] deps` 今天一个都不装** +(只有根工程的会装)。判据必须是「把它拿走再装回来」,不能只看构建是否绿。 + +--- + +## 7. 明确不做 + +| 不做 | 理由 | +|---|---| +| 开放 `os` / `env` token 表 | 把语义责任推给包作者;引擎有十余处行为要问这个字段 | +| 开放编译器族 | 族的差异是引擎必须知道的,不是数据能描述的 | +| 命名空间准入门槛 | 把瓶颈换个地方;违反规则四;挡不住真风险(不需要 `provides` 也能破坏构建) | +| 目标侧包预构建分发 | 资产数回到 N×M 那一侧;冷构建成本实测可接受 | +| 跨族借用编译器运行时 | 让同一次构建里的链接互相不一致;规则二的直接违反 | +| 为报告里的信息新增清单字段 | 清单陈述意图会过期,报告陈述结果不会 | +| `c-abi` / `compiler` 一类清单键 | 三元组与依赖已经说得出;层名不进使用者表面 | +| 发明新的三元组语法 | 三元组要与 LLVM/GNU 写法互认;要改的是「空」的含义,不是语法 | + +--- + +## 8. 判据 + +不是「测试通过」,是这五条同时成立: + +1. **一份新平台的实现,发一个包就能被使用** —— 引擎零改动、索引零改动; +2. **报告里每一行都能被某个声明解释**,没有一行是猜出来的; +3. **每个「编译器原话」的失败,都有一条更早的 mcpp 诊断**; +4. **零配置工程的清单里没有任何一行提到目标侧**; +5. **同一个事实在代码里只有一处推导** —— 新增消费者时去问那一处, + 而不是新增第 N 条并行判据。 + +⚠️ 第 5 条最容易退化也最贵:目标侧的判据曾在三处各推一遍并互相矛盾, +收敛它们的代价远大于当初写对的代价。 + +--- + +## 9. 与当前实现的差距 + +| 设计条款 | 现状 | +|---|---| +| §1.1 五层 | 三层;`compiler` 与 `compiler-runtime` 未建模 | +| §1.2 四来源 | ✅ 已有 | +| §1.3 规则一 | ❌ 冲突时图遍历顺序第一个静默胜出 | +| §1.3 规则二 | ⚠️ 半条 | +| §1.3 规则三 | ⚠️ 图×图、载荷×载荷成立;跨来源未接线 | +| §1.3 规则四 | ✅ 层名侧成立;⚠️ 工具链族里混入了一个目标侧策略 | +| §1.4 三元组是请求 | ❌ 解析时把「未指定」折叠成了 `gnu` | +| §2 三个表面 | ⚠️ 报告与清单未分层;`sysroot` 让层泄漏到使用者面 | +| 语义一 C 库是依赖 | ❌ 由引擎表的列供给 | +| 语义二 声明而非猜 | ⚠️ 图侧声明,载荷侧从包名切 | +| 语义三 `[build]` | ❌ 在 `[package]` 下,且不可条件化 | +| 语义四 包去问 | ❌ `cfg` 只能问三元组的字段 | +| 语义五 报告拼写 | ✅ 已是短名 | +| §4.3 不重复自己 | ❌ 全局默认会被目标表的行推翻 | +| M5 `${xim:<名>}` | ❌ 不存在 | + +落地顺序见 `2026-08-24-graph-target-side-optimization-plan.md` §14.5 / §16.7。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 89df3b9a..c4997c7c 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1101,6 +1101,36 @@ blas = "compat.openblas" # equivalently: mcpp build --cap blas=compat.openbl compat.openblas = "0.3.0" # the provider must be a real dependency in the graph ``` +The reserved prefix `mcpp:` names the target-side layers this engine resolves, +and those names are validated against a closed set. A package-level `requires` +array carries the symmetric statement — what a target-side layer must resolve to +for this package to be usable. + +```toml +[package] +name = "acme.llvm-runtime" +version = "0.1.0" +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] +``` + +A package that is a standard library states its `std` module source under +`[build]`, where the flags it needs become conditional like any other build +input. + +```toml +[build] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc++"] + +[target.'cfg(c-abi = "musl")'.build] +std-module-flags = ["-D_GNU_SOURCE"] +``` + +See [14 - The Target Side](14-target-side.md) for the five layers, the rules +that govern them, and the diagnostics. + Binding is **deterministic**: | Providers of a required capability in the graph | Result | diff --git a/docs/14-target-side.md b/docs/14-target-side.md new file mode 100644 index 00000000..d0014de5 --- /dev/null +++ b/docs/14-target-side.md @@ -0,0 +1,293 @@ +# The Target Side + +A build must answer one question before it can emit a command line: where the +target's compiler runtime, platform interface, C library and C++ runtime come +from. mcpp resolves that question once, after the dependency graph is known, +and every later stage reads the single resolved value. + +This document specifies the model, the rules that govern it, what a project +writes, and what a package declares. + +## The Five Layers + +The target side of a build consists of five layers. + +| Layer | Content | Implementations | +|---|---|---| +| `compiler` | the program that compiles | `llvm`, `gcc`, `msvc` | +| `compiler-runtime` | the compiler's own runtime: integer and floating-point builtins, the unwinder | `compiler-rt` with `libunwind`, `libgcc` | +| `kernel-abi` | the platform interface, or its equivalent | `linux`, `windows`, `darwin`, `openkal` | +| `c-abi` | the C library | `glibc`, `musl`, `picolibc` | +| `c++-abi` | the C++ library and its ABI runtime | `libc++` with `libc++abi`, `libstdc++`, MSVC STL | + +### Membership Criteria + +A component is a layer when three conditions hold simultaneously: at least two +interchangeable implementations exist; it can be replaced independently of its +neighbours; and it stands in a definite "was configured for" relation to the +layer beneath it. A component failing any one of the three belongs to an +adjacent layer rather than to one of its own. + +`compiler-runtime` is separate from `c++-abi` because the builtins are what a +C program requires. Treating them as part of the C++ runtime asserts that a C +program needs no integer division, and that assertion has already produced a +measured defect: a C program cross-compiled to macOS was asked whether a C++ +runtime was present, answered that none was, and the link line consequently +retained the compiler payload's own `libc++`. + +`kernel-abi` is unnamed on a conventional stack, where a C library issues +system calls or invokes platform entry points directly. Naming the seam is what +permits one C library implementation to sit above several platforms. + +## The Four Origins + +Each layer is supplied from one of four origins. + +| Origin | Meaning | Known | +|---|---|---| +| `payload` | the compiler payload carries it | before dependency resolution | +| `prebuilt` | a named prebuilt payload supplies it | before dependency resolution | +| `graph` | a package in the dependency graph supplies it | after dependency resolution | +| `—` | nothing supplies it, and that is a statement | — | + +Two origins are knowable before dependency resolution and two only after it. +The target side is therefore resolved exactly once, at the point where the +graph exists. Any earlier derivation is an inference about a fact that does not +yet exist, and independent inferences about such a fact do not agree. + +An absent layer is an answer rather than a gap. A bare-metal target has no +kernel; a project that depends on no C library has no C library. + +## The Rules + +### One Supplier Per Layer + +A C library, a platform interface and a C++ runtime are mutually exclusive +choices rather than additive contributions. Two suppliers for one layer is an +error, reported during resolution and naming both packages together with how +each entered the graph. + +The failure mode motivates the strictness: selecting the wrong supplier does +not fail the link. It produces a program that runs and intermittently does not. + +### Configured For The Layer Beneath + +An implementation is usable only above the layer it was configured for. A +`libc++` build records that configuration in its `__config_site`; a `libgcc` +build is configured for GCC. The relation is declared rather than inferred — +see [`requires`](#requires) below. + +Two consequences follow. The compiler payload's C++ runtime is eligible only +when the C library is also the payload's. A compiler runtime must belong to the +compiler's own family, since a build in which the two disagree resolves +`__udivti3` differently from every other link in the same program. + +### Cross-Origin Wiring + +The engine wires two layers together only when they come from different +origins. + +| Combination | Relation expressed by | Engine | +|---|---|---| +| both from `graph` | ordinary dependencies between packages | no involvement | +| both from `payload` | the payload is internally consistent | no involvement | +| one prebuilt, one from the graph | only the engine knows both addresses | wiring required | + +Moving a layer from a prebuilt payload into the dependency graph therefore +removes engine work rather than adding it. This is the mechanism by which one +source reaches several platforms without an engine change. + +### Layer Names Are Fixed, Implementations Are Not + +The five layer names are a closed set compiled into the engine. The +implementations filling them appear in package manifests and in the index, and +in no line of engine code. + +Layer names may be fixed because the layers are determined by the C and C++ +build model and do not grow. Implementations may not, because growth is +precisely what they do: an ecosystem's combinations are the product of its +implementations while its packages are their sum. + +## What A Project Writes + +Layer names do not appear in a project manifest. A project expresses its target +side through three existing mechanisms. + +### The Target Triple + +`--target `, or `[build] target`. The OS field selects the platform +interface. The environment field states a request for a C library; it is a +request rather than the answer, and the resolved value is reported by the +build. + +### The Toolchain + +`mcpp toolchain default @`, `[toolchain]` in the manifest, or +`[target.].toolchain` for one target. This selects the `compiler` +layer, which is the one layer no package may supply. + +A target row may carry a convention — a toolchain whose payload supplies that +target's C library. The convention applies when the manifest states nothing for +that target. When it replaces a default set with `mcpp toolchain default`, the +status line reports the substitution and names the one-line override. + +### Dependencies + +Every other layer is selected by depending on a package that supplies it. A +single dependency may supply several layers, and may bring further suppliers +through its own dependencies. + +```toml +[dependencies] +openkal-llvm-runtime = "0.1" +``` + +## What The Build Reports + +The build prints what it resolved. A manifest line states an intention that +goes stale when the packages beneath it change; a report states the outcome and +cannot. + +By default the report lists only the layers the compiler payload did not +supply. A zero-configuration build resolves all five from one payload, and five +lines reading `(payload)` answer a question nobody asked. + +``` + Target x86_64-linux-gnu +``` + +``` + Target x86_64-windows-gnu → x86_64-w64-windows-gnu + kernel-abi openkal (openkal-windows@0.1.3, graph) + c-abi musl (openkal-musl@0.3.3, graph) + c++-abi libc++ (openkal-llvm-runtime@0.1.1, graph) +``` + +`MCPP_VERBOSE=1` lists all five. Diagnostics always list every layer the +decision rested on, including the ordinary ones, because an error that omits +its evidence cannot be checked by its reader. + +Interface and implementation are separate columns. `openkal` is an interface +and `openkal-windows` an implementation of it; collapsing the two would conceal +why one source reaches several machines. + +## What A Package Declares + +### provides + +A package supplying a layer declares it in the reserved `mcpp:` namespace. + +```toml +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +``` + +The grammar is `mcpp:[=]`. The layer name is validated +against the closed set; a misspelling is an error rather than a silently +disabled behaviour. Names outside the prefix belong to the feature system and +pass through unexamined. + +`mcpp:compiler` may be required but not provided. A compiler is a payload this +engine installs and drives, and the differences between families — flag +spellings, the module model, the BMI format, the driver configuration file — +are facts the engine must hold rather than data a package can describe. + +### requires + +`requires` is the symmetric half, and the mechanism by which the layering rule +is enforced without an implementation name in the engine. + +```toml +requires = ["mcpp:compiler=llvm"] +``` + +A C++ runtime built from `libc++` sources is compiled, and its module compiled, +by Clang. That fact belongs to the package. The engine checks a relation it can +state generically — the named layer must resolve to the named implementation — +and reports a mismatch by naming both, which a table of families compiled into +the engine could not do for a family it had never heard of. + +The check runs before compilation begins. The combination it rejects otherwise +fails inside the runtime's own headers, in a message naming a file the reader +has never opened and no decision mcpp made. + +### Standard Library Module Sources + +A package that is a standard library states where its `std` module source is +and what that source requires. + +```toml +[build] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc", "-nostdinc++"] +``` + +These keys belong to `[build]` because the module source is one of the +package's translation units: it is compiled with the package's include +directories and definitions. Membership of `[build]` also makes the flags +conditional, which a package supplying one C++ runtime over several C libraries +requires. + +```toml +[target.'cfg(c-abi = "musl")'.build] +std-module-flags = ["-D_GNU_SOURCE"] +``` + +Declaring `std-module` without a matching `provides` entry is an error: the +package describes a library it does not supply. + +The `[package]` spelling of these three keys remains accepted and is not +conditional. + +### Adaptation To The Resolved Target Side + +A package supplying a layer frequently supports several implementations of the +layer beneath it. It queries the resolved target side rather than being told. + +```toml +[target.'cfg(c-abi = "musl")'.build] +include_dirs = ["config/musl"] + +[target.'cfg(c-abi = "picolibc")'.build] +include_dirs = ["config/picolibc"] +``` + +Requiring a feature selection for this would oblige a project to restate what +the target triple or its dependency graph has already established, and permit +the two statements to disagree. + +These predicates are available in `[build]` sections only. The target side is +resolved after dependency resolution, so a dependency selected by one would +form a cycle. A package whose C libraries require different dependencies is +split per C library, or depends on the union and selects sources in `[build]`. + +## Diagnostics + +Four conditions are reported by the engine rather than by a compiler. + +| Condition | Report | +|---|---| +| a required implementation is not what resolved | names both, and the command that selects it | +| two packages supply one layer | names both, and how each entered the graph | +| a layer has no supplier | names the layer, and the capability to depend on | +| the payload's C++ runtime sits above a foreign C library | names both, and the two ways out | + +A message from a compiler or a linker about a target-side combination +indicates a missing diagnostic. The engine knows the combination is untenable +before any command line is emitted. + +## Compatibility + +Three provisions preserve existing manifests and existing builds. + +The capability `hosted-standard-library` continues to denote the C++ layer. A +package carrying both spellings is one supplier, and the entry naming an +interface is the one reported. + +The toolchain family spelling `openkal-llvm` normalises to `llvm`. It named the +same payload and carried a fact about the target side, which the model above +resolves from what packages declare. + +An unknown name inside the reserved prefix is an error; an unknown key elsewhere +in a manifest is ignored. A published package therefore continues to load under +an engine predating a key it carries. diff --git a/docs/README.md b/docs/README.md index a6739ef9..83d07c44 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ - [11 - Machine-Readable Output](11-machine-output.md) - [12 - Distributing a Prebuilt Library](12-binary-distribution.md) - [13 - Bare-Metal and Freestanding Targets](13-baremetal.md) +- [14 - The Target Side](14-target-side.md) ## Specifications diff --git a/docs/spec/README.md b/docs/spec/README.md index 1cc4e8a7..5d392c7d 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -31,6 +31,7 @@ | 编号 | 标题 | 状态 | 最后修改 | 对应实现 | |---|---|---|---|---| | [SPEC-001](package-identity.md) | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | 评审中 v1.1 | 2026-08-03 | mcpp >= 0.0.106 | +| [SPEC-002](target-side.md) | 目标侧模型与能力声明(`mcpp:` 保留命名空间、五层、三条规则) | 评审中 v1.0 | 2026-08-24 | mcpp >= 2026.8.24.2 | ## 文档约定 diff --git a/docs/spec/target-side.md b/docs/spec/target-side.md new file mode 100644 index 00000000..81081609 --- /dev/null +++ b/docs/spec/target-side.md @@ -0,0 +1,177 @@ +# SPEC-002:目标侧模型与能力声明 + +| 项 | 值 | +|---|---| +| 规范编号 | SPEC-002 | +| 标题 | 目标侧模型与能力声明(`mcpp:` 保留命名空间) | +| 状态 | 评审中 v1.0 | +| 最后修改 | 2026-08-24 | +| 对应实现 | mcpp >= 2026.8.24.2 | +| 相关设计文档 | `.agents/docs/2026-08-24-target-side-design.md` | +| 使用文档 | [docs/14 - 目标侧](../zh/14-target-side.md) | + +本规范定义一次构建的目标侧由哪些层构成、每一层可以由谁供给、 +供给者与需求者如何声明,以及引擎据此执行的规则。 + +用语按 RFC 2119:**必须 / 禁止**(强制)、**应当**(强烈建议)、**可以**(可选)。 + +--- + +## 1. 层 + +### 1.1 层的闭集 ✅ 已实现 + +一次构建的目标侧**必须**由且仅由以下五层构成: + +| 层名 | 内容 | +|---|---| +| `compiler` | 执行编译的程序 | +| `compiler-runtime` | 编译器自身的运行时:builtins 与展开器 | +| `kernel-abi` | 平台接口或其等价物 | +| `c-abi` | C 库 | +| `c++-abi` | C++ 库及其 ABI 运行时 | + +层名是编译进引擎的闭集。实现名**禁止**出现在引擎代码中。 + +### 1.2 可供给性 ✅ 已实现 + +除 `compiler` 外的每一层**可以**由包供给。`compiler` **禁止**由包供给: +编译器是引擎安装并驱动的载荷,族与族之间的差异是引擎必须持有的事实。 +包**可以**要求某个 `compiler`。 + +### 1.3 来源 ✅ 已实现 + +每一层的来源**必须**是以下四者之一: + +| 来源 | 报告用词 | 可知时刻 | +|---|---|---| +| 编译器载荷 | `payload` | 依赖解析之前 | +| 被点名的预制载荷 | `prebuilt` | 依赖解析之前 | +| 依赖图中的包 | `graph` | 依赖解析之后 | +| 无 | `—` | — | + +目标侧**必须**在依赖解析完成之后解析,且**必须**只解析一次。 + +--- + +## 2. 声明 + +### 2.1 `provides` 语法 ✅ 已实现 + +``` +mcpp:<层名>[=<实现名>] +``` + +`<层名>` **必须**属于 §1.1 的闭集;不属于时引擎**必须**报错。 +不以 `mcpp:` 开头的条目属于特性系统,引擎**必须**原样透传。 + +省略 `=<实现名>` 时,该层的实现名取包名。 + +### 2.2 `requires` 语法 ✅ 已实现 + +与 `provides` 同语法。语义为:被命名的层**必须**解析为被命名的实现, +否则引擎**必须**在编译开始之前拒绝该构建。 + +省略 `=<实现名>` 时,语义为该层**必须**有供给者。 + +### 2.3 标准库模块源 ✅ 已实现 + +供给 `c++-abi` 的包**可以**在 `[build]` 下声明: + +| 键 | 类型 | 含义 | +|---|---|---| +| `std-module` | 路径 | `std` 模块源,相对包根 | +| `std-compat-module` | 路径 | `std.compat` 模块源 | +| `std-module-flags` | 字符串数组 | 编译该模块源所需的 flag | + +三者的 `[package]` 写法**必须**继续被接受。`[build]` 写法**应当**优先, +因为只有它可以按 `[target.'cfg(...)'.build]` 条件化。 + +声明了 `std-module` 而没有相应 `provides` 条目的包,引擎**必须**报错。 + +⚠️ **部分实现**:`std-module` 与 `std-compat-module` 是单一路径,不可条件化; +仅 `std-module-flags` 参与条件合并。 + +### 2.4 预制载荷的声明 ❌ 未实现 + +预制载荷的描述符**应当**能够声明它供给哪一层、该层在载荷内的位置、 +以及该层的真实版本。当前实现从载荷的包名切出接口名, +因此一个名为 `musl-gcc` 的载荷在供给 `c-abi` 时被报告为 `musl-gcc` 而非 `musl`。 + +在其落地之前,预制载荷供给的层由 `[target.<三元组>].sysroot` 与目标表的 +`sysroot` 列点名,接口名取包名。 + +--- + +## 3. 规则 + +### 3.1 规则一:每层恰好一个供给者 ✅ 已实现 + +同一层出现两个供给者时,引擎**必须**在解析期报错, +并**必须**同时指出两个包及各自进入依赖图的路径(直接依赖或传递依赖)。 + +同一个包在 `provides` 中为同一层给出多条条目时,视为一个供给者; +其中命名了实现名的一条**必须**优先。 + +### 3.2 规则二:为其下方的层配置过 ⚠️ 部分实现 + +一个实现**必须**在它曾被配置的层之上使用。引擎**必须**执行: + +- 编译器载荷的 `c++-abi`,仅在 `c-abi` 同样来自该载荷时可用;✅ +- §2.2 声明的每一条 `requires`。✅ + +⚠️ 「`compiler-runtime` 必须与 `compiler` 同族」当前**依赖供给者自行声明 +`requires`**,引擎不独立推断 —— 因为推断需要一张实现名到族的映射表, +而 §1.1 禁止引擎持有实现名。 + +### 3.3 规则三:跨来源接线 ⚠️ 部分实现 + +两层来自同一来源时,引擎**禁止**介入它们之间的关系。 +两层来自不同来源时,只有引擎同时知道两边的地址,因此**必须**由引擎接线。 + +⚠️ 当前实现覆盖「两层均来自图」与「两层均来自载荷」。 +「一层预制、一层来自图」的接线尚不完整。 + +--- + +## 4. 报告 + +### 4.1 默认输出 ✅ 已实现 + +构建**必须**报告解析出的结果而非清单中的意图。 +默认**应当**只列出来源不是编译器载荷的层;五层均来自载荷时**应当**只输出目标行。 + +`MCPP_VERBOSE=1` 时**必须**列出全部五层。 + +### 4.2 诊断 ✅ 已实现 + +诊断**必须**列出该判断所依据的每一层,包含来源为编译器载荷的层。 + +### 4.3 标识 ✅ 已实现 + +报告**应当**使用与 `[dependencies]` 中一致的包名拼写。 +全限定名**应当**仅在以下三种情形出现:`MCPP_VERBOSE`、 +同一次构建中两个短名相同、以及诊断信息。 + +--- + +## 5. 兼容性 + +### 5.1 未知名字 ✅ 已实现 + +`mcpp:` 前缀内的未知层名,引擎**必须**报错。 +清单中其它位置的未知键,引擎**禁止**据此使整份清单加载失败;**应当**警告并忽略。 + +### 5.2 既有拼写 ✅ 已实现 + +- 能力名 `hosted-standard-library` **必须**继续表示 `c++-abi` 层; +- 工具链族拼写 `openkal-llvm` **必须**继续解析,归一为 `llvm`; +- `[package]` 下的三个 `std-module*` 键**必须**继续被接受。 + +--- + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v1.0 | 2026-08-24 | 初版。五层闭集、`provides`/`requires` 语法、三条规则、报告与兼容性条款。 | diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 185b68c4..e6b3af77 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -963,6 +963,33 @@ blas = "compat.openblas" # 等价于:mcpp build --cap blas=compat.openblas compat.openblas = "0.3.0" # provider 必须是图中真实存在的依赖 ``` +保留前缀 `mcpp:` 命名本引擎解析的目标侧层,这些名字对照一个闭集校验。 +包级 `requires` 数组承载对称的陈述 —— 某个目标侧层必须解析为什么, +本包才可用。 + +```toml +[package] +name = "acme.llvm-runtime" +version = "0.1.0" +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] +``` + +作为标准库的包在 `[build]` 下陈述它的 `std` 模块源, +其所需的 flag 在那里与任何其它构建输入一样可条件化。 + +```toml +[build] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc++"] + +[target.'cfg(c-abi = "musl")'.build] +std-module-flags = ["-D_GNU_SOURCE"] +``` + +五个层、约束它们的规则与相应诊断,见 [14 - 目标侧](14-target-side.md)。 + 绑定是**确定性**的: | 图中某被需要能力的 provider 数量 | 结果 | diff --git a/docs/zh/14-target-side.md b/docs/zh/14-target-side.md new file mode 100644 index 00000000..4d92fbc4 --- /dev/null +++ b/docs/zh/14-target-side.md @@ -0,0 +1,249 @@ +# 目标侧 + +一次构建在发出任何命令行之前必须回答一个问题:目标的编译器运行时、平台接口、 +C 库与 C++ 运行时从哪里来。mcpp 在依赖图解析完成之后解析该问题一次, +其后的每一个阶段读取同一个结果。 + +本文规定该模型、约束它的规则、工程需要书写的内容,以及包需要声明的内容。 + +## 五个层 + +一次构建的目标侧由五个层构成。 + +| 层 | 内容 | 实现举例 | +|---|---|---| +| `compiler` | 执行编译的程序 | `llvm`、`gcc`、`msvc` | +| `compiler-runtime` | 编译器自身的运行时:整数与浮点 builtins、展开器 | `compiler-rt` 与 `libunwind`、`libgcc` | +| `kernel-abi` | 平台接口或其等价物 | `linux`、`windows`、`darwin`、`openkal` | +| `c-abi` | C 库 | `glibc`、`musl`、`picolibc` | +| `c++-abi` | C++ 库及其 ABI 运行时 | `libc++` 与 `libc++abi`、`libstdc++`、MSVC STL | + +### 成为层的判据 + +一个部件成为层,当三个条件同时成立:野外至少存在两个可互换的实现; +它可以独立于相邻层被替换;它与其下方的层之间存在确定的「曾为谁配置」关系。 +三者缺一,该部件属于相邻的层而非独立的一层。 + +`compiler-runtime` 独立于 `c++-abi`,因为 builtins 是一个 C 程序所需要的东西。 +把它算作 C++ 运行时的一部分,等价于断言 C 程序不需要整数除法, +而该断言已经产生过一次实测缺陷:一个交叉到 macOS 的 C 程序被询问是否存在 +C++ 运行时,回答不存在,链接行因而保留了编译器载荷自带的 `libc++`。 + +`kernel-abi` 在传统栈上没有名字 —— 在那里,C 库直接发出系统调用或直接调用 +平台入口。命名该接缝,是一份 C 库实现能够坐落在多个平台之上的前提。 + +## 四种来源 + +每一层由四种来源之一供给。 + +| 来源 | 含义 | 可知时刻 | +|---|---|---| +| `payload` | 编译器载荷自带 | 依赖解析之前 | +| `prebuilt` | 一份被点名的预制载荷供给 | 依赖解析之前 | +| `graph` | 依赖图中的包供给 | 依赖解析之后 | +| `—` | 无人供给,且这是一个陈述 | — | + +四种来源中两种在依赖解析之前可知、两种在其之后才可知。因此目标侧只在图存在的 +那一点解析一次。更早的任何推断都是对一个尚不存在的事实作推断, +而对该事实的多处独立推断不会一致。 + +缺席的层是答案而非缺口。裸机目标没有内核;不依赖任何 C 库的工程没有 C 库。 + +## 三条规则 + +### 每层恰好一个供给者 + +C 库、平台接口与 C++ 运行时是互斥的选择,而非可叠加的贡献。 +同一层出现两个供给者是错误,在解析期报出,并同时指出两个包及各自进入图的路径。 + +严格性的依据是失败模态:选错供给者不会使链接失败, +它产出一个能够运行且间歇性不能运行的程序。 + +### 为其下方的层配置过 + +一个实现只有在它曾被配置的层之上才可用。一份 `libc++` 构建把该配置记录在 +自己的 `__config_site` 中;一份 `libgcc` 构建是为 GCC 配置的。 +该关系由声明得来而非由推断得来 —— 见下文 [`requires`](#requires)。 + +由此得出两条推论。编译器载荷的 C++ 运行时,仅在 C 库同样来自该载荷时可用。 +编译器运行时必须属于编译器自身的族,因为二者不一致的构建解析 `__udivti3` +的方式,将与同一程序中其它每一次链接不同。 + +### 跨来源接线 + +引擎仅在两层来自不同来源时为它们接线。 + +| 组合 | 关系由谁表达 | 引擎 | +|---|---|---| +| 两层均来自 `graph` | 包之间的普通依赖 | 不介入 | +| 两层均来自 `payload` | 载荷自身一致 | 不介入 | +| 一层预制、一层来自图 | 只有引擎同时知道两边的地址 | 需要接线 | + +因此把一层从预制载荷移入依赖图,减少而非增加引擎的工作。 +这是一份源码在引擎不作改动的情况下到达多个平台的机制依据。 + +### 层名固定,实现不固定 + +五个层名是编译进引擎的闭集。填充它们的实现出现在包清单与索引中, +不出现在引擎的任何一行代码中。 + +层名可以固定,因为层由 C 与 C++ 的构建模型决定且不增长。 +实现不可以,因为增长正是它们所做的事:一个生态的组合数是其实现数之积, +而包数是其和。 + +## 工程书写的内容 + +层名不出现在工程清单中。工程通过三个既有机制表达它的目标侧。 + +### 目标三元组 + +`--target <三元组>`,或 `[build] target`。OS 段选择平台接口。 +env 段陈述一条对 C 库的请求;它是请求而非答案,解析出的值由构建报告。 + +### 工具链 + +`mcpp toolchain default <族>@<版本>`、清单中的 `[toolchain]`, +或针对单一目标的 `[target.<三元组>].toolchain`。它选择 `compiler` 层 —— +唯一一个任何包都不能供给的层。 + +目标表的行可以携带一条约定,即其载荷供给该目标 C 库的工具链。 +该约定在清单对该目标未作陈述时生效。当它替换了由 `mcpp toolchain default` +设定的默认时,状态行报出该替换并给出一行覆盖写法。 + +### 依赖 + +其余每一层均通过依赖一个供给它的包来选择。一条依赖可以供给多个层, +也可以通过它自身的依赖带来更多供给者。 + +```toml +[dependencies] +openkal-llvm-runtime = "0.1" +``` + +## 构建报告的内容 + +构建打印它解析出的结果。清单中的一行陈述一个意图,该意图在其下方的包发生变化 +时过期;报告陈述结果,因而不会过期。 + +默认情况下报告只列出编译器载荷未供给的层。零配置构建的五个层全部解析自同一份 +载荷,五行 `(payload)` 回答的是无人提出的问题。 + +``` + Target x86_64-linux-gnu +``` + +``` + Target x86_64-windows-gnu → x86_64-w64-windows-gnu + kernel-abi openkal (openkal-windows@0.1.3, graph) + c-abi musl (openkal-musl@0.3.3, graph) + c++-abi libc++ (openkal-llvm-runtime@0.1.1, graph) +``` + +`MCPP_VERBOSE=1` 列出全部五层。诊断始终列出该判断所依据的每一层, +包含其中平凡的部分,因为省略证据的错误信息无法被其读者复核。 + +接口与实现是两列。`openkal` 是接口,`openkal-windows` 是它的一个实现; +合并二者会掩盖一份源码为何能够到达多台机器。 + +## 包声明的内容 + +### provides + +供给某一层的包在保留前缀 `mcpp:` 下声明它。 + +```toml +provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] +``` + +语法为 `mcpp:<层>[=<实现>]`。层名对照闭集校验;拼写错误是错误, +而非一个被静默禁用的行为。前缀之外的名字属于特性系统,原样透传。 + +`mcpp:compiler` 可以被 require,不能被 provide。编译器是本引擎安装并驱动的 +载荷,而族与族之间的差异 —— flag 拼写、模块模型、BMI 格式、驱动配置文件 —— +是引擎必须持有的事实,而非包能够描述的数据。 + +### requires + +`requires` 是对称的另一半,也是在引擎中不出现实现名的前提下执行分层规则的机制。 + +```toml +requires = ["mcpp:compiler=llvm"] +``` + +由 `libc++` 源码构建的 C++ 运行时,其编译与其模块的编译均由 Clang 完成。 +该事实属于包。引擎检查一条它能够一般性地陈述的关系 —— +被命名的层必须解析为被命名的实现 —— 并通过同时指出二者来报出不匹配; +一张编译进引擎的族表,对于它从未听说过的族无法做到这一点。 + +该检查在编译开始之前运行。它所拒绝的组合,否则将在该运行时自身的头文件深处失败, +其消息命名一个读者从未打开过的文件,以及一个 mcpp 从未作出的决定。 + +### 标准库模块源 + +作为标准库的包陈述它的 `std` 模块源在何处,以及该源需要什么。 + +```toml +[build] +std-module = "llvm-generated/std.cppm" +std-compat-module = "llvm-generated/std.compat.cppm" +std-module-flags = ["--no-default-config", "-nostdinc", "-nostdinc++"] +``` + +这些键属于 `[build]`,因为模块源是该包的一个翻译单元: +它以该包的 include 目录与定义被编译。属于 `[build]` 同时使这些 flag 可条件化, +而一个在多种 C 库之上供给同一 C++ 运行时的包需要这一点。 + +```toml +[target.'cfg(c-abi = "musl")'.build] +std-module-flags = ["-D_GNU_SOURCE"] +``` + +声明 `std-module` 而没有相应的 `provides` 条目是错误:该包描述了一个它并不供给的库。 + +这三个键的 `[package]` 写法仍被接受,且不可条件化。 + +### 对已解析目标侧的适配 + +供给某一层的包经常支持其下方层的多个实现。它查询已解析的目标侧,而非被告知。 + +```toml +[target.'cfg(c-abi = "musl")'.build] +include_dirs = ["config/musl"] + +[target.'cfg(c-abi = "picolibc")'.build] +include_dirs = ["config/picolibc"] +``` + +若此处要求一次特性选择,将迫使工程重述目标三元组或其依赖图已经确立的事实, +并允许两处陈述互相矛盾。 + +这些谓词仅在 `[build]` 段中可用。目标侧在依赖解析之后才被解析, +因此由它选择的依赖将构成环。一个在不同 C 库下需要不同依赖的包, +按 C 库拆分,或依赖其并集并在 `[build]` 中选择源码。 + +## 诊断 + +四种情形由引擎而非由编译器报出。 + +| 情形 | 报出内容 | +|---|---| +| 被要求的实现不是解析出的那个 | 同时指出二者,以及选择它的命令 | +| 两个包供给同一层 | 同时指出二者,以及各自进入图的路径 | +| 某一层无人供给 | 指出该层,以及应当依赖的能力 | +| 载荷的 C++ 运行时位于外来 C 库之上 | 同时指出二者,以及两条出路 | + +一条来自编译器或链接器的、关于目标侧组合的消息,表明缺少一条诊断。 +在任何命令行被发出之前,引擎已经知道该组合不成立。 + +## 兼容性 + +三条规定保全既有清单与既有构建。 + +能力名 `hosted-standard-library` 继续表示 C++ 层。 +同时携带两种拼写的包是一个供给者,其中命名了接口的那一条是被报告的一条。 + +工具链族拼写 `openkal-llvm` 归一为 `llvm`。它命名同一份载荷, +并携带一条关于目标侧的事实,而上述模型从包的声明中解析该事实。 + +保留前缀内的未知名字是错误;清单中其它位置的未知键被忽略。 +因此一个已发布的包在早于其所携带的键的引擎下仍能加载。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 15533efd..e1c949af 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -16,6 +16,7 @@ - [11 - 机器可读输出](11-machine-output.md) - [12 - 分发预编译库](12-binary-distribution.md) - [13 - 裸机与 freestanding 目标](13-baremetal.md) +- [14 - 目标侧](14-target-side.md) ## 规范文档 diff --git a/mcpp.toml b/mcpp.toml index 780a2363..ad5f2a34 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.24.1" +version = "2026.8.24.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 657191bd..99e6b762 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -434,15 +434,51 @@ export enum class TcOrigin { None, // nothing resolved yet ManifestToolchain, // mcpp.toml [toolchain] — user explicit TargetSection, // mcpp.toml [target.X].toolchain — user explicit - GlobalDefault, // config.toml [toolchain] default — mcpp's own default + GlobalDefault, // `mcpp toolchain default` — user explicit TargetPin, // triple.cppm vocabulary convention FirstRun, // chosen and persisted by this very invocation }; +// ⚠️ `GlobalDefault` IS DELIBERATELY NOT LISTED, AND THE REASON IS A MEASURED +// REGRESSION RATHER THAN A JUDGEMENT ABOUT WHOSE OPINION COUNTS. +// +// A target row's pin does not name a preferred compiler. It names the payload +// that supplies THAT TARGET'S C library — the mingw payload for +// `x86_64-windows-gnu`, the musl-gcc payload for `*-linux-musl`. Whether the +// user's own default can serve the target instead depends on whether something +// ELSE supplies the target side, and that is knowable only after the dependency +// graph is resolved, which is after this line. +// +// Making the global default outrank the pin was tried and measured: a project +// with no dependencies, a global default of `llvm@22.1.8` and +// `--target x86_64-windows-gnu` stopped building, because clang alone carries no +// C runtime for that target while the payload the row names does. That is a +// working build turned into a failing one by an upgrade. +// +// What the user actually loses is ergonomics, and that is addressed where it is +// visible: when the pin replaces a default the user wrote down, the status line +// SAYS SO and names the one-line override. 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. export inline bool tc_origin_is_user_explicit(TcOrigin o) { return o == TcOrigin::ManifestToolchain || o == TcOrigin::TargetSection; } +// 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. +export constexpr std::string_view tc_origin_name(TcOrigin o) { + switch (o) { + case TcOrigin::ManifestToolchain: return "[toolchain] in mcpp.toml"; + case TcOrigin::TargetSection: return "[target.] in mcpp.toml"; + case TcOrigin::GlobalDefault: return "your default"; + case TcOrigin::TargetPin: return "target default"; + case TcOrigin::FirstRun: return "first-run default"; + case TcOrigin::None: break; + } + return {}; +} + // What to tell a user whose build targets the MSVC ABI on a machine that // cannot serve it. Two shapes, because the two states need different fixes: // @@ -853,6 +889,12 @@ prepare_build(bool print_fingerprint, // the graph can supply the target's system, and the graph is not known // here. Held until it is, and released only if nothing supplies it. std::string unservedTargetDiagnosis; + // Non-empty when a target row's convention replaced a toolchain the user + // had set with `mcpp toolchain default`. Reported on the status line, + // because a substitution nobody is told about is a rule that can only be + // learned by experiment — writing the same value a second time in + // `[target.]` and observing that it works. + std::string pinReplacedDefault; auto root = overrides.project_root.empty() ? mcpp::project::find_manifest_root(std::filesystem::current_path()) @@ -1531,6 +1573,17 @@ prepare_build(bool print_fingerprint, const bool pinWouldOverruleUser = tc_origin_is_user_explicit(tcOrigin); if (known && !hasToolchainOverride && !known->pin.empty() && !pinWouldOverruleUser) { + // ⚠️ AND WHEN IT REPLACES SOMETHING THE USER WROTE DOWN, SAY SO. + // + // `mcpp toolchain default llvm` prints the change back and then a + // cross build silently used a different compiler. The substitution + // is correct — the row names the payload that supplies this target's + // C library — but a status line reporting only the outcome left the + // reader to discover the rule by writing the same value a second + // time in `[target.]` and observing that it worked. + if (tcOrigin == TcOrigin::GlobalDefault && tcSpec.has_value() + && *tcSpec != known->pin) + pinReplacedDefault = *tcSpec; tcSpec = std::string(known->pin); if (!tc_origin_is_user_explicit(tcOrigin)) tcOrigin = TcOrigin::TargetPin; @@ -1697,10 +1750,27 @@ prepare_build(bool print_fingerprint, else report_fixup(*fixed, payload->root); // Canonical rendering, whatever spelling the manifest/config used: // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". + // + // ⚠️ AND IT SAYS SO WHEN MCPP CHOSE. A toolchain the user wrote down + // needs no explanation — they can read their own manifest. One this + // engine selected from a target row is a decision the user did not + // 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()) + chosenBy = std::format( + "\n target default for {}, replacing your " + "{} — override with `[target.{}] toolchain`", + overrides.target_triple, pinReplacedDefault, + overrides.target_triple); + else if (tcOrigin == TcOrigin::TargetPin + || tcOrigin == TcOrigin::FirstRun) + chosenBy = std::format(" ({})", tc_origin_name(tcOrigin)); mcpp::ui::info("Resolved", - std::format("{} → {}", spec->display(), + std::format("{} → {}{}", spec->display(), mcpp::ui::shorten_path(explicit_compiler, - mcpp::fetcher::make_path_ctx(&**get_cfg(), *root)))); + mcpp::fetcher::make_path_ctx(&**get_cfg(), *root)), + chosenBy)); } } else if (tcSpec.has_value() && *tcSpec == "system") { // Explicit user opt-in to system PATH compiler — kept as escape hatch. @@ -5453,43 +5523,118 @@ prepare_build(bool print_fingerprint, { namespace tsd = mcpp::targetside; - // Scan the graph once for each layer. A package declares the layer it + // Scan the graph once for every layer. A package declares the layer it // supplies and, optionally, the interface name it answers to: // // provides = ["mcpp:kernel-abi=openkal"] // - // The engine knows the three layer names and nothing about the + // The engine knows the five layer names and nothing about the // implementations that fill them. `hosted-standard-library` is accepted // for the C++ layer as the spelling that shipped before this one, so an // existing package keeps working unchanged. - auto provider_of = [&](tsd::CapLayer want) - -> std::optional { - std::optional found; - for (auto const& pkg : packages) { - for (auto const& entry : pkg.manifest.provides) { - std::optional decl; - if (auto parsed = tsd::parse_capability(entry); parsed && *parsed) - decl = **parsed; - else if (entry == "hosted-standard-library") - decl = tsd::CapDecl{ tsd::CapLayer::CxxAbi, {} }; - if (!decl || decl->layer != want) continue; - - tsd::Provider p; - p.name = pkg.manifest.package.name; - p.version = pkg.manifest.package.version; - p.interfaceName = decl->interfaceName; - p.hasStdModule = !pkg.manifest.stdModule.empty(); - // A package may carry both spellings during the transition, - // and the array order is the author's, not a preference. - // The current spelling names the interface; the older one - // cannot, so taking whichever came first would report a - // package name where an interface name belongs. - if (!found || (found->interfaceName.empty() - && !p.interfaceName.empty())) - found = p; + // + // ⚠️ ONE SUPPLIER PER LAYER, AND TWO IS AN ERROR RATHER THAN A PICK. + // A C library, a kernel interface and a C++ runtime are mutually + // exclusive choices; the same rule already governs `[build] runner` for + // the same reason. Until this scan collected candidates instead of + // keeping the first acceptable one, two suppliers resolved by graph + // traversal order — an order the author neither writes nor can predict — + // and the loser's `[build]` section still reached the command line. + struct Candidate { tsd::Provider p; bool direct; }; + std::map> byLayer; + std::vector requirements; + + const auto& rootDeps = m->dependencies; + auto is_direct = [&](std::string_view name) { + for (auto const& [k, _] : rootDeps) { + if (k == name) return true; + // Selectors are `.` or a bare tail; a tail + // match is what the author sees in their own manifest. + if (k.size() > name.size() && k.ends_with(name) + && k[k.size() - name.size() - 1] == '.') + return true; + } + return false; + }; + + for (auto const& pkg : packages) { + const auto pkgId = pkg.manifest.package.version.empty() + ? pkg.manifest.package.name + : std::format("{}@{}", pkg.manifest.package.name, + pkg.manifest.package.version); + + for (auto const& entry : pkg.manifest.provides) { + std::optional decl; + if (auto parsed = tsd::parse_capability(entry); parsed && *parsed) + decl = **parsed; + else if (entry == "hosted-standard-library") + decl = tsd::CapDecl{ tsd::CapLayer::CxxAbi, {} }; + if (!decl) continue; + if (!tsd::layer_is_suppliable_by_package(decl->layer)) { + return std::unexpected(std::format( + "package '{}' declares `provides = [\"{}\"]`, and the " + "compiler is not a layer a package can supply.\n" + " A compiler is a payload this engine installs and " + "drives; the differences between families are things the " + "engine must know rather than data a package can " + "describe.\n" + " A package may REQUIRE one: `requires = " + "[\"mcpp:compiler=\"]`.", + pkgId, entry)); + } + + tsd::Provider p; + p.name = pkg.manifest.package.name; + p.version = pkg.manifest.package.version; + p.interfaceName = decl->interfaceName; + p.hasStdModule = !pkg.manifest.stdModule.empty(); + + auto& slot = byLayer[static_cast(decl->layer)]; + // A package may carry both spellings during the transition, and + // the array order is the author's, not a preference. Two entries + // from the SAME package are one supplier; the current spelling + // names the interface and the older one cannot, so the entry + // that carries an interface name wins. + auto same = std::find_if(slot.begin(), slot.end(), + [&](const Candidate& c){ return c.p.name == p.name; }); + if (same != slot.end()) { + if (same->p.interfaceName.empty() && !p.interfaceName.empty()) + same->p = p; + } else { + slot.push_back({ p, is_direct(p.name) }); } } - return found; + + // `requires` — the symmetric half. An entry naming a layer this + // engine does not know is an error for the same reason a `provides` + // one is: a typo would otherwise disable a check silently. + for (auto const& entry : pkg.manifest.requires_) { + auto parsed = tsd::parse_capability(entry); + if (!parsed) + return std::unexpected(std::format( + "package '{}': {}", pkgId, parsed.error())); + if (!*parsed) continue; // not in mcpp's namespace + requirements.push_back({ pkgId, (*parsed)->layer, + (*parsed)->interfaceName }); + } + } + + for (auto const& [layerInt, slot] : byLayer) { + if (slot.size() < 2) continue; + tsd::Conflict c; + c.layer = static_cast(layerInt); + c.first = slot[0].p.id(); + c.firstVia = slot[0].direct ? "" : "a transitive dependency"; + c.second = slot[1].p.id(); + c.secondVia = slot[1].direct ? "" : "a transitive dependency"; + return std::unexpected(tsd::format_conflict(c)); + } + + auto provider_of = [&](tsd::CapLayer want) + -> std::optional { + auto it = byLayer.find(static_cast(want)); + if (it == byLayer.end() || it->second.empty()) return std::nullopt; + return it->second.front().p; }; tsd::Inputs in; @@ -5513,14 +5658,27 @@ prepare_build(bool print_fingerprint, } in.payloadLibcRef = tc->targetSysrootPkg; in.payloadCxxInterface = tc->stdlibId; + // The compiler is a layer, and it is the one layer no package can + // supply. It enters here so that a requirement has something to be + // checked against and so the report can show the whole stack. + in.compilerFamily = std::string(tc->compiler_family()); + in.compilerVersion = tc->version; } - in.kernelAbi = provider_of(tsd::CapLayer::KernelAbi); - in.cAbi = provider_of(tsd::CapLayer::CAbi); - in.cxxAbi = provider_of(tsd::CapLayer::CxxAbi); + in.compilerRuntime = provider_of(tsd::CapLayer::CompilerRuntime); + in.kernelAbi = provider_of(tsd::CapLayer::KernelAbi); + in.cAbi = provider_of(tsd::CapLayer::CAbi); + in.cxxAbi = provider_of(tsd::CapLayer::CxxAbi); resolvedTargetSide = tsd::resolve(in); if (auto why = tsd::check_layering(resolvedTargetSide)) return std::unexpected(*why); + // ⚠️ REQUIREMENTS ARE CHECKED BEFORE ANYTHING IS COMPILED, WHICH IS THE + // WHOLE POINT OF DECLARING THEM. The combination this rejects — a C++ + // 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)) + return std::unexpected(*why); // The refusal held since toolchain resolution, released now that the // other half of its question has an answer. A payload on this machine @@ -5530,6 +5688,38 @@ prepare_build(bool print_fingerprint, && !resolvedTargetSide.system_from_graph()) return std::unexpected(unservedTargetDiagnosis); + // ⚠️ THE TARGET AND THE COMPILER ARE NOT BOUND TOGETHER, AND THE + // TARGET ROW'S CONVENTION IS A FALLBACK RATHER THAN A RULE. + // + // A row pins a toolchain because the payload that toolchain belongs to + // is what supplies THAT TARGET'S C library. A project whose target side + // comes from its dependency graph does not use that payload, so the + // substitution was unnecessary — and this is the first line at which + // that is knowable, because it is the first line at which the graph + // exists. + // + // ⚠️ The decision itself is NOT revised here. `tc` has been read and + // mutated at 39 sites between its resolution and this point — the + // effective triple, the cross flag, the target sysroot, the MSVC + // runtime contract — and re-resolving it here would redo all of them + // out of order. Deferring the CHOICE the way the target side itself was + // deferred is the structural fix and is its own change; until then the + // user is told what happened and how to state the preference once. + if (!pinReplacedDefault.empty() + && resolvedTargetSide.system_from_graph()) { + mcpp::diag::warning("toolchain", std::format( + "this project's target side comes from its dependency graph, so " + "{} would have served {}.\n" + " mcpp used the target row's convention because the graph " + "is not known when the\n" + " toolchain is chosen. State the preference for this " + "target to skip the substitution:\n" + " [target.{}]\n" + " toolchain = \"{}\"", + pinReplacedDefault, resolvedTargetCanonical, + resolvedTargetCanonical, pinReplacedDefault)); + } + // A request that cannot be honoured is said so rather than dropped. // // Measured 2026-08-23: `[build] linkage = "dynamic"` on a project whose @@ -5556,11 +5746,17 @@ prepare_build(bool print_fingerprint, // question that until now had no answer at all: reading every manifest // in the graph did not tell anyone what would end up on the link line, // because three places derived it separately and could disagree. + // + // ⚠️ AND IT PRINTS ONLY WHAT IS NOT ORDINARY. A zero-configuration build + // resolves all five layers from one compiler payload, and five lines + // reading `(payload)` answer a question nobody asked. `MCPP_VERBOSE` + // prints them all; a diagnostic always does. mcpp::ui::info("Target", tsd::format_report( resolvedTargetSide, resolvedTargetCanonical.empty() ? (tc ? tc->targetTriple : std::string{}) - : resolvedTargetCanonical)); + : resolvedTargetCanonical, + mcpp::log::is_verbose())); } // ── L3: ROOT build.mcpp (moved after dependency resolution, design §3.1 @@ -5845,7 +6041,7 @@ prepare_build(bool print_fingerprint, // follows says where its headers are. The codegen step needs only the // first — see Toolchain::stdModuleTargetFlags. tc->stdModuleTargetFlags = flags; - for (auto& f : pkg.manifest.stdModuleFlags) { + for (auto& f : pkg.manifest.buildConfig.stdModuleFlags) { // A flag naming a path is relative to the package that named it, // for the same reason the module source is. auto candidate = pkg.root / f; diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index c6da8b60..3f672342 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -474,14 +474,38 @@ std::expected parse_string(std::string_view content, return std::unexpected(error(origin, cap.error())); m.provides = *v; } - // [package] std-module / std-module-flags — see manifest::types. Relative to - // the package root, because that is what a package can state about itself; - // the absolute path is made where the package's root is known. + // [package] requires — validated exactly like `provides`: names under the + // reserved prefix are a closed set, everything else passes through. + if (auto v = doc->get_string_array("package.requires")) { + for (auto const& entry : *v) + if (auto cap = mcpp::targetside::parse_capability(entry); !cap) + return std::unexpected(error(origin, cap.error())); + m.requires_ = *v; + } + // std-module / std-compat-module / std-module-flags. + // + // ⚠️ THEY BELONG UNDER `[build]`, AND `[package]` IS THE OLDER SPELLING. + // The module source is one of this package's translation units in every way + // that matters: it is compiled with the package's include directories and + // its definitions, and it is a `.cppm` file like any other. Keeping it in + // `[package]` cost the one thing that placement decides — `[build]` is + // conditional and `[package]` is not, so a package supporting several C + // libraries could not vary the flags its std module needs. `-D_GNU_SOURCE` + // is right for musl and glibc and wrong for picolibc, and there was no + // spelling for that. + // + // Read `[package]` first so `[build]` wins, and so a manifest carrying both + // during the transition behaves the way its author would expect. if (auto v = doc->get_string("package.std-module")) m.stdModule = *v; if (auto v = doc->get_string("package.std-compat-module")) m.stdCompatModule = *v; if (auto v = doc->get_string_array("package.std-module-flags")) - m.stdModuleFlags = *v; + m.buildConfig.stdModuleFlags = *v; + if (auto v = doc->get_string("build.std-module")) m.stdModule = *v; + if (auto v = doc->get_string("build.std-compat-module")) + m.stdCompatModule = *v; + if (auto v = doc->get_string_array("build.std-module-flags")) + m.buildConfig.stdModuleFlags = *v; // [capabilities] cap = "provider" — root-only provider pins. if (auto* caps = doc->get_table("capabilities"); caps && !caps->empty()) { diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index e082dc9b..c8b61551 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -192,6 +192,16 @@ struct BuildInputs { std::vector includeDirs; // relative to package root // #249: emitted as -idirafter (searched after the toolchain's system dirs) std::vector includeDirsAfter; + // What the `std` module source of a package that IS a standard library + // needs on its command line. + // + // A MEMBER OF THIS TYPE AND NOT OF THE MANIFEST, for the same reason + // `defines` is: membership here is what makes the cfg axis carry it. A + // package supplying one C++ runtime over SEVERAL C libraries needs + // different flags per C library — `-D_GNU_SOURCE` is right for musl and + // glibc and wrong for picolibc — and while this lived beside the package's + // identity there was no spelling for that difference. + std::vector stdModuleFlags; }; // The single additive merge. Every conditional axis folds through this, so @@ -210,6 +220,9 @@ inline void append(BuildInputs& dst, const BuildInputs& src) { dst.includeDirsAfter.insert(dst.includeDirsAfter.end(), src.includeDirsAfter.begin(), src.includeDirsAfter.end()); + dst.stdModuleFlags.insert(dst.stdModuleFlags.end(), + src.stdModuleFlags.begin(), + src.stdModuleFlags.end()); } // A build-graph node declared by a build program (`mcpp:action=`). @@ -904,6 +917,23 @@ struct Manifest { // one provider from the graph. See // .agents/docs/2026-06-29-feature-capability-model-design.md. std::vector provides; // package-level + // [package] requires — the symmetric half of `provides`, and the only way + // the layering rule can be enforced without a product name in the engine. + // + // A C++ runtime built from libc++'s sources is compiled, and its module, + // by clang; gcc cannot consume it. That fact belongs to the package: + // + // requires = ["mcpp:compiler=llvm"] + // + // The engine then checks a relation it can state generically — the named + // layer must resolve to the named implementation — and reports a mismatch + // by naming both, which a hardcoded table could not do for a family it had + // never heard of. + // + // ⚠️ Names outside the `mcpp:` prefix are the feature system's and pass + // through untouched, exactly as they do in `provides`. + // ⚠️ The spelling is `requires_` because `requires` is a keyword. + std::vector requires_; // [package] std-module / std-module-flags — a package that IS a standard // library says where its `std' module source is and what that source needs // to compile. The build tool otherwise asks the COMPILER where std.cppm is @@ -921,7 +951,8 @@ struct Manifest { // `import std.compat;` — a complete answer, and better than silently // pairing its own `std` with the toolchain's `std.compat`. std::string stdCompatModule; - std::vector stdModuleFlags; + // (`std-module-flags` lives on BuildInputs so the cfg axis can carry it — + // see the member there.) std::map> featureProvides; // feature → caps std::map> featureRequires; // feature → caps // Feature System v2 Stage 2a — dependencies activated by a feature. A dep diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index 13652b21..ca70844b 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -229,7 +229,8 @@ inline constexpr std::string_view kKnownXpkgKeys[] = { "cflags", "c_standard", "cxxflags", "defines", "deps", "features", "flags", "generated_files", "import_std", "include_dirs", "include_dirs_after", "language", "ldflags", - "linux", "macosx", "modules", "provides", "runtime", "scan_overrides", + "linux", "macosx", "modules", "provides", "requires", "runtime", + "scan_overrides", "schema", "sources", "target_cfg", "targets", "windows", }; @@ -238,7 +239,10 @@ inline constexpr std::string_view kKnownXpkgKeys[] = { inline constexpr std::pair kXpkgKeyAliases[] = { { "dependencies", "deps" }, { "dependency", "deps" }, - { "requires", "deps" }, // cargo muscle memory + // ⚠️ `requires` USED TO REDIRECT HERE and no longer does: it is a key in its + // own right now, the symmetric half of `provides`. A redirect for a key that + // exists is unreachable — `closest_known_xpkg_key` is consulted only for + // UNKNOWN keys — and leaving it would be a claim this file no longer makes. // #296: `defines` is a real key now (package-level bare macros). Only the // singular misspelling needs redirecting, and it points at `defines` — the // old redirect to `flags` predates the key existing. @@ -1271,18 +1275,25 @@ synthesize_from_xpkg_lua(std::string_view luaContent, } cur.consume('}'); } - else if (key == "provides") { + else if (key == "provides" || key == "requires") { // Package-level capabilities (Feature System v2 S3): this package // satisfies the listed abstract capability names for any dependent // that `requires` them. `{ "blas", "lapack", ... }`. + // + // `requires` is the symmetric half: what this package needs a + // target-side layer to resolve to. Both arrays serve the same two + // populations — names under the reserved `mcpp:` prefix are layers + // the engine acts on, everything else belongs to the feature system. + auto* dst = (key == "provides") ? &m.provides : &m.requires_; if (!cur.consume('{')) { return std::unexpected(ManifestError{ - "expected '{' after `provides =`", m.sourcePath, 0, 0}); + std::format("expected '{{' after `{} =`", key), + m.sourcePath, 0, 0}); } cur.skip_ws_and_comments(); while (!cur.eof() && cur.peek() != '}') { auto s = cur.read_string(); - if (!s.empty()) m.provides.push_back(std::move(s)); + if (!s.empty()) dst->push_back(std::move(s)); cur.skip_ws_and_comments(); } cur.consume('}'); diff --git a/src/targetside/model.cppm b/src/targetside/model.cppm index 28062562..cc36b57b 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -47,7 +47,7 @@ import std; export namespace mcpp::targetside { -// ── The four ways a layer can be supplied ──────────────────────────────────── +// ── The four ways a layer can be supplied ─────────────────────────────────── // // `Xpkg` and `Payload` are both "prebuilt", and they are still distinct: a // payload directory is chosen by the toolchain and an xpkg by the target table @@ -89,16 +89,40 @@ struct Layer { // ── The resolved target side ───────────────────────────────────────────────── // -// Three layers, and their correspondence to the triple is not decoration: +// FIVE LAYERS. A layer is a seam at which one implementation can be exchanged +// for another; a thing is a layer when three conditions hold at once — at least +// two interchangeable implementations exist, it can be replaced independently of +// its neighbours, and it stands in a definite "was configured for" relation to +// the layer beneath it. // -// kernelAbi ← the triple's OS field linux / macos / windows / none -// cAbi ← the triple's ENV field gnu / musl / (msvc) -// cxx ← no field of the triple because it sits above the ABI +// compiler who compiles llvm / gcc / msvc +// compilerRuntime the compiler's own compiler-rt+libunwind / libgcc +// runtime: builtins, the +// unwinder +// kernelAbi the platform interface linux / windows / darwin / openkal +// cAbi the C library glibc / musl / picolibc +// cxx the C++ library and its libc++ / libstdc++ / MSVC STL +// ABI runtime // -// The middle layer is implicit on a traditional stack — a C library issues -// syscalls or calls Win32 directly, and nothing names the seam. openkal's whole -// contribution is to name it, which is why `kernelAbi` reads `—` for a picolibc -// bare-metal build and `openkal` for an openkal one ON THE SAME TARGET. +// ⚠️ `compilerRuntime` IS NOT PART OF `cxx`, AND THE DISTINCTION WAS MEASURED +// BEFORE IT WAS NAMED. The builtins (`__udivti3` and its relatives) are what a +// PURE C PROGRAM needs. Counting them as part of the C++ runtime is the same +// error as the one recorded at the head of this file: a C program crossed to +// macOS was asked "is there a C++ runtime" and answered "no", after which the +// link line kept the payload's own libc++ and handed a Linux shared object to a +// Mach-O linker. A layer that only some programs need is still a layer. +// +// ⚠️ `kernelAbi` HAS NO NAME ON A TRADITIONAL STACK. A C library issues system +// calls or calls the platform's own entry points directly, and nothing names the +// seam. Naming it is what lets one C library sit above four platforms, which is +// why this field reads `—` for a picolibc bare-metal build and `openkal` for an +// openkal one ON THE SAME TARGET. +// +// The correspondence to the triple is partial and that is the point: +// +// kernelAbi ← the triple's OS field +// cAbi ← the triple's ENV field, AS A REQUEST rather than as the answer +// the rest ← no field of the triple struct TargetSide { // The triple the driver is actually given, which is NOT the one the user // wrote. Measured: `--target=aarch64-macos` produces a Mach-O whose @@ -108,6 +132,8 @@ struct TargetSide { // bearing and belongs in the report. std::string llvmTriple; + Layer compiler; + Layer compilerRuntime; Layer kernelAbi; Layer cAbi; Layer cxx; @@ -126,7 +152,7 @@ struct TargetSide { // // mcpp HARDCODES LAYER NAMES AND NEVER HARDCODES IMPLEMENTATIONS. // -// The three layer names below are a closed set compiled into the engine. The +// The five layer names below are a closed set compiled into the engine. The // implementations that fill them — openkal, musl, picolibc, and whatever comes // next — appear nowhere in this file or any other. That line is what separates // this design from the string comparison it replaces (`fam == "openkal-llvm"` @@ -136,17 +162,28 @@ struct TargetSide { // build model and do not grow. Implementations may not, because growing is // precisely what they do: the ecosystem's combinations are 2×N×M while its // packages are 2+N+M. -enum class CapLayer { KernelAbi, CAbi, CxxAbi }; +enum class CapLayer { Compiler, CompilerRuntime, KernelAbi, CAbi, CxxAbi }; constexpr std::string_view cap_layer_name(CapLayer l) { switch (l) { - case CapLayer::KernelAbi: return "kernel-abi"; - case CapLayer::CAbi: return "c-abi"; - case CapLayer::CxxAbi: return "c++-abi"; + case CapLayer::Compiler: return "compiler"; + case CapLayer::CompilerRuntime: return "compiler-runtime"; + case CapLayer::KernelAbi: return "kernel-abi"; + case CapLayer::CAbi: return "c-abi"; + case CapLayer::CxxAbi: return "c++-abi"; } return {}; } +// The layers a PACKAGE may supply. `compiler` is not among them: a compiler is +// a payload this engine installs and drives, and the differences between +// families — flag spellings, the module model, the BMI format, the driver +// config file — are things the engine must know rather than data a package can +// describe. It remains a layer, and it remains one a package may REQUIRE. +constexpr bool layer_is_suppliable_by_package(CapLayer l) { + return l != CapLayer::Compiler; +} + struct CapDecl { CapLayer layer; std::string interfaceName; // the `=` part; empty when omitted @@ -180,17 +217,23 @@ parse_capability(std::string_view entry) { } CapDecl d{}; - if (layer == "kernel-abi") d.layer = CapLayer::KernelAbi; - else if (layer == "c-abi") d.layer = CapLayer::CAbi; - else if (layer == "c++-abi") d.layer = CapLayer::CxxAbi; + if (layer == "compiler") d.layer = CapLayer::Compiler; + else if (layer == "compiler-runtime") d.layer = CapLayer::CompilerRuntime; + else if (layer == "kernel-abi") d.layer = CapLayer::KernelAbi; + else if (layer == "c-abi") d.layer = CapLayer::CAbi; + else if (layer == "c++-abi") d.layer = CapLayer::CxxAbi; else return std::unexpected(std::format( - "`provides = [\"{}\"]` names no capability mcpp knows.\n" + "`{}` names no capability mcpp knows.\n" " The `mcpp:` prefix is reserved for the target-side layers " - "this engine resolves, and there are three:\n" - " mcpp:kernel-abi[=] the platform interface a C library sits on\n" - " mcpp:c-abi[=] the C library\n" - " mcpp:c++-abi[=] the C++ runtime\n" + "this engine resolves, and there are five:\n" + " mcpp:compiler[=] who compiles\n" + " mcpp:compiler-runtime[=] the compiler's own runtime " + "(builtins, unwinder)\n" + " mcpp:kernel-abi[=] the platform interface a C " + "library sits on\n" + " mcpp:c-abi[=] the C library\n" + " mcpp:c++-abi[=] the C++ runtime\n" " A capability of your own needs no prefix; those are passed " "through untouched.", entry)); @@ -198,6 +241,32 @@ parse_capability(std::string_view entry) { return std::optional{d}; } +// ── Requirements: `requires = ["mcpp:="]` ───────────── +// +// THE SYMMETRIC HALF OF `provides`, AND THE ONLY WAY THE LAYERING RULE CAN BE +// ENFORCED WITHOUT PUTTING A PRODUCT NAME IN THE ENGINE. +// +// A C++ runtime built from libc++'s sources is compiled, and its module, by +// clang; gcc cannot consume it. That fact belongs to the package, not to mcpp — +// writing `if (stdlib == "libc++" && compiler == gcc)` here would hardcode two +// implementation names, which rule four forbids. The package states it: +// +// requires = ["mcpp:compiler=llvm"] +// +// and this engine checks a relation it can state generically: the layer named +// must resolve to the interface named. +// +// ⚠️ It is also how `compiler-runtime` stays honest. libgcc is configured for +// gcc and compiler-rt for clang; a build whose compiler is one and whose +// runtime is the other resolves `__udivti3` differently from every other link in +// the same program. mcpp does not know which runtime belongs to which family — +// the runtime package says so. +struct Requirement { + std::string requiredBy; // package id that stated it, for the diagnostic + CapLayer layer; + std::string interfaceName; // what that layer must resolve to +}; + // ── Resolver input ─────────────────────────────────────────────────────────── // // Plain data, assembled by the caller after dependency resolution. Keeping the @@ -227,6 +296,14 @@ struct Inputs { std::string targetEnv; // mcpp's own ENV field ("musl", "gnu", …) bool freestandingTarget = false; + // The compiler, which is always a payload and never a package (see + // `layer_is_suppliable_by_package`). Present here so that the layering rule + // has something to check requirements against, and so that the report can + // show the whole stack rather than the part of it packages happen to fill. + std::string compilerFamily; // "llvm" / "gcc" / "msvc" + std::string compilerVersion; + + std::optional compilerRuntime; std::optional kernelAbi; std::optional cAbi; std::optional cxxAbi; @@ -257,6 +334,25 @@ inline TargetSide resolve(const Inputs& in) { TargetSide ts; ts.llvmTriple = in.llvmTriple; + // compiler — always a payload, never a package. + if (!in.compilerFamily.empty()) + ts.compiler = { Origin::Payload, in.compilerFamily, + in.compilerVersion, false }; + + // compiler-runtime — the builtins and the unwinder. + // + // ⚠️ ABSENT FROM THE GRAPH DOES NOT MEAN ABSENT. Every compiler payload + // ships one; a package supplies it only when the payload's own is the wrong + // one for this target, which is the same shape as every other layer here. + // The payload's is reported under the compiler's own name because that is + // what it is — a family's runtime, not a separately chosen implementation. + if (in.compilerRuntime) + ts.compilerRuntime = { Origin::Graph, + in.compilerRuntime->display_interface(), + in.compilerRuntime->id(), false }; + else if (!in.compilerFamily.empty()) + ts.compilerRuntime = { Origin::Payload, in.compilerFamily, {}, false }; + // kernel-abi ← the triple's OS field. if (in.kernelAbi) ts.kernelAbi = { Origin::Graph, in.kernelAbi->display_interface(), @@ -304,8 +400,25 @@ inline TargetSide resolve(const Inputs& in) { return ts; } -// The same rule stated for the explicit-override path, where the resolver's -// structure no longer guarantees it. +// The layer a capability name refers to, so a check can be written once for all +// five rather than once per layer. +inline const Layer& layer_of(const TargetSide& ts, CapLayer l) { + switch (l) { + case CapLayer::Compiler: return ts.compiler; + case CapLayer::CompilerRuntime: return ts.compilerRuntime; + case CapLayer::KernelAbi: return ts.kernelAbi; + case CapLayer::CAbi: return ts.cAbi; + case CapLayer::CxxAbi: return ts.cxx; + } + return ts.cxx; +} + +// ── Rule two, part one: what the resolver's structure cannot guarantee ─────── +// +// The default path cannot construct the payload-C++-over-foreign-C-library +// combination, because `resolve` only reaches the payload's C++ runtime when the +// C library is also the payload's. An explicit `[target.X]` override can, so the +// rule is stated again here for that path. inline std::optional check_layering(const TargetSide& ts) { if (ts.cxx.origin == Origin::Payload && ts.cAbi.origin != Origin::Payload && ts.cAbi.origin != Origin::None) @@ -324,28 +437,159 @@ inline std::optional check_layering(const TargetSide& ts) { return std::nullopt; } +// ── Rule two, part two: declared requirements ──────────────────────────────── +// +// ⚠️ A REQUIREMENT IS CHECKED AGAINST THE RESOLVED LAYER, NOT AGAINST THE +// REQUEST. `requires = ["mcpp:compiler=llvm"]` is satisfied by whatever the +// compiler layer actually resolved to, which is the only value that will be on +// the command line. +// +// Nothing here knows what `llvm` or `compiler-rt` mean. The comparison is +// between two strings a package chose and a supplier declared, and a mismatch is +// 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. +inline std::optional +check_requirements(const TargetSide& ts, std::span reqs) { + constexpr std::string_view kPad = " "; + for (auto const& r : reqs) { + // An entry with no `=` asks only that the layer be + // supplied by someone, which the absence check below still answers. + auto const& have = layer_of(ts, r.layer); + if (!r.interfaceName.empty() && have.interfaceName == r.interfaceName) + continue; + if (r.interfaceName.empty() && !have.absent()) continue; + + auto name = cap_layer_name(r.layer); + // What to do about it depends on which layer disagreed, and there are + // only two answers: the compiler is chosen by the toolchain axis, and + // 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) + : std::format( + " Depend on a package that declares `provides = " + "[\"mcpp:{}={}\"]`,\n" + " or remove the package that requires it.", + name, r.interfaceName); + + if (have.absent()) + return std::format( + "`{}` requires the {} to be `{}`, and nothing supplies that " + "layer.\n" + "{}{:<17} {}\n{}", + r.requiredBy, name, + r.interfaceName.empty() ? "supplied" : r.interfaceName, + kPad, name, "—", advice); + + std::string resolved = have.impl.empty() + ? std::format("{:<14} ({})", have.interfaceName, + origin_name(have.origin)) + : std::format("{:<14} ({}, {})", have.interfaceName, have.impl, + origin_name(have.origin)); + return std::format( + "`{}` requires the {} to be `{}`.\n" + "{}{:<17} {}\n" + "{}{:<17} {:<14} (required by {})\n" + " An implementation is configured for the layer beneath it, " + "and this one\n" + " was never configured for the one that resolved.\n{}", + r.requiredBy, name, r.interfaceName, + kPad, name, resolved, + kPad, "required", r.interfaceName, r.requiredBy, + advice); + } + return std::nullopt; +} + +// ── Rule one: one supplier per layer ───────────────────────────────────────── +// +// A C library, a kernel interface and a C++ runtime are MUTUALLY EXCLUSIVE +// CHOICES, not additive contributions. Two suppliers is an error, and it must be +// an error rather than a silent pick: the failure mode of choosing wrong is not +// a link error but a program that runs and occasionally does not. +struct Conflict { + CapLayer layer; + std::string first; // package id + std::string firstVia; // empty when a direct dependency + std::string second; + std::string secondVia; +}; + +inline std::string format_conflict(const Conflict& c) { + auto via = [](std::string_view v) { + return v.empty() ? std::string("a direct dependency") + : std::format("via {}", v); + }; + return std::format( + "two packages supply the {}, and it is a choice rather than a " + "contribution.\n" + " {} ({})\n" + " {} ({})\n" + " A build has exactly one {}. Remove one of them, or depend on a " + "package that reexports the one you want.", + cap_layer_name(c.layer), + c.first, via(c.firstVia), c.second, via(c.secondVia), + cap_layer_name(c.layer)); +} + // ── Report ─────────────────────────────────────────────────────────────────── // // The build prints what it RESOLVED, and that is why this design adds no // manifest field for the same information. A line in a manifest states an // intention that goes stale when the packages beneath it change; this states // the outcome and cannot. -inline std::string format_report(const TargetSide& ts, std::string_view targetName) { +// +// ⚠️ BY DEFAULT IT PRINTS ONLY THE LAYERS THE COMPILER PAYLOAD DID NOT SUPPLY. +// A zero-configuration build resolves all five from one payload, and five lines +// reading `(payload)` carry no information — they are the answer to a question +// nobody asked. What earns a line is a layer that came from somewhere else. +// +// `verbose` prints all five, and DIAGNOSTICS ALWAYS DO: an error must show the +// evidence it rests on, including the parts that are ordinary. +inline std::string format_layers(const TargetSide& ts, bool verbose) { // Thirteen spaces so the layer names sit under the triple rather than under // the status verb: the caller's status line right-aligns a verb in twelve // columns and follows it with one space. constexpr std::string_view kIndent = " "; + std::string out; auto line = [&](std::string_view label, const Layer& l) { - if (l.absent()) - return std::format("{}{:<11} —\n", kIndent, label); + if (!verbose && l.origin == Origin::Payload) return; + if (l.absent()) { + // An absent layer is a statement, so it prints — but only where the + // reader is already being shown the stack. + if (verbose || !out.empty()) + out += std::format("{}{:<17} —\n", kIndent, label); + return; + } std::string suffix = l.subset ? ", subset" : ""; + // One column layout whether or not an implementation is named, so the + // five rows read as a table rather than as five sentences. if (l.impl.empty()) - return std::format("{}{:<11} {} ({}{})\n", kIndent, label, + out += std::format("{}{:<17} {:<14} ({}{})\n", kIndent, label, l.interfaceName, origin_name(l.origin), suffix); - return std::format("{}{:<11} {:<14} ({}, {}{})\n", kIndent, label, - l.interfaceName, l.impl, origin_name(l.origin), suffix); + else + out += std::format("{}{:<17} {:<14} ({}, {}{})\n", kIndent, label, + l.interfaceName, l.impl, origin_name(l.origin), + suffix); }; + line("compiler", ts.compiler); + line("compiler-runtime", ts.compilerRuntime); + line("kernel-abi", ts.kernelAbi); + line("c-abi", ts.cAbi); + line("c++-abi", ts.cxx); + return out; +} +inline std::string format_report(const TargetSide& ts, std::string_view targetName, + bool verbose = false) { // The head carries no verb of its own: the caller supplies one through the // status line's own padding, and the layer lines below are indented to sit // under it. @@ -353,10 +597,9 @@ inline std::string format_report(const TargetSide& ts, std::string_view targetNa ? std::format("{}\n", targetName) : std::format("{} → {}\n", targetName, ts.llvmTriple); - auto body = line("kernel-abi", ts.kernelAbi) - + line("c-abi", ts.cAbi) - + line("c++", ts.cxx); + auto body = format_layers(ts, verbose); if (!body.empty() && body.back() == '\n') body.pop_back(); + if (body.empty()) { head.pop_back(); return head; } return head + body; } diff --git a/src/toolchain/compat.cppm b/src/toolchain/compat.cppm index af0dbb24..7521712a 100644 --- a/src/toolchain/compat.cppm +++ b/src/toolchain/compat.cppm @@ -108,13 +108,15 @@ std::optional normalize_spec(std::string_view compilerIn, out.version = version; // ── canonical families pass through ───────────────────────────────────── - // ⭐ `openkal-llvm` is a canonical family and NOT an alias for `llvm`. The - // two resolve to the same payload, and they answer differently about which - // targets are reachable — see Family::OpenkalLlvm. An alias would collapse - // that difference, which is the whole content of the name. + // ⚠️ `openkal-llvm` NORMALISES TO `llvm`, AND USED TO BE A FAMILY OF ITS + // OWN. It named the same payload and carried a fact about where the TARGET + // SIDE comes from — which `mcpp.targetside` now resolves from what packages + // declare, after the graph exists, where the fact actually lives. The + // spelling is kept so a manifest or config written against it still + // resolves; it is an alias and nothing behaves differently under it. if (compiler == "gcc" || compiler == "llvm" || compiler == "msvc" || compiler == "openkal-llvm") { - out.family = std::string(compiler); + out.family = compiler == "openkal-llvm" ? "llvm" : std::string(compiler); if (muslVersionSuffix && compiler == "gcc") { out.target = host_musl_triple(); return with_hint(std::move(out), diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index d085f9b4..e7c98f8f 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -210,6 +210,23 @@ struct Toolchain { default: return "unknown"; } } + + // ⚠️ THE FAMILY NAME, WHICH IS NOT THE DRIVER'S NAME, AND THEY DIFFER FOR + // EXACTLY ONE FAMILY — THE COMMON ONE. + // + // The driver is `clang`; the family is `llvm`. Everything a user or a + // package WRITES uses the family: `mcpp toolchain default llvm@22.1.8`, + // `[toolchain] default = "llvm@22.1.8"`, `requires = ["mcpp:compiler=llvm"]`. + // Reporting or matching against `clang` would mean a requirement stated in + // the spelling the ecosystem uses could never be satisfied. + std::string_view compiler_family() const { + switch (compiler) { + case CompilerId::GCC: return "gcc"; + case CompilerId::Clang: return "llvm"; + case CompilerId::MSVC: return "msvc"; + default: return "unknown"; + } + } }; struct DetectError { std::string message; }; diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index 2843dc1c..e81953e5 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -28,7 +28,7 @@ import mcpp.toolchain.triple; export namespace mcpp::toolchain { -// `OpenkalLlvm` IS A SPELLING, NOT A COMPILER, AND NO LONGER A DECISION. +// ⚠️ A FAMILY IS A COMPILER, AND `openkal-llvm` WAS NOT ONE. // // It named the same llvm payload as `Llvm` and existed to carry one fact: that // a project's headers, C library, C++ runtime and platform implementation come @@ -40,10 +40,16 @@ export namespace mcpp::toolchain { // have required a second name for the same fact. // // `mcpp.targetside` resolves it per layer, from what packages declare, at the -// point where the graph exists. This member survives so that a manifest -// written against the older spelling still resolves; it behaves in every -// respect as `Llvm`, and nothing branches on it. -enum class Family { Gcc, Llvm, Msvc, OpenkalLlvm }; +// point where the graph exists. The enumerator is therefore gone and the +// SPELLING remains, normalised to `llvm` in `compat.cppm`, so a manifest or a +// config written against it still resolves. +// +// Keeping the enumerator had a visible cost beyond the dead branch: the +// available-toolchain listing enumerates families, so one payload under two +// family names appeared twice — and, since installation is recorded per family, +// the second copy was reported as NOT INSTALLED and offered for installation to +// users who already had it. +enum class Family { Gcc, Llvm, Msvc }; inline std::string_view family_name(Family f) { @@ -51,7 +57,6 @@ inline std::string_view family_name(Family f) { case Family::Gcc: return "gcc"; case Family::Llvm: return "llvm"; case Family::Msvc: return "msvc"; - case Family::OpenkalLlvm: return "openkal-llvm"; } return "?"; } @@ -311,7 +316,6 @@ parse_toolchain_spec(std::string compilerArg, ToolchainSpec spec; if (norm->family == "llvm") spec.family = Family::Llvm; else if (norm->family == "msvc") spec.family = Family::Msvc; - else if (norm->family == "openkal-llvm") spec.family = Family::OpenkalLlvm; else spec.family = Family::Gcc; spec.version = std::move(norm->version); spec.target = std::move(norm->target); @@ -408,8 +412,8 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { pkg.frontendCandidates = {"cl.exe"}; return pkg; } - if (spec.family == Family::Llvm || spec.family == Family::OpenkalLlvm) { - // ⭐ THE SAME PAYLOAD. `openkal-llvm` downloads nothing of its own and + if (spec.family == Family::Llvm) { + // ⭐ ONE PAYLOAD. The `openkal-llvm` spelling normalises to this family and // installs nothing of its own — it is a statement about where the // TARGET SIDE comes from, and the compiler is the llvm payload either // way. A user who has one has both. @@ -516,7 +520,6 @@ std::filesystem::path payload_frontend(const std::filesystem::path& payloadRoot, if (family == Family::Msvc) { // Same resolution the install and build paths use, so the three // cannot disagree about where an msvc payload keeps its compiler. - // (OpenkalLlvm falls through to the generic bin/-shaped resolution // below, which is the llvm payload's shape.) if (auto inst = mcpp::toolchain::msvc::installation_at(payloadRoot, pkg.ximVersion)) @@ -625,10 +628,6 @@ std::vector available_toolchain_indexes() { { "gcc", Family::Gcc }, { "musl-gcc", Family::Gcc }, { mcpp::toolchain::llvm::package_name(), Family::Llvm }, - // The same package, listed a second time under the name that says what - // its targets are. Installing either installs both, which is accurate: - // there is one payload and two ways of asking it a question. - { mcpp::toolchain::llvm::package_name(), Family::OpenkalLlvm }, }; // The Windows-PE gcc payload is host-split at the distribution layer // (§4.3); each host lists the package it would actually install. diff --git a/src/version.cppm b/src/version.cppm index 69401360..f64c92d1 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.24.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.24.2"; } // namespace mcpp diff --git a/tests/e2e/268_target_side_resolution.sh b/tests/e2e/268_target_side_resolution.sh index a537b30c..19fd5150 100755 --- a/tests/e2e/268_target_side_resolution.sh +++ b/tests/e2e/268_target_side_resolution.sh @@ -103,7 +103,9 @@ echo "$out" | grep -q 'graph' || { # ⚠️ AND THE POSITIVE CONTROL ON THE SAME OUTPUT. Without this, the assertions # above would also pass on an engine that printed the same three lines for # every build regardless of what the graph contained. -echo "$out" | grep -q 'c++ *—' || { +# The label is the capability name — `c++-abi`, matching `mcpp:c++-abi` — so +# that the report and the manifest use one vocabulary for one layer. +echo "$out" | grep -q 'c++-abi *—' || { echo "a project with no C++ runtime package must report that layer absent" >&2 echo "$out" >&2; exit 1 } @@ -113,7 +115,12 @@ echo "$out" | grep -q 'c++ *—' || { # The control that makes the block above mean something: remove the capability # line and every layer must fall back to the payload. provider_declares -plain=$("$MCPP" build 2>&1 || true) +# MCPP_VERBOSE, because an ordinary report prints only the layers the compiler +# payload did NOT supply. That suppression is itself the control's subject here: +# with nothing in the graph there is nothing to print, and the assertion below +# needs the full stack to check that the fallback happened rather than that the +# lines merely vanished. +plain=$(MCPP_VERBOSE=1 "$MCPP" build 2>&1 || true) echo "$plain" | grep -q 'fakeos' && { echo "a package that declares no capability must not fill a layer" >&2 echo "$plain" >&2; exit 1 diff --git a/tests/e2e/269_openkal_llvm_spelling_still_resolves.sh b/tests/e2e/269_openkal_llvm_spelling_still_resolves.sh index 313947a8..4f7d41ae 100755 --- a/tests/e2e/269_openkal_llvm_spelling_still_resolves.sh +++ b/tests/e2e/269_openkal_llvm_spelling_still_resolves.sh @@ -68,7 +68,10 @@ old_spelling=$(driver_for "openkal-llvm@22.1.8") # the second would report `graph` somewhere and the first would not. manifest "openkal-llvm@22.1.8" rm -rf target -old_report=$("$MCPP" build 2>&1 | grep -E 'kernel-abi|c-abi' || true) +# MCPP_VERBOSE, because an ordinary report prints only the layers the compiler +# payload did NOT supply — and every layer here is the payload's, which is +# precisely what this assertion is about. +old_report=$(MCPP_VERBOSE=1 "$MCPP" build 2>&1 | grep -E 'kernel-abi|c-abi' || true) echo "$old_report" | grep -q 'graph' && { echo "the toolchain family name must no longer decide where the target side comes from" >&2 echo "$old_report" >&2 diff --git a/tests/e2e/280_target_side_layers.sh b/tests/e2e/280_target_side_layers.sh new file mode 100755 index 00000000..a4d166c1 --- /dev/null +++ b/tests/e2e/280_target_side_layers.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# requires: +# The target side is five layers, and the report shows what is not ordinary. +# +# WHY THIS FILE EXISTS. +# +# A zero-configuration build resolves every layer from one compiler payload. A +# report that prints five lines reading `(payload)` answers a question nobody +# asked, and the lines that matter — a C library or a C++ runtime that came from +# somewhere else — are then read out of a block that always looks the same. +# +# The two assertions below are the two halves of that rule: silence when there +# is nothing to say, and every layer when the reader asks for it. +set -e + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +"$MCPP" new plain > /dev/null +cd plain + +# ── 1. Nothing came from elsewhere, so no layer earns a line ──────────────── +out="$("$MCPP" build 2>&1)" +target_line="$(printf '%s\n' "$out" | grep -E '^\s+Target ' || true)" +[[ -n "$target_line" ]] || { echo "no Target line at all:"; echo "$out"; exit 1; } + +for label in compiler compiler-runtime kernel-abi c-abi c++-abi; do + if printf '%s\n' "$out" | grep -qE "^\s+${label}\s"; then + echo "a zero-configuration build printed the '$label' layer, which came" + echo "from the compiler payload and is therefore not news:" + echo "$out" + exit 1 + fi +done + +# ── 2. Asked for it, every layer is there ────────────────────────────────── +"$MCPP" clean > /dev/null 2>&1 +verbose="$(MCPP_VERBOSE=1 "$MCPP" build 2>&1)" +for label in compiler compiler-runtime kernel-abi c-abi c++-abi; do + printf '%s\n' "$verbose" | grep -qE "^\s+${label}\s" || { + echo "MCPP_VERBOSE did not print the '$label' layer:" + printf '%s\n' "$verbose" | grep -A 8 'Target ' + exit 1 + } +done + +# The compiler layer reports the FAMILY, which is the spelling every toolchain +# spec and every capability uses. `clang` is the driver's name and would make a +# requirement written as `mcpp:compiler=llvm` unsatisfiable. +printf '%s\n' "$verbose" | grep -qE "^\s+compiler\s+(llvm|gcc|msvc)\s" || { + echo "the compiler layer must report a family, not a driver name:" + printf '%s\n' "$verbose" | grep -E '^\s+compiler\s' + exit 1 +} + +echo "OK" diff --git a/tests/e2e/281_target_side_rules.sh b/tests/e2e/281_target_side_rules.sh new file mode 100755 index 00000000..6358c614 --- /dev/null +++ b/tests/e2e/281_target_side_rules.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# requires: gcc +# The two rules that govern the target side, each stated as a refusal. +# +# WHY THIS FILE EXISTS. +# +# Both conditions below used to reach a compiler or a linker. One produced +# +# fatal error: __config: No such file or directory +# +# from a `std` module source the package supplied and gcc could not consume; +# the other silently selected whichever supplier the graph traversal reached +# first and carried the loser's build inputs into the same command line. +# +# A message from a compiler about a target-side combination is a missing +# diagnostic: the engine knows the combination is untenable before it emits any +# command line. These assertions are that the engine says so. +set -e + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +# ── Rule two: an implementation is configured for the layer beneath it ────── +# +# The package declares a requirement the resolved compiler does not meet. It +# supplies no sources, so nothing here depends on the requirement being wrong +# in any particular way — only on it being stated. +mkdir -p runtime app/src +cat > runtime/mcpp.toml <<'TOML' +[package] +namespace = "probe" +name = "needs-llvm" +version = "0.1.0" +provides = ["mcpp:c++-abi=libc++"] +requires = ["mcpp:compiler=llvm"] + +[build] +sources = [] +TOML +cat > app/mcpp.toml <<'TOML' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +needs-llvm = { path = "../runtime" } +TOML +printf 'int main(){ return 0; }\n' > app/src/main.cpp + +cd app +rc=0 +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" || { + echo "the refusal names no next step:"; echo "$out"; exit 1; } +# 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" || { + echo "the refusal omits the layer it rests on:"; echo "$out"; exit 1; } +cd .. + +# ── Rule one: one supplier per layer ──────────────────────────────────────── +# +# A C library is a mutually exclusive choice, not an additive contribution. +# Selecting the wrong one does not fail the link; it produces a program that +# runs and intermittently does not. +mkdir -p a b app2/src +for n in a b; do + cat > "$n/mcpp.toml" < app2/mcpp.toml <<'TOML' +[package] +name = "app2" +version = "0.1.0" + +[dependencies] +libc-a = { path = "../a" } +libc-b = { path = "../b" } +TOML +printf 'int main(){ return 0; }\n' > app2/src/main.cpp + +cd app2 +rc=0 +out="$("$MCPP" build 2>&1)" || rc=$? +[[ "$rc" -ne 0 ]] || { echo "two suppliers of one layer were accepted:"; echo "$out"; exit 1; } +grep -q "two packages supply the c-abi" <<< "$out" || { + echo "the refusal does not name the layer:"; echo "$out"; exit 1; } +for pkg in "libc-a@0.1.0" "libc-b@0.1.0"; do + grep -q "$pkg" <<< "$out" || { + echo "the refusal does not name $pkg:"; echo "$out"; exit 1; } +done + +# ── The compiler is a layer no package may supply ─────────────────────────── +cd .. +mkdir -p c app3/src +cat > c/mcpp.toml <<'TOML' +[package] +namespace = "probe" +name = "claims-compiler" +version = "0.1.0" +provides = ["mcpp:compiler=llvm"] + +[build] +sources = [] +TOML +cat > app3/mcpp.toml <<'TOML' +[package] +name = "app3" +version = "0.1.0" + +[dependencies] +claims-compiler = { path = "../c" } +TOML +printf 'int main(){ return 0; }\n' > app3/src/main.cpp +cd app3 +rc=0 +out="$("$MCPP" build 2>&1)" || rc=$? +[[ "$rc" -ne 0 ]] || { echo "a package was allowed to supply the compiler:"; echo "$out"; exit 1; } +grep -q "not a layer a package can supply" <<< "$out" || { + echo "the refusal does not say why:"; echo "$out"; exit 1; } + +echo "OK" diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp index 52fa7d4c..0766cc75 100644 --- a/tests/unit/test_targetside.cpp +++ b/tests/unit/test_targetside.cpp @@ -254,10 +254,13 @@ TEST(TargetSideReport, AbsentLayersReadAsADashRatherThanBeingOmitted) { in.llvmTriple = "x86_64-none-elf"; in.freestandingTarget = true; in.sysrootDeclaredEmpty = true; - auto text = ts::format_report(ts::resolve(in), "x86_64-none-elf"); - EXPECT_NE(text.find("kernel-abi —"), std::string::npos); - EXPECT_NE(text.find("c-abi —"), std::string::npos); - EXPECT_NE(text.find("c++ —"), std::string::npos); + in.compilerFamily = "llvm"; + auto text = ts::format_report(ts::resolve(in), "x86_64-none-elf", + /*verbose=*/true); + EXPECT_NE(text.find("kernel-abi"), std::string::npos); + EXPECT_NE(text.find("c-abi"), std::string::npos); + EXPECT_NE(text.find("c++-abi"), std::string::npos); + EXPECT_NE(text.find("—"), std::string::npos); } TEST(TargetSideReport, NamesInterfaceAndImplementationSeparately) { @@ -270,3 +273,147 @@ TEST(TargetSideReport, NamesInterfaceAndImplementationSeparately) { "hide why one source reaches four machines"; EXPECT_NE(text.find("graph"), std::string::npos); } + +// ── What earns a line ──────────────────────────────────────────────────────── +// +// A zero-configuration build resolves every layer from one compiler payload, +// and five lines reading `(payload)` answer a question nobody asked. What earns +// a line is a layer that came from somewhere else. +TEST(TargetSideReport, AZeroConfigurationBuildPrintsOnlyTheTarget) { + auto in = payload_linux(); + in.compilerFamily = "llvm"; + in.compilerVersion = "22.1.8"; + auto text = ts::format_report(ts::resolve(in), "x86_64-linux-gnu"); + EXPECT_EQ(text.find('\n'), std::string::npos) + << "every layer came from the payload, so none of them is news:\n" << text; +} + +TEST(TargetSideReport, VerbosePrintsAllFiveIncludingThePayloadOnes) { + auto in = payload_linux(); + in.compilerFamily = "llvm"; + in.compilerVersion = "22.1.8"; + auto text = ts::format_report(ts::resolve(in), "x86_64-linux-gnu", + /*verbose=*/true); + for (auto* label : {"compiler", "compiler-runtime", "kernel-abi", "c-abi", + "c++-abi"}) + EXPECT_NE(text.find(label), std::string::npos) << label; +} + +TEST(TargetSideReport, OnlyTheLayersThatCameFromElsewhereEarnALine) { + auto in = payload_linux(); + in.compilerFamily = "llvm"; + in.cxxAbi = provider("openkal-llvm-runtime", "0.1.1", "libc++"); + auto text = ts::format_report(ts::resolve(in), "x86_64-linux-gnu"); + EXPECT_NE(text.find("c++-abi"), std::string::npos); + EXPECT_EQ(text.find("kernel-abi"), std::string::npos) + << "the kernel interface is the payload's, which is not news"; +} + +// ── The five layers ────────────────────────────────────────────────────────── + +TEST(TargetSideResolve, TheCompilerIsALayerAndItIsAlwaysThePayloads) { + auto in = payload_linux(); + in.compilerFamily = "gcc"; + in.compilerVersion = "16.1.0"; + auto r = ts::resolve(in); + EXPECT_EQ(r.compiler.origin, ts::Origin::Payload); + EXPECT_EQ(r.compiler.interfaceName, "gcc"); + EXPECT_EQ(r.compiler.impl, "16.1.0"); +} + +// The builtins are what a PURE C PROGRAM needs. A package supplies them only +// when the payload's own are wrong for the target; absent from the graph means +// the payload's, not absent. +TEST(TargetSideResolve, TheCompilerRuntimeDefaultsToTheCompilersOwn) { + auto in = payload_linux(); + in.compilerFamily = "llvm"; + auto r = ts::resolve(in); + EXPECT_EQ(r.compilerRuntime.origin, ts::Origin::Payload); + EXPECT_EQ(r.compilerRuntime.interfaceName, "llvm"); + + in.compilerRuntime = provider("openkal-llvm-runtime", "0.1.1", "compiler-rt"); + auto g = ts::resolve(in); + EXPECT_EQ(g.compilerRuntime.origin, ts::Origin::Graph); + EXPECT_EQ(g.compilerRuntime.interfaceName, "compiler-rt"); +} + +TEST(TargetSideCapability, TheCompilerIsALayerNoPackageMaySupply) { + EXPECT_FALSE(ts::layer_is_suppliable_by_package(ts::CapLayer::Compiler)); + for (auto l : {ts::CapLayer::CompilerRuntime, ts::CapLayer::KernelAbi, + ts::CapLayer::CAbi, ts::CapLayer::CxxAbi}) + EXPECT_TRUE(ts::layer_is_suppliable_by_package(l)); +} + +TEST(TargetSideCapability, TheGrammarKnowsAllFiveLayers) { + struct Row { const char* entry; ts::CapLayer layer; const char* iface; }; + for (auto const& row : std::initializer_list{ + {"mcpp:compiler=llvm", ts::CapLayer::Compiler, "llvm"}, + {"mcpp:compiler-runtime=compiler-rt", ts::CapLayer::CompilerRuntime, "compiler-rt"}, + {"mcpp:kernel-abi=openkal", ts::CapLayer::KernelAbi, "openkal"}, + {"mcpp:c-abi=musl", ts::CapLayer::CAbi, "musl"}, + {"mcpp:c++-abi=libc++", ts::CapLayer::CxxAbi, "libc++"}}) { + auto parsed = ts::parse_capability(row.entry); + ASSERT_TRUE(parsed.has_value()) << row.entry; + ASSERT_TRUE(parsed->has_value()) << row.entry; + EXPECT_EQ((*parsed)->layer, row.layer) << row.entry; + EXPECT_EQ((*parsed)->interfaceName, row.iface) << row.entry; + } + // Outside the reserved prefix the feature system owns the name. + auto own = ts::parse_capability("freestanding-allocator"); + ASSERT_TRUE(own.has_value()); + EXPECT_FALSE(own->has_value()); + // Inside it, a misspelling is an error rather than a silently dead check. + EXPECT_FALSE(ts::parse_capability("mcpp:c_abi=musl").has_value()); +} + +// ── Rule two: declared requirements ────────────────────────────────────────── + +TEST(TargetSideRequirements, ARequirementIsCheckedAgainstWhatResolved) { + auto in = payload_linux(); + in.compilerFamily = "gcc"; + in.cxxAbi = provider("acme-runtime", "0.1.0", "libc++"); + auto r = ts::resolve(in); + + std::vector reqs{ + {"acme-runtime@0.1.0", ts::CapLayer::Compiler, "llvm"}}; + 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) + << "a diagnostic that names no next step is a diagnostic the reader " + "must still go and research"; + + in.compilerFamily = "llvm"; + EXPECT_EQ(ts::check_requirements(ts::resolve(in), reqs), std::nullopt); +} + +TEST(TargetSideRequirements, AnUnsuppliedLayerIsNamedAsSuch) { + auto in = payload_linux(); + in.compilerFamily = "llvm"; + in.freestandingTarget = true; + in.sysrootDeclaredEmpty = true; + std::vector reqs{ + {"acme-board@0.1.0", ts::CapLayer::CAbi, "picolibc"}}; + auto why = ts::check_requirements(ts::resolve(in), reqs); + ASSERT_TRUE(why.has_value()); + EXPECT_NE(why->find("nothing supplies that layer"), std::string::npos); + EXPECT_NE(why->find("mcpp:c-abi=picolibc"), std::string::npos); +} + +// ── Rule one: one supplier per layer ───────────────────────────────────────── + +TEST(TargetSideConflict, TwoSuppliersAreNamedTogetherWithHowEachArrived) { + ts::Conflict c; + c.layer = ts::CapLayer::CAbi; + c.first = "mcpplibs/openkal-musl@0.3.3"; + c.firstVia = "a transitive dependency"; + c.second = "acme/tinylibc@0.2.0"; + auto text = ts::format_conflict(c); + EXPECT_NE(text.find("openkal-musl@0.3.3"), std::string::npos); + EXPECT_NE(text.find("tinylibc@0.2.0"), std::string::npos); + EXPECT_NE(text.find("a transitive dependency"), std::string::npos); + EXPECT_NE(text.find("a direct dependency"), std::string::npos); + EXPECT_NE(text.find("choice rather than a contribution"), std::string::npos) + << "the reason matters: choosing wrong does not fail the link, it " + "produces a program that runs and occasionally does not"; +} From 1b7cd8dd9638a295cad69b012fd57e4efff73d3c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:09:02 +0800 Subject: [PATCH 2/6] fix(targetside): whether an absent layer prints must not depend on its row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自查发现:`format_layers` 用「到目前为止有没有打印过东西」来决定一个缺席的层 要不要打印,而这让它依赖**行序**。 裸机构建的 `kernel-abi` 是缺席的,并且排在预制的 `c-abi` 之上,于是: Target riscv64-none-elf c-abi picolibc-riscv (xim:picolibc-riscv@1.8.12, prebuilt) c++-abi — 缺席的 `kernel-abi` 被吞掉了,而下方同样缺席的 `c++-abi` 打印了 —— 两个同为「这一层没有供给者」的陈述,一个在场一个不在场。 「一台裸机没有内核」正是这份报告要说的话之一。改成两遍: 先判定这次是否展示整栈,再逐行输出。 Target riscv64-none-elf kernel-abi — c-abi picolibc-riscv (xim:picolibc-riscv@1.8.12, prebuilt) c++-abi — test: TargetSideReport.AnAbsentLayerAboveAPrintedOneIsStillPrinted; test_targetside 27 passed。 --- src/targetside/model.cppm | 43 +++++++++++++++++++++++----------- tests/unit/test_targetside.cpp | 16 +++++++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/targetside/model.cppm b/src/targetside/model.cppm index cc36b57b..e49cec55 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -559,19 +559,39 @@ inline std::string format_layers(const TargetSide& ts, bool verbose) { // the status verb: the caller's status line right-aligns a verb in twelve // columns and follows it with one space. constexpr std::string_view kIndent = " "; + const std::pair rows[] = { + { "compiler", ts.compiler }, + { "compiler-runtime", ts.compilerRuntime }, + { "kernel-abi", ts.kernelAbi }, + { "c-abi", ts.cAbi }, + { "c++-abi", ts.cxx }, + }; + + // ⚠️ WHETHER THE STACK IS SHOWN AT ALL IS DECIDED BEFORE ANY ROW IS + // WRITTEN, AND THE FIRST VERSION DECIDED IT PER ROW. + // + // An absent layer is a statement rather than a gap, so it belongs in a + // report that is showing the stack and nowhere else. Asking "has anything + // been printed yet" made that depend on ORDER: a bare-metal build has an + // absent `kernel-abi` above a prebuilt `c-abi`, so the statement was + // swallowed while the line below it printed. Two passes, and the question + // is asked once. + const bool showing = verbose || std::any_of( + std::begin(rows), std::end(rows), [](auto const& r) { + return !r.second.absent() && r.second.origin != Origin::Payload; + }); + if (!showing) return {}; + std::string out; - auto line = [&](std::string_view label, const Layer& l) { - if (!verbose && l.origin == Origin::Payload) return; + for (auto const& [label, l] : rows) { + if (!verbose && l.origin == Origin::Payload) continue; if (l.absent()) { - // An absent layer is a statement, so it prints — but only where the - // reader is already being shown the stack. - if (verbose || !out.empty()) - out += std::format("{}{:<17} —\n", kIndent, label); - return; + out += std::format("{}{:<17} —\n", kIndent, label); + continue; } std::string suffix = l.subset ? ", subset" : ""; // One column layout whether or not an implementation is named, so the - // five rows read as a table rather than as five sentences. + // rows read as a table rather than as a list of sentences. if (l.impl.empty()) out += std::format("{}{:<17} {:<14} ({}{})\n", kIndent, label, l.interfaceName, origin_name(l.origin), suffix); @@ -579,12 +599,7 @@ inline std::string format_layers(const TargetSide& ts, bool verbose) { out += std::format("{}{:<17} {:<14} ({}, {}{})\n", kIndent, label, l.interfaceName, l.impl, origin_name(l.origin), suffix); - }; - line("compiler", ts.compiler); - line("compiler-runtime", ts.compilerRuntime); - line("kernel-abi", ts.kernelAbi); - line("c-abi", ts.cAbi); - line("c++-abi", ts.cxx); + } return out; } diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp index 0766cc75..288b7bfa 100644 --- a/tests/unit/test_targetside.cpp +++ b/tests/unit/test_targetside.cpp @@ -299,6 +299,22 @@ TEST(TargetSideReport, VerbosePrintsAllFiveIncludingThePayloadOnes) { EXPECT_NE(text.find(label), std::string::npos) << label; } +// An absent layer is a statement, and whether the stack is shown at all must not +// depend on where that statement sits among the rows. A bare-metal build has an +// absent kernel interface ABOVE a prebuilt C library; deciding per row swallowed +// the first and printed the second. +TEST(TargetSideReport, AnAbsentLayerAboveAPrintedOneIsStillPrinted) { + ts::Inputs in; + in.llvmTriple = "riscv64-none-elf"; + in.freestandingTarget = true; + in.compilerFamily = "llvm"; + in.sysrootXpkg = "xim:picolibc-riscv@1.8.12"; + auto text = ts::format_report(ts::resolve(in), "riscv64-none-elf"); + EXPECT_NE(text.find("c-abi"), std::string::npos) << text; + EXPECT_NE(text.find("kernel-abi"), std::string::npos) + << "the kernel interface is absent, which is the information:\n" << text; +} + TEST(TargetSideReport, OnlyTheLayersThatCameFromElsewhereEarnALine) { auto in = payload_linux(); in.compilerFamily = "llvm"; From 369cabefa7472e36bc7fe625f98fc573966c4daa Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:10:26 +0800 Subject: [PATCH 3/6] docs(changelog): 2026.8.24.2 --- CHANGELOG.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c15fa6a8..fdd4cd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,82 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.24.2] — 2026-08-24 + +### 新增 + +- **目标侧是五层,而其中最大的一层此前没有名字。** + + 实测 `openkal-llvm-runtime` 编译的 729 个对象里,**498 个是 compiler-rt 的 + builtins**、21 个是 libunwind 的。这个包最大的一块此前声明在 `mcpp:c++-abi` + 名下 —— 而 `__udivti3` 及其同类是一个**纯 C 程序**需要的东西,与 C++ 无关。 + + ⚠️ 把 builtins 算作 C++ 运行时的一部分,与 `targetside` 模块开头记录的缺陷同形: + 一个交叉到 macOS 的 C 程序被问「有没有 C++ 运行时」,答「没有」,链接行因而 + 保留了载荷自带的 libc++。**一个只有部分程序需要的层仍然是层。** + + 五层为 `compiler` / `compiler-runtime` / `kernel-abi` / `c-abi` / `c++-abi`。 + `compiler` 是唯一一个包不能供给的层 —— 族与族之间的差异(flag 拼写、模块模型、 + BMI 格式、驱动 cfg)是引擎必须持有的事实,不是数据能描述的。 + + ⚠️ `compiler` 层上报**族名**(`llvm`)而非驱动名(`clang`):使用者书写的每一处 + 都用族名,报告用驱动名会让 `requires = ["mcpp:compiler=llvm"]` 永远不可满足。 + +- **`requires = ["mcpp:<层>=<实现>"]` —— 在引擎里不出现实现名的前提下执行分层规则。** + + libc++ 的源码由 clang 编译;递给 gcc 的实测结果是 + `fatal error: __config: No such file or directory` —— 一个命名了读者从未打开过的 + 文件、且不提任何 mcpp 决定的消息。在引擎里写 + `if (stdlib == "libc++" && compiler == gcc)` 会把两个实现名放进引擎; + 写在包里,引擎只需检查一条它能一般性陈述的关系。 + + ⚠️ 检查在**编译开始之前**运行,这正是声明它的全部意义。 + +- **规则一:每层恰好一个供给者。** + + ⚠️ 此前两个包供给同一层时,**图遍历顺序里第一个静默胜出** —— 那个顺序既不是 + 作者写的,也不是他能预测的 —— 而落选者的 `[build]` 段仍然进入命令行。 + 判据是失败模态:选错不会让链接失败,会得到一个能跑、偶尔崩的程序。 + `[build] runner` 早已按同一条规则处理。 + +- **`docs/14-target-side.md`(中英)与 `docs/spec/target-side.md`(SPEC-002)。** + +### 变更 + +- **`std-module` / `std-compat-module` / `std-module-flags` 移入 `[build]`。** + + 模块源是这个包的一个翻译单元 —— 引擎自己的注释早就这么写。放在 `[package]` 下 + 损失的恰恰是位置所决定的那件事:`[build]` 可条件化而 `[package]` 不可,于是 + 一个在多种 C 库之上供给同一 C++ 运行时的包无法为不同 C 库给出不同 flag。 + `-D_GNU_SOURCE` 对 musl 与 glibc 是对的,对 picolibc 是错的。 + `[package]` 写法保留为别名。 + +- **报告按需暴露。** 零配置构建的五个层全部来自同一份载荷,五行 `(payload)` + 回答的是无人提出的问题。默认只列出来源不是编译器载荷的层;`MCPP_VERBOSE=1` + 列出全部;**诊断始终列出它所依据的每一层**。 + +- **`Family` 去掉 `OpenkalLlvm`,拼写归一为 `llvm`。** + + ⚠️ 保留枚举项的代价不止一条死分支:可用工具链列表按族枚举,一份载荷挂在两个 + 族名下就出现两次,而安装状态按族记录 ⇒ **第二份被报成未安装,并被推荐给已经 + 装了它的人。** + +- **词表 pin 替换用户默认时,状态行说出替换与修法;图已知后指出这次替换本可不必。** + + ⚠️ 让全局默认压过词表 pin 的做法被实测否掉:一个无依赖的工程、全局默认 + `llvm@22.1.8`、`--target x86_64-windows-gnu`,**从能构建变成不能构建**。 + 行所 pin 的不是「偏好的编译器」而是「供给该目标 C 库的载荷」。 + 结构性修法(把 pin 的决定与目标侧一样后移)未在本版落地:`tc` 在解析后到图之间 + 被读写 39 处。 + +### 兼容性 + +- `hosted-standard-library` 继续表示 C++ 层; +- `openkal-llvm` 拼写继续解析; +- `[package]` 下三个 std-module 键继续被接受; +- ⚠️ 实测:**旧引擎(2026.8.24.1)读带 `requires` 的清单构建成功** —— + TOML 侧忽略未知键,xpkg 侧警告而非报错。已发布的包因此可以先行声明。 + ## [2026.8.20.2] — 2026-08-20 ### 新增 From 2e9fc304e78dbc345ba384dfe0566d90dc35427d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:17:14 +0800 Subject: [PATCH 4/6] fix(manifest): an unknown layer name from a dependency is a version gap, not a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 这条由生态 CI 实测暴露,而它让层名词表**对已发布的包永远不可扩展**。 `openkal-llvm-runtime` 声明本次新命名的那一层,被上一版引擎读到: error: dependency 'openkal-llvm-runtime': mcpp.toml: error: `provides = ["mcpp:compiler-runtime=compiler-rt"]` names no capability mcpp knows. 保留前缀是闭集,为的是让**拼写错误成为错误而不是一个被静默禁用的行为**。 在解析期直接拒绝,让这个闭集在第二个、没人打算要的意义上也闭上了: 一个声明了「读者发布之后才被命名的层」的包,**整份清单加载不了**。 ── 谁的清单决定答案 ────────────────────────────────────── * **根工程**的清单里出现未知层名 ⇒ **错误**。那是作者自己的拼写, 而他正看着这次构建。 * **依赖**的清单里出现未知层名 ⇒ **警告并忽略该层**。那份清单是对着一个更新的 引擎写的,而「忽略未知的并说出来」正是本引擎对其它每一种未知键已有的做法 (`warn_unknown_xpkg_keys` 的注释:should not fail outright, only tell the user what it ignored)。 ⚠️ 警告放在扫描**全部包**的那个循环里,不放在 `warn_unknown_xpkg_keys`: 后者只走到经索引解析的依赖,而 path / git 依赖自带清单,先前一条警告都收不到。 ⚠️ 这条规定只对**此后的**引擎生效。一个包若要声明某个层名,其使用者的引擎仍须 不早于该层名被引入的版本 —— 本次修的是「从今往后可扩展」,不是追溯。 test: e2e 281 增两格(依赖声明未知层 ⇒ 构建成功且点名被忽略的层; 根工程拼错 ⇒ 报错并列出五个层名);unit 93 passed。 spec: SPEC-002 §5.1 拆成两条;docs/14 中英同步。 --- docs/14-target-side.md | 12 +++++-- docs/spec/target-side.md | 21 +++++++++-- docs/zh/14-target-side.md | 9 +++-- src/build/prepare.cppm | 53 ++++++++++++++++++++++++--- src/manifest/toml.cppm | 4 +-- src/manifest/types.cppm | 23 ++++++++++++ tests/e2e/281_target_side_rules.sh | 58 ++++++++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 13 deletions(-) diff --git a/docs/14-target-side.md b/docs/14-target-side.md index d0014de5..1dd0fc3e 100644 --- a/docs/14-target-side.md +++ b/docs/14-target-side.md @@ -288,6 +288,12 @@ The toolchain family spelling `openkal-llvm` normalises to `llvm`. It named the same payload and carried a fact about the target side, which the model above resolves from what packages declare. -An unknown name inside the reserved prefix is an error; an unknown key elsewhere -in a manifest is ignored. A published package therefore continues to load under -an engine predating a key it carries. +An unknown name inside the reserved prefix is an error in the root project's own +manifest and a warning in a dependency's. The first is a misspelling the author +is looking at; the second is a manifest written against a newer engine, and +refusing it would mean the layer vocabulary could never be extended by a +published package. An unknown key elsewhere in a manifest is ignored. + +That provision governs future engines only. A package declaring a layer name +still requires its consumers to run an engine no older than the release that +introduced the name. diff --git a/docs/spec/target-side.md b/docs/spec/target-side.md index 81081609..5445c020 100644 --- a/docs/spec/target-side.md +++ b/docs/spec/target-side.md @@ -62,7 +62,7 @@ mcpp:<层名>[=<实现名>] ``` -`<层名>` **必须**属于 §1.1 的闭集;不属于时引擎**必须**报错。 +`<层名>` **必须**属于 §1.1 的闭集;不属于时的处置见 §5.1(根工程报错、依赖警告)。 不以 `mcpp:` 开头的条目属于特性系统,引擎**必须**原样透传。 省略 `=<实现名>` 时,该层的实现名取包名。 @@ -159,9 +159,26 @@ mcpp:<层名>[=<实现名>] ### 5.1 未知名字 ✅ 已实现 -`mcpp:` 前缀内的未知层名,引擎**必须**报错。 +⚠️ **谁的清单决定答案。** + +- 出现在**根工程**清单中的未知层名,引擎**必须**报错。 + 这是作者自己的拼写,而他正看着这次构建。 +- 出现在**依赖**清单中的未知层名,引擎**禁止**据此使构建失败; + **必须**警告并忽略该层。该清单是对着一个更新的引擎写的。 + 清单中其它位置的未知键,引擎**禁止**据此使整份清单加载失败;**应当**警告并忽略。 +⚠️ 第二条来自一次实测。在其落地之前,一个声明了新层名的包 +**在该层被命名之前发布的每一个引擎上都无法加载**,因此层名词表对已发布的包 +永远不可扩展: + + error: dependency 'openkal-llvm-runtime': mcpp.toml: error: + `provides = ["mcpp:compiler-runtime=compiler-rt"]` names no + capability mcpp knows. + +⚠️ 这条规定只在**未来**的引擎上生效。一个包若要声明某个层名, +其使用者的引擎仍须不早于该层名被引入的版本。 + ### 5.2 既有拼写 ✅ 已实现 - 能力名 `hosted-standard-library` **必须**继续表示 `c++-abi` 层; diff --git a/docs/zh/14-target-side.md b/docs/zh/14-target-side.md index 4d92fbc4..29c0133e 100644 --- a/docs/zh/14-target-side.md +++ b/docs/zh/14-target-side.md @@ -245,5 +245,10 @@ include_dirs = ["config/picolibc"] 工具链族拼写 `openkal-llvm` 归一为 `llvm`。它命名同一份载荷, 并携带一条关于目标侧的事实,而上述模型从包的声明中解析该事实。 -保留前缀内的未知名字是错误;清单中其它位置的未知键被忽略。 -因此一个已发布的包在早于其所携带的键的引擎下仍能加载。 +保留前缀内的未知名字,在根工程自己的清单中是错误,在依赖的清单中是警告。 +前者是作者正看着的一处拼写错误;后者是一份对着更新引擎写成的清单, +拒绝它将意味着层名词表永远不能被一个已发布的包扩展。 +清单中其它位置的未知键被忽略。 + +该规定只约束此后的引擎。一个包若声明某个层名, +其使用者仍须运行不早于该层名被引入的那个版本。 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 99e6b762..e834a81d 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -99,6 +99,22 @@ namespace mcpp::build { // should not fail outright, only tell the user what it ignored. inline void warn_unknown_xpkg_keys(const mcpp::manifest::Manifest& dm, std::string_view depLabel) { + // ⚠️ A LAYER NAME THIS ENGINE DOES NOT KNOW IS A VERSION GAP, NOT A TYPO, + // WHEN IT ARRIVES FROM A DEPENDENCY. + // + // The reserved `mcpp:` prefix is a closed set so a misspelling cannot + // silently disable a behaviour. Refusing a DEPENDENCY's manifest for it made + // the set closed in a second sense nobody intended: a published package + // could never declare a layer named after the reader was released. + // Ignoring the layer and saying so is what this engine already does for + // every other unknown key, and it is the only response that lets the + // vocabulary grow. + for (auto const& cap : dm.unknownCapabilities) { + mcpp::ui::warning(std::format( + "dependency '{}': `{}` names a target-side layer this mcpp does not " + "know — ignored. A newer mcpp may resolve it; this build proceeds " + "without that layer.", depLabel, cap)); + } for (auto const& key : dm.xpkgUnknownKeys) { auto suggestion = mcpp::manifest::closest_known_xpkg_key(key); if (suggestion.empty()) @@ -923,6 +939,21 @@ prepare_build(bool print_fingerprint, : mcpp::manifest::load(*root / "mcpp.toml"); if (!m) return std::unexpected(m.error().format()); + // ⚠️ AND ONLY FOR THE ROOT. A layer name this engine does not know is a + // typo in the manifest the author is looking at, and a version gap in a + // dependency's. The reserved `mcpp:` prefix exists so the first is an error + // rather than a silently disabled behaviour; refusing the second as well + // meant the layer vocabulary could never be extended by a published package + // (`warn_unknown_xpkg_keys` carries that half). + if (!m->unknownCapabilities.empty()) { + auto const& cap = m->unknownCapabilities.front(); + auto why = mcpp::targetside::parse_capability(cap); + return std::unexpected(std::format( + "{}: {}", (*root / "mcpp.toml").string(), + why ? std::format("`{}` names no capability mcpp knows.", cap) + : why.error())); + } + // A DISTRIBUTION package is not a source tree, and building "in" one is a // failure that looks like a success: `interface/` holds declarations whose // definitions are in the prebuilt archive, so the build compiles the @@ -5563,6 +5594,20 @@ prepare_build(bool print_fingerprint, : std::format("{}@{}", pkg.manifest.package.name, pkg.manifest.package.version); + // ⚠️ EVERY PACKAGE KIND, NOT ONLY THE ONES WITH AN XPKG + // DESCRIPTOR. `warn_unknown_xpkg_keys` reaches a dependency + // resolved through the index; a path or git dependency carries a + // manifest of its own and reached no warning at all, so a layer + // this engine does not know went by in silence. This loop sees + // every package in the graph. + for (auto const& cap : pkg.manifest.unknownCapabilities) { + if (&pkg == &packages.front()) continue; // root: already refused + mcpp::ui::warning(std::format( + "package '{}': `{}` names a target-side layer this mcpp does " + "not know — ignored. A newer mcpp may resolve it; this build " + "proceeds without that layer.", pkgId, cap)); + } + for (auto const& entry : pkg.manifest.provides) { std::optional decl; if (auto parsed = tsd::parse_capability(entry); parsed && *parsed) @@ -5610,10 +5655,10 @@ prepare_build(bool print_fingerprint, // one is: a typo would otherwise disable a check silently. for (auto const& entry : pkg.manifest.requires_) { auto parsed = tsd::parse_capability(entry); - if (!parsed) - return std::unexpected(std::format( - "package '{}': {}", pkgId, parsed.error())); - if (!*parsed) continue; // not in mcpp's namespace + // An unknown layer name is reported where the manifest was + // read — as an error for the root and a warning for a + // dependency — so it is skipped rather than refused twice. + if (!parsed || !*parsed) continue; requirements.push_back({ pkgId, (*parsed)->layer, (*parsed)->interfaceName }); } diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 3f672342..64206b97 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -471,7 +471,7 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string_array("package.provides")) { for (auto const& entry : *v) if (auto cap = mcpp::targetside::parse_capability(entry); !cap) - return std::unexpected(error(origin, cap.error())); + m.unknownCapabilities.push_back(entry); m.provides = *v; } // [package] requires — validated exactly like `provides`: names under the @@ -479,7 +479,7 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string_array("package.requires")) { for (auto const& entry : *v) if (auto cap = mcpp::targetside::parse_capability(entry); !cap) - return std::unexpected(error(origin, cap.error())); + m.unknownCapabilities.push_back(entry); m.requires_ = *v; } // std-module / std-compat-module / std-module-flags. diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index c8b61551..02809261 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -890,6 +890,29 @@ struct Manifest { // schema evolution is loud in lint instead of invisible. std::vector xpkgUnknownKeys; + // ⚠️ CAPABILITY NAMES INSIDE THE RESERVED `mcpp:` PREFIX THAT THIS ENGINE + // DOES NOT KNOW, AND WHY THEY ARE RECORDED RATHER THAN REFUSED HERE. + // + // The reserved prefix is a closed set so that a misspelled layer name is an + // error instead of a silently disabled behaviour. Refusing at PARSE time + // made the set closed in a second, unintended sense: a package declaring a + // layer added after the reader was released failed to load AT ALL, so the + // vocabulary could never be extended by a published package. + // + // Measured 2026-08-24, `openkal-llvm-runtime` declaring the newly named + // compiler-runtime layer, read by the release before it: + // + // error: dependency 'openkal-llvm-runtime': mcpp.toml: error: + // `provides = ["mcpp:compiler-runtime=compiler-rt"]` names no + // capability mcpp knows. + // + // Whose manifest it is decides the answer. A name in the ROOT project's own + // manifest is the author's to fix and they are looking at the build — an + // error. A name in a DEPENDENCY's manifest was written against a newer + // engine, and the correct response is to ignore the layer and say so, which + // is what this engine already does for every other unknown key. + std::vector unknownCapabilities; + Package package; Language language; Modules modules; diff --git a/tests/e2e/281_target_side_rules.sh b/tests/e2e/281_target_side_rules.sh index 6358c614..53a61fa7 100755 --- a/tests/e2e/281_target_side_rules.sh +++ b/tests/e2e/281_target_side_rules.sh @@ -131,4 +131,62 @@ out="$("$MCPP" build 2>&1)" || rc=$? grep -q "not a layer a package can supply" <<< "$out" || { echo "the refusal does not say why:"; echo "$out"; exit 1; } +# ── The layer vocabulary must be extensible by a published package ────────── +# +# ⚠️ This one comes from a measurement, and the behaviour it replaces made the +# vocabulary permanently un-extendable: +# +# error: dependency 'openkal-llvm-runtime': mcpp.toml: error: +# `provides = ["mcpp:compiler-runtime=compiler-rt"]` names no +# capability mcpp knows. +# +# A package declaring a newly named layer failed to load under every engine +# released before that layer was named. Whose manifest it is decides the +# answer: the root's is a misspelling the author is looking at, a dependency's +# is a manifest written against a newer engine. +cd .. +mkdir -p future app4/src +cat > future/mcpp.toml <<'TOML' +[package] +namespace = "probe" +name = "from-the-future" +version = "0.1.0" +provides = ["mcpp:quantum-abi=spooky"] + +[build] +sources = [] +TOML +cat > app4/mcpp.toml <<'TOML' +[package] +name = "app4" +version = "0.1.0" + +[dependencies] +from-the-future = { path = "../future" } +TOML +printf 'int main(){ return 0; }\n' > app4/src/main.cpp +cd app4 +rc=0 +out="$("$MCPP" build 2>&1)" || rc=$? +[[ "$rc" -eq 0 ]] || { + echo "a dependency naming a layer this engine does not know must not fail the build:" + echo "$out"; exit 1; } +grep -q "quantum-abi" <<< "$out" || { + echo "the ignored layer must be named:"; echo "$out"; exit 1; } + +# And the same name in the ROOT's own manifest is the author's to fix. +cat > mcpp.toml <<'TOML' +[package] +name = "app4" +version = "0.1.0" +provides = ["mcpp:quantum-abi=spooky"] +TOML +rc=0 +out="$("$MCPP" build 2>&1)" || rc=$? +[[ "$rc" -ne 0 ]] || { + echo "a misspelled layer name in the root manifest must be an error:" + echo "$out"; exit 1; } +grep -q "names no capability mcpp knows" <<< "$out" || { + echo "the refusal does not say what is wrong:"; echo "$out"; exit 1; } + echo "OK" From 10d779b5a56adc24ad1a754c7e3b751038284e87 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:28:21 +0800 Subject: [PATCH 5/6] fix(diag): the warning must be as useful as the error it replaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生态 e2e 暴露两处: 1. 警告只说了「不认识」,没有列出**存在的**层名 —— 而它替代的那条错误列了。 一条比它所替代的错误说得更少的警告,是一条穿着更轻处罚的更差诊断。 两处警告(依赖清单、任意包)改为复用 `parse_capability` 的原文。 2. e2e 268 的断言写的是**严重级别**,而这个前缀存在的理由是**不静默**。 ⚠️ 但一条断言若跟着策略改就该说清它现在测什么:改为断言三件事 —— 拼错的名字被报出、被拼错的那一层**没有被填上**、以及旁边拼对的那一条 **仍然解析**(一个坏条目只该花掉一层,不是整个包)。 删掉的那条「必须在编译开始之前被拒绝」不再成立,而它成立的地方仍被守着: 根工程自己的拼写错误与 `requires` 不满足,都在 e2e 281 里,都在编译之前。 unit 93 passed;e2e 268/269/280/281 全绿。 --- src/build/prepare.cppm | 24 +++++++++++---- tests/e2e/268_target_side_resolution.sh | 40 ++++++++++++++++++------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index e834a81d..88097f4c 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -110,10 +110,14 @@ inline void warn_unknown_xpkg_keys(const mcpp::manifest::Manifest& dm, // every other unknown key, and it is the only response that lets the // vocabulary grow. for (auto const& cap : dm.unknownCapabilities) { + auto why = mcpp::targetside::parse_capability(cap); mcpp::ui::warning(std::format( - "dependency '{}': `{}` names a target-side layer this mcpp does not " - "know — ignored. A newer mcpp may resolve it; this build proceeds " - "without that layer.", depLabel, cap)); + "dependency '{}': {}\n" + " Ignored, and this build proceeds without that layer. " + "A newer mcpp may resolve it.", + depLabel, + why ? std::format("`{}` names no capability mcpp knows.", cap) + : why.error())); } for (auto const& key : dm.xpkgUnknownKeys) { auto suggestion = mcpp::manifest::closest_known_xpkg_key(key); @@ -5602,10 +5606,18 @@ prepare_build(bool print_fingerprint, // every package in the graph. for (auto const& cap : pkg.manifest.unknownCapabilities) { if (&pkg == &packages.front()) continue; // root: already refused + // The same text the root's refusal carries, including the list + // of layers that do exist. A warning that says less than the + // error it replaced would be a worse diagnostic wearing a + // milder severity. + auto why = tsd::parse_capability(cap); mcpp::ui::warning(std::format( - "package '{}': `{}` names a target-side layer this mcpp does " - "not know — ignored. A newer mcpp may resolve it; this build " - "proceeds without that layer.", pkgId, cap)); + "package '{}': {}\n" + " Ignored, and this build proceeds without that layer. " + "A newer mcpp may resolve it.", + pkgId, + why ? std::format("`{}` names no capability mcpp knows.", cap) + : why.error())); } for (auto const& entry : pkg.manifest.provides) { diff --git a/tests/e2e/268_target_side_resolution.sh b/tests/e2e/268_target_side_resolution.sh index 19fd5150..b752126e 100755 --- a/tests/e2e/268_target_side_resolution.sh +++ b/tests/e2e/268_target_side_resolution.sh @@ -130,25 +130,43 @@ echo "$plain" | grep -qE 'kernel-abi .*payload' || { echo "$plain" >&2; exit 1 } -# ── A misspelling inside mcpp's reserved namespace is an error ────────────── +# ── A name inside mcpp's reserved namespace is never silent ───────────────── # -# This is the whole reason the prefix exists. An unvalidated capability array -# turns one wrong letter into a behaviour that silently does not happen, and -# the build still reports success. +# This is the whole reason the prefix exists: an unvalidated capability array +# turns one wrong letter into a behaviour that silently does not happen while +# the build reports success. +# +# ⚠️ IN A DEPENDENCY IT IS A WARNING RATHER THAN AN ERROR, AND THE CHANGE WAS +# FORCED BY A MEASUREMENT. mcpp cannot distinguish a misspelling from a layer +# named after this build tool was released, and the two mistakes cost different +# amounts. Refusing meant a package declaring a NEW layer failed to load under +# every engine released before it — the vocabulary could never be extended by a +# published package, permanently. Warning means a dependency's typo costs a +# missing layer the consumer is told about, and cannot fix anyway. +# +# What this file asserts is therefore the property the prefix was introduced +# for — the name is reported — not the severity. The root project's own +# manifest still errors; e2e 281 covers that half. provider_declares "mcpp:kernel_abi=fakeos" "mcpp:c-abi=fakelibc" bad=$("$MCPP" build 2>&1 || true) -echo "$bad" | grep -q "names no capability mcpp knows" || { - echo "a misspelled capability in the mcpp: namespace must fail the build" >&2 +echo "$bad" | grep -q "kernel_abi" || { + echo "a misspelled capability in the mcpp: namespace must be reported" >&2 echo "$bad" >&2; exit 1 } echo "$bad" | grep -q 'mcpp:kernel-abi' || { - echo "the diagnostic must list the layer names that do exist" >&2 + echo "the report must list the layer names that do exist" >&2 + echo "$bad" >&2; exit 1 +} +# And the misspelled layer must be UNFILLED — reporting the name and then +# resolving the layer anyway would be the worst of both. +echo "$bad" | grep -qE 'kernel-abi +fakeos' && { + echo "a name this engine does not know must not fill a layer" >&2 echo "$bad" >&2; exit 1 } -# And it must fail BEFORE anything is compiled: a manifest this engine cannot -# read is not a build that got far enough to have a link. -echo "$bad" | grep -q 'Compiling' && { - echo "the manifest must be rejected before compilation begins" >&2 +# The layer beside it, spelled correctly, still resolves: one bad entry costs +# one layer and not the package. +echo "$bad" | grep -qE 'c-abi +fakelibc' || { + echo "a correctly spelled capability beside a misspelled one must still resolve" >&2 echo "$bad" >&2; exit 1 } From 3c45cfed32ce01e85d2b8d53bc1ba914f6bc7899 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:47:10 +0800 Subject: [PATCH 6/6] fix(targetside): name the payload's C library for its own platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS CI 报出两件事,一件是我的测试写错,一件是这份报告刚刚让一个既有缺陷 变得可见。 ── 1. `c-abi glibc (payload)`,在 macOS 上 ──────────────── `resolve` 对没有 env 段的三元组回落到字面量 `glibc`,而 macOS 的规范三元组 正是没有 env 段的。这条一直在,但此前报告只打印「这次构建有话要说」的那几层, 于是一个错误的标签从未被打印出来;把整栈显示出来,错标签就成了错陈述。 c-abi glibc (payload) ← macOS 改为按平台取名:macOS `libSystem`、Windows `ucrt`、其余 `glibc`; 三元组自己说了 env 的仍以它为准。 ⚠️ 这几个名字可以写在引擎里,而包供给的实现名不可以 —— 区别在于载荷是 mcpp 自己分发的,它知道里面装着什么。保留能力语法存在的理由正是守住这条界线。 ── 2. `c++-abi` 在 BSD grep 的 ERE 里是非法的 ──────────── grep: repetition-operator operand invalid `c++` 在扩展正则里是「重复算子作用于重复算子」,GNU grep 容忍,BSD grep 直接 拒绝。e2e 280 的层名循环改用 `grep -F` 定长匹配。 test: TargetSideResolve.ThePayloadCLibraryIsNamedForItsPlatform; unit 93 passed;本机全量 e2e 288 pass / 26 fail,失败集与基线逐条相同。 --- src/targetside/model.cppm | 25 ++++++++++++++++++++++++- tests/e2e/280_target_side_layers.sh | 12 ++++++++---- tests/unit/test_targetside.cpp | 19 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/targetside/model.cppm b/src/targetside/model.cppm index e49cec55..f16a751c 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -320,6 +320,29 @@ struct Inputs { std::string payloadCxxInterface; // "libc++" / "libstdc++" / "MSVC STL" }; +// The name of the C library a compiler payload carries for a target. +// +// ⚠️ THE TRIPLE'S ENV FIELD ANSWERS THIS ONLY WHERE THE TRIPLE HAS ONE, AND +// FALLING BACK TO `glibc` NAMED A LIBRARY THAT DOES NOT EXIST ON THE PLATFORM. +// Measured on macOS, where the canonical triple carries no env segment: +// +// c-abi glibc (payload) +// +// The value was invisible while the report printed only three layers on a +// build that had something to say; showing the whole stack made a wrong label +// into a wrong statement. +// +// ⚠️ These names are PAYLOAD facts, which is why they may be written here at +// all: mcpp ships those payloads and knows what is inside them. What must never +// be written here is what a PACKAGE supplies — that is the difference the +// reserved-capability grammar exists to keep. +inline std::string payload_libc_name(std::string_view os, std::string_view env) { + if (!env.empty()) return std::string(env); + if (os == "macos") return "libSystem"; + if (os == "windows") return "ucrt"; + return "glibc"; +} + // An xpkg reference is `:[@]`; the interface a reader // wants to see is the name, not the whole address. inline std::string xpkg_interface(std::string_view ref) { @@ -376,7 +399,7 @@ inline TargetSide resolve(const Inputs& in) { else if (in.freestandingTarget) ts.cAbi = { Origin::None, {}, {}, false }; else - ts.cAbi = { Origin::Payload, in.targetEnv.empty() ? "glibc" : in.targetEnv, + ts.cAbi = { Origin::Payload, payload_libc_name(in.targetOs, in.targetEnv), in.payloadLibcRef, false }; // c++ — no field of the triple, because it sits above the ABI. diff --git a/tests/e2e/280_target_side_layers.sh b/tests/e2e/280_target_side_layers.sh index a4d166c1..ad82af8b 100755 --- a/tests/e2e/280_target_side_layers.sh +++ b/tests/e2e/280_target_side_layers.sh @@ -25,8 +25,12 @@ out="$("$MCPP" build 2>&1)" target_line="$(printf '%s\n' "$out" | grep -E '^\s+Target ' || true)" [[ -n "$target_line" ]] || { echo "no Target line at all:"; echo "$out"; exit 1; } +# ⚠️ The labels go through `grep -F`, not a pattern. `c++-abi` in an extended +# regular expression is a repetition operator applied to a repetition operator, +# which GNU grep tolerates and BSD grep rejects outright — measured on macOS: +# grep: repetition-operator operand invalid for label in compiler compiler-runtime kernel-abi c-abi c++-abi; do - if printf '%s\n' "$out" | grep -qE "^\s+${label}\s"; then + if printf '%s\n' "$out" | grep -qF " ${label} "; then echo "a zero-configuration build printed the '$label' layer, which came" echo "from the compiler payload and is therefore not news:" echo "$out" @@ -38,7 +42,7 @@ done "$MCPP" clean > /dev/null 2>&1 verbose="$(MCPP_VERBOSE=1 "$MCPP" build 2>&1)" for label in compiler compiler-runtime kernel-abi c-abi c++-abi; do - printf '%s\n' "$verbose" | grep -qE "^\s+${label}\s" || { + printf '%s\n' "$verbose" | grep -qF " ${label} " || { echo "MCPP_VERBOSE did not print the '$label' layer:" printf '%s\n' "$verbose" | grep -A 8 'Target ' exit 1 @@ -48,9 +52,9 @@ done # The compiler layer reports the FAMILY, which is the spelling every toolchain # spec and every capability uses. `clang` is the driver's name and would make a # requirement written as `mcpp:compiler=llvm` unsatisfiable. -printf '%s\n' "$verbose" | grep -qE "^\s+compiler\s+(llvm|gcc|msvc)\s" || { +printf '%s\n' "$verbose" | grep -qE "compiler +(llvm|gcc|msvc) " || { echo "the compiler layer must report a family, not a driver name:" - printf '%s\n' "$verbose" | grep -E '^\s+compiler\s' + printf '%s\n' "$verbose" | grep -F " compiler " exit 1 } diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp index 288b7bfa..cfc7b4e2 100644 --- a/tests/unit/test_targetside.cpp +++ b/tests/unit/test_targetside.cpp @@ -327,6 +327,25 @@ TEST(TargetSideReport, OnlyTheLayersThatCameFromElsewhereEarnALine) { // ── The five layers ────────────────────────────────────────────────────────── +// A payload's C library is named by the triple's env field where the triple has +// one. macOS has none, and falling back to `glibc` named a library that does not +// exist on the platform — invisible while the report printed only the layers a +// build had something to say about. +TEST(TargetSideResolve, ThePayloadCLibraryIsNamedForItsPlatform) { + auto mac = payload_linux(); + mac.targetOs = "macos"; + mac.targetEnv = ""; + EXPECT_EQ(ts::resolve(mac).cAbi.interfaceName, "libSystem"); + + auto win = payload_linux(); + win.targetOs = "windows"; + win.targetEnv = ""; + EXPECT_EQ(ts::resolve(win).cAbi.interfaceName, "ucrt"); + + EXPECT_EQ(ts::resolve(payload_linux()).cAbi.interfaceName, "gnu") + << "a triple that states its env keeps stating it"; +} + TEST(TargetSideResolve, TheCompilerIsALayerAndItIsAlwaysThePayloads) { auto in = payload_linux(); in.compilerFamily = "gcc";