From 39ab49e6df3cc00d6b0040f10861b49d9036c968 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:28:21 +0800 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20#540=20=E7=9A=84=E4=B8=83=E6=9D=A1?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1,=E4=BB=A5=E5=8F=8A=E6=A0=B8=E9=AA=8C?= =?UTF-8?q?=E5=AE=83=E4=BB=AC=E6=97=B6=E6=8C=96=E5=87=BA=E7=9A=84=E5=9B=9B?= =?UTF-8?q?=E6=9D=A1=20(2026.9.1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 七条里六条成立,一条判据打偏。核验过程本身挖出四条没有人报过的,其中一条比原报告 的全部七条都严重。它们几乎全是同一族:**mcpp 关于自己说了一句话,而 mcpp 不遵守它。** 完整核验、量化与设计见 `.agents/docs/2026-08-31-issue540-seven-audit-findings.md`。 ── 1. 供给从不检查自己是否成功(未报告,最严重)──────────────── `xlings::call` 返回 `expected`,只要子进程跑起来就处于**值**态 —— 能力自身的状态在 `CallResult` 里面,因为 xlings 讲完 NDJSON 协议后按设计退 0。 #531 的调用点只测了 `if (!r)`,于是 xlings 能报出的每一种失败都被读成成功。实测: $ mcpp build # deps = ["definitely-not-a-real-package"] Provisioning [xlings] deps (definitely-not-a-real-package) Finished dev [unoptimized + debuginfo] in 0.12s $ cat .mcpp/.xlings-deps.stamp definitely-not-a-real-package ← 记为已完成 $ mcpp build Finished dev in 0.00s ← 连 Provisioning 都不再打印 xlings 报得完全正确(`E_NOT_FOUND` + `{"exitCode":1,"kind":"result"}`),`call()` 也 解析对了。⚠️ 正确写法就在同一个文件里:依赖安装路径写的是 `if (r && r->exitCode != 0 && …)`。#531 的注释说它修的缺陷是「声明看起来被接受了却 什么都没做,这是一个配置键能有的最坏形态」—— 没人读结果,它的修法重现了那个形态, 而记号把它变成永久的。 ── 2. 该路径不认两个自动安装开关(未报告)──────────────────── 它自称与 `[toolchain]` 平权,而那条先例在 `MCPP_OFFLINE` 或 `MCPP_NO_AUTO_INSTALL` 下硬错并报出触发的是哪一个。⚠️ 一个专门导出 `MCPP_NO_AUTO_INSTALL` 来阻止意外下载 的 CI,会从一条从没听说过这个变量的路径上拿到下载。 拦的是安装**动作**而不是整块:已供给好的工程仍然离线构建得出来。 ── 3. 记号记录全局效果却存在项目里(未报告)──────────────── 安装落在 registry(刻意如此),而 `/.mcpp/.xlings-deps.stamp` 记着它。清掉 或换掉 `MCPP_HOME`,项目仍然声称已装;`mcpp clean` 只删 `target/`,也清不掉。改按 依赖列表哈希存进 registry,并且只在成功时写。 ⚠️ 搬迁不得让昨天能跑的构建今天被拒。自审时发现:升级后每个已供给的工程读起来都是 「未供给」,配上第 2 条的闸,离线首次构建会被拒。旧记号因此在**唯一一处**被采信 —— 就是那道闸 —— 因为在那里网络关着,没有别的办法查证。它绝不被提升进 registry:写它 的那个版本不读结果,所以它的含义是「尝试过」而不是「成功了」,别处采信等于把缺陷 带过修它的这次升级。 ── 4. 三份手抄的词汇表,三份都漂移了 ─────────────────────── `kKnownBuildKeys`、`kKnownConditionalBuildKeys` 与 xpkg 的 `target_cfg` 列表,都是 别处已有机器可读形式(紧挨其上的读取点、`BuildInputs` 的成员表)的转录。代价不是 少一条警告,而是**一条假的警告**。 * `[build] std-module` / `std-compat-module` / `std-module-flags` 被读取却报 unsupported —— `kKnownBuildKeys` 的**第二次**漂移,而第一次的详细叙述就在它上方 八行。 * 条件轴拒绝 `BuildInputs` 的两个成员:`std-module-flags`(#494 就是为这条轴才把它 挪上来的,成员注释写着「membership here is what makes the cfg axis carry it」) 与 `private_include_dirs` —— 后者更严重,xpkg 描述符的 `target_cfg` 块,也就是 **同一条轴的另一套语法**,是接受它的。 * `[features]` 是唯一一个完全没有 schema 检查的结构化段落。 两条列表的消息现在都由列表本身生成。新增 6 个单测,每个都带否定对照 —— 「没有警告」 这类断言会被一个把检查整个删掉的解析器满足。 ── 5. cfg(<层> = "…"):文档记载而从未接线的特性 ──────────── docs/14 用一整节记载它,连「为什么不能用 feature 选择代替」和作用域约束都论证过; 而 `cfgpred::Ctx` 只由三元组构造,`match_kv` 只认 os/arch/family/env。于是每一个这样 的段落被**静默**丢弃,包成功构建在错误的 C 库配置上。实测:`cfg(env="gnu")` 生效、 `cfg(c-abi="glibc")` 不生效、零诊断。8 处文档如此(中英各 4)。 实现:目标侧解析(`tsd::resolve`)与 P1689 扫描之间有一段空窗,而 build.mcpp 已经在 用它 —— 它按同样的形状把 directive tail 镜像进 `packages[0]`。第二趟合并用同样的 `directives::mark` + `fold_private_tail`,不另造机制。 ⚠️ 两趟必须不相交,而只靠 `matches()` 做不到:`cfg(any(linux, c-abi="musl"))` 的 三元组腿在第一趟就为真,第二趟会再匹配一次,`append()` 是追加式的于是贡献两遍。 按**是否命名了层**归属,而不是按答案。e2e 328 数 `-D` 出现次数来守这条。 ⚠️ 层谓词不能选择依赖(层是从依赖图解析出来的),这种段落被报出并忽略。 ── 6. 未知的 cfg 键现在会说话 ────────────────────────────── 求值器过去对未知键返回假,而那与「这一段本就不该匹配」读数完全相同。⚠️ 词汇表从 求值器**导出**而不是被转录 —— 否则这条诊断自己就会成为第 4 条里的第四份手抄件。 求值器同时就是校验器:一次遍历回答三个问题,因为另写一个校验器就是同一份文法的 第二个解析器,而本仓库已经为其中一个付过账。 `ident()` 现在接受 `-` 与 `+`,否则 `c-abi` 会被扫成裸词 `c` 加一堆垃圾,诊断能报的 就只有字母 `c`。 ── 7. c-abi 层报的是库名,不再是三元组的 env 段 ────────────── 这条是实现第 5 条时才暴露的:谓词是一次比较,而比较有两侧,而此前的设计工作从没问过 右侧的取值是什么。它在普通 Linux 宿主上是 `gnu`(`payload_libc_name` 原样返回 env 段),而 docs/14 的表一直写着 `glibc`/`musl`/`picolibc`,e2e 296 的文件头也把它期望的 报告写作 `c-abi glibc (payload)`。⚠️ **一个只被打印的值没有拼写纪律,把它提升为用户 比较的对象会追溯地强加一条。** 请求侧保留三元组的拼写(规范 §3.4:env 段是对 c-abi 的请求而非答案),两者经 `c_abi_request_satisfied` 比较而非按相等 —— 否则每一次普通 `-gnu` 构建都会被报成请求 不匹配。Windows 上 `-gnu` 命名的是工具链的 MinGW 形态,其 C 运行时是 UCRT,因此映射 按 OS 分叉。 ── 8. 退出码:补上 runtime 的一半,并写下被指派的契约 ───────── 原报告说 docs/11 的表漏了 `4`。判据打偏了:那张表按信封命令划定,而**没有一个信封 命令给得出 4** —— `self env --format json` 恰恰是被特意做成绕开产生 4 的 `load_or_init` 的。表真正漏的是 `1`(`xpkg parse` 五处返回)。 2026-08-08 的协议设计文档 §R4 把完整契约指派给了 `docs/spec/`,一直没有写。现在写了: `docs/spec/exit-codes.md`(SPEC-003),0/1/2/4/70/127 全表 + 稳定性承诺。 ── 9. 其余文本 ───────────────────────────────────────────── * `mcpp build --help` / `mcpp test --help` 说默认档位是 release,而它是 dev。六处说得 对(含一条 e2e 与 mcpp 自己的 mcpp.toml),两处说错;`prepare.cppm` 那条字段注释是 没被报告的第三处。 * `mcpp index update ` 承诺按索引筛选而只筛项目级。限制此前只写在一条注释里 —— 一个只有实现者看得到的地方,从外面看与「这功能坏了」无从区分。 * docs/13 与 docs/17 仍在说 `[xlings] deps` 不是安装触发器(#531 之后为假)。 * `mcpp::target_libc()` 的文档改为它实际回答的问题:供给 sysroot 的那个**载荷**包, 而这个值是目标侧解析的一项**输入**。 ── 测试 ──────────────────────────────────────────────────── 单元:test_manifest 新增 6 个(三份词汇表各一正一负),test_targetside 新增 2 个; 96 个测试二进制全过。 e2e:新增 327(供给失败会报出来 + 两个开关 + 搬迁连续性,五条断言,前两条不需要网络)、 328(层谓词生效/不生效/恰好一次 + 未知键 + --strict)、329(退出码契约,含「退 1 且 stdout 带信封」)。 ⚠️ 判据的分母:327 的核心断言跨**两次**调用 —— 为失败而写的记号在写它的那一次里 不可见,只有第二次构建才分得开「失败了」与「失败了并被记成完成」。328 数 `-D` 的 出现次数而不是用预处理器判断,因为预处理器分不开一个 `-D` 和两个。 --- ...026-08-31-issue540-seven-audit-findings.md | 945 ++++++++++++++++++ CHANGELOG.md | 93 ++ docs/05-mcpp-toml.md | 48 +- docs/11-machine-output.md | 13 +- docs/13-baremetal.md | 22 +- docs/14-target-side.md | 34 +- docs/17-the-project-environment.md | 15 +- docs/spec/README.md | 1 + docs/spec/exit-codes.md | 101 ++ docs/zh/05-mcpp-toml.md | 35 +- docs/zh/11-machine-output.md | 10 + docs/zh/13-baremetal.md | 16 +- docs/zh/14-target-side.md | 27 +- docs/zh/17-the-project-environment.md | 11 +- mcpp.toml | 2 +- modules/manifest/src/targetside_model.cppm | 37 +- modules/manifest/src/toml.cppm | 88 +- modules/versioning/src/version.cppm | 2 +- src/build/prepare.cppm | 355 ++++++- src/build/prepare_inputs.cppm | 149 ++- src/cli.cppm | 18 +- src/pm/index_management.cppm | 10 +- ...ed_xlings_deps_report_their_own_failure.sh | 155 +++ ...redicates_apply_and_unknown_ones_say_so.sh | 137 +++ .../e2e/329_exit_codes_match_the_contract.sh | 81 ++ tests/unit/test_manifest.cpp | 211 ++++ tests/unit/test_targetside.cpp | 32 +- 27 files changed, 2587 insertions(+), 61 deletions(-) create mode 100644 .agents/docs/2026-08-31-issue540-seven-audit-findings.md create mode 100644 docs/spec/exit-codes.md create mode 100755 tests/e2e/327_declared_xlings_deps_report_their_own_failure.sh create mode 100755 tests/e2e/328_layer_predicates_apply_and_unknown_ones_say_so.sh create mode 100755 tests/e2e/329_exit_codes_match_the_contract.sh diff --git a/.agents/docs/2026-08-31-issue540-seven-audit-findings.md b/.agents/docs/2026-08-31-issue540-seven-audit-findings.md new file mode 100644 index 00000000..9e89ff87 --- /dev/null +++ b/.agents/docs/2026-08-31-issue540-seven-audit-findings.md @@ -0,0 +1,945 @@ +# Issue #540, verified: seven filed findings, six confirmed, one misaimed, four more underneath + +> Status: **shipped in 2026.9.1.1.** Everything in §11 landed except D2b (an +> upstream xlings change) and D7b (a structural rewrite of the `[build]` read +> block, deliberately deferred). Item 8's decision was taken: **D9** — the layer +> predicates are implemented, so `docs/14` describes something that exists. +> +> One thing this document did not predict. Implementing D9 made the `c-abi` +> layer's interface name a value users write, and the engine reported `gnu` +> where `docs/14` (and e2e 296's own header) said `glibc` — `payload_libc_name` +> returned the triple's env segment verbatim. A vocabulary that was cosmetic +> while it was only printed became load-bearing the moment a predicate compared +> against it, so the layer now names the library and the request keeps the +> segment's spelling. §13 records it with the other reversals. +> + +> Baseline: `origin/main` @ `aef5191`, the commit the report audits. The working +> checkout at the time of writing was 37 commits behind and predates the +> `modules/` split, so every line anchor below was read from a detached worktree +> at `aef5191`, never from the checkout. A reader who greps the checkout will +> conclude that `modules/manifest/src/toml.cppm` does not exist. +> +> Measured with: mcpp `2026.8.30.2` (the audited release), gcc 16.1.0, +> Linux x86_64. Five probe projects, each reproduced inline; four need nothing +> from the index and no network. +> +> **Revision.** Rewritten twice. The first pass read the code; the second read +> the design record — the commit messages and `.agents/docs` entries that say why +> each mechanism exists — and the third asked, for the one feature that looked +> unimplemented, what a package does today instead. Six conclusions changed +> across the two rewrites, and §12 and §6.7 are entirely new. What the earlier +> drafts got wrong is recorded in §13 rather than removed, because the reasoning +> that produced a wrong answer is more useful than a document that only ever +> agreed with itself. + +--- + +## 0. Verdicts + +| # | filed as | verdict | what it actually is | +|---|---|---|---| +| 1 | `--profile` help says release, resolver says dev | **confirmed** | one stale copy of a decision six other sites state correctly, one of them an e2e | +| 2 | `index update ` filters only project indices | **confirmed** | recorded in the source as a follow-up; the promise is in the CLI, the limitation is in a comment | +| 3 | exit code 4 missing from the machine-output table | **misaimed, and the real gap is wider** | no enveloped command can return 4. The design record assigned this whole contract to `docs/spec/`, which was never written (§4) | +| 4 | `[features]` swallows unknown keys silently | **confirmed** | the only structured manifest section with no schema check | +| 5 | `std-module` keys read but reported unsupported | **confirmed** | second recorded drift of a hand-maintained list, narrated in a comment eight lines below the defect | +| 6 | the `cfg(c-abi = ...)` example fails both legs | **confirmed, far larger than filed** | a documented feature of the normative target-side chapter, never wired, 8 doc sites, failing silently | +| 7 | docs/13 and docs/17 predate #531 | **confirmed, and it is the least of what is wrong there** | see §12 | +| — | not filed | **confirmed** | the conditional axis rejects two `BuildInputs` members, one of which the xpkg grammar for the same axis accepts (§6.2) | +| — | not filed | **confirmed, measured** | **#531's provisioning never checks whether it succeeded.** An unresolvable package name is reported as provisioned, the stamp makes it permanent, and the build succeeds (§12.1) | +| — | not filed | **confirmed, measured** | that path honours neither `MCPP_OFFLINE` nor `MCPP_NO_AUTO_INSTALL`, while the precedent it claims parity with honours both (§12.2) | +| — | not filed | **confirmed** | `mcpp::target_libc()` reports `targetSysrootPkg`, which `prepare.cppm:7293` feeds *into* target-side resolution as `payloadLibcRef`, while `docs/05:1181` calls it "which C library was resolved" (§6.7) | + +Nothing in this set is a usage error. Nine of the eleven are a statement mcpp +makes about itself that mcpp does not honour, which is the class the report +correctly named. + +--- + +## 1. The shapes the set shares + +**A rule is stated in one place and enforced from a hand-written copy of it.** +`kKnownBuildKeys` (§5), `kKnownConditionalBuildKeys` and the xpkg +`target_cfg` list (§6.2) are transcriptions of a set that exists elsewhere in +machine-readable form: the read sites immediately above them, and `BuildInputs`'s +member list. All three have drifted, in different directions. +`kKnownBuildKeys` has drifted twice — the comment at `toml.cppm:1334-1345` +narrates the `bmi_schedule` occurrence in detail, and three keys eight lines +above it are in the same state. + +The consequence is not a missing warning but a **warning that is false**: + +``` +warning: [build] has unsupported key 'std-module' (ignored). Supported keys: ... +``` + +The key is read at `toml.cppm:556`. The only sentence mcpp offers about it tells +the author the opposite of what happens, and the comment at `:1340` says so in +those words about the previous instance. + +**An answer is computed, carried back, and never consulted.** §12.1 is the sharp +case: xlings reports `E_NOT_FOUND`, `call()` parses it into +`CallResult::exitCode`, the provisioning site tests only the `expected`'s error +state — which `call()` never sets — and reports success. The sibling install +path 750 lines away in the same file reads `r->exitCode` correctly. + +**A predicate that answers false is indistinguishable from one that was never +understood.** `cfgpred` returns `false` for an unknown key and an unknown +bareword (`prepare_inputs.cppm:111`, `:118`), and a `[target..build]` +section whose predicate is false is dropped without a word. This is what makes +§6 silent, and it is worth fixing whichever way §6 is decided. + +--- + +## 2. Finding 1 — the profile default + +### 2.1 Measured + +``` +$ mcpp build --help | grep profile + --profile Build profile: release (default) | dev | dist | ... +$ mcpp build + Finished dev [unoptimized + debuginfo] in 0.08s +``` + +### 2.2 The disagreeing copies + +| site | says | +|---|---| +| `src/cli.cppm:351` (`build`), `:414` (`test`) | release is the default | +| `src/build/prepare.cppm:703-709` `resolve_profile_name` | `fallback = "dev"` | +| `src/build/prepare.cppm:692-694` (its comment) | "The global default is `dev`" | +| `docs/05-mcpp-toml.md:1574`, `docs/zh/05-mcpp-toml.md:1369` | `flag > default-profile > global dev` | +| `tests/e2e/87_build_default_profile.sh:3-4, 24` | asserts `-O0`, with the same Cargo/Meson rationale | +| `mcpp.toml:12-14` | pins `default-profile = "release"` *because* the global default is not release | +| `src/cli.cppm:509-510` (`pack`) | correct and explicit: "default: `[build] default-profile`, else release" | + +Six agree, two disagree. `mcpp pack` is the only caller passing a `release` +fallback (`src/pack/pipeline.cppm:57`, `:89`), and its help says so. + +A third stale copy the report did not reach — `src/build/prepare.cppm:767`: + +```cpp +std::string profile; // --profile (default "release") +``` + +### 2.3 D1 + +`src/cli.cppm:351` and `:414` become `dev (default) | release | dist | +<[profile.*] name>`; correct the comment at `prepare.cppm:767`. Leave `pack`'s +help alone. No behaviour change; `87_build_default_profile.sh` already holds it. + +--- + +## 3. Finding 2 — `index update ` + +`src/cli.cppm:645` declares `.arg(cl::Arg("name").help("If given, update only +this index"))`. The rendered help does not print the argument description — + +``` +$ mcpp index update --help +USAGE: + mcpp index update [OPTIONS] + mcpp index update +``` + +— so the misleading sentence is source-only, but the USAGE line still advertises +a per-index selection. `index_update` (`src/pm/index_management.cppm:133`) syncs +the global repos unconditionally (`:148-150`) and applies `filterName` only to +project-level custom indices. The source says so at `:127-131`, including why: +`xlings update` has no per-index mode to call, and calls it "a follow-up". + +**The recorded intent is therefore to implement it.** D2b below is the direction; +D2a is what to do until the upstream change exists. + +- **D2a (now).** State the actual contract in the argument help and the command + description: `` selects among the **project's** custom indices; the + global repos always sync wholesale. Keep the `:127-131` note — it stays a + follow-up, it stops being a discrepancy. +- **D2b (the intent).** Give `xlings update` a per-index mode and pass the filter + through. xlings change first, mcpp second. + +--- + +## 4. Finding 3 — the exit-code table, and the contract that was never written + +This is where the report is wrong, and the correction is larger than the claim. + +### 4.1 Why 4 does not belong in that table + +The table at `docs/11-machine-output.md:103-110` (zh `:86-90`, identical) sits at +the end of §3 "Asking for machine output", governing the three kinds +`--protocol-version` advertises. Exit 4 comes from `config::load_or_init` +failures at eight sites — `src/pm/index_management.cppm:27, 64, 107, 118, 134, +187` and `src/doctor.cppm:87, 1194` — none of which has a `--format json` path. +`self env --format json`, the one that could have produced it, is built +specifically not to: `src/cli/cmd_self.cppm:23-36` explains that the machine path +avoids `load_or_init` so that asking where `$MCPP_HOME` is does not create it. + +### 4.2 The code that table omits is 1 + +``` +$ mcpp xpkg parse /nonexistent.lua --format json +error: cannot open '/nonexistent.lua' # stderr; stdout empty +$ echo $? +1 +``` + +`src/cli/cmd_xpkg.cppm` returns 1 at `:117`, `:143`, `:172`, `:188`, `:260`. +Several of those write JSON to stdout *before* returning 1 — a fact a client +needs and the table does not state. + +### 4.3 What the design record actually asked for + +`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md` §R4 +enumerates the measured codes — 127 unknown subcommand, 2 unknown option, 2 +unknown value, 70 uncaught exception — which is exactly the table that shipped, +and then says: + +> 光接管 parse error 不够,还要把 usage / runtime / internal 的 rc 映射写成契约, +> 并覆盖异常边界 —— 否则客户端仍然要靠猜。这条现在是 `docs/spec/` 的内容,不是代码。 + +So the table is R4's **usage/internal** half, shipped without the **runtime** +half. `ls docs/spec/` is `package-identity.md`, `README.md`, `target-side.md` — +the assigned contract was never written. + +The reporter's instinct about 4 is right about the *contract* and wrong about the +*location*. + +### 4.4 D3 + +1. Add `| 1 | the command failed — see stderr; when stdout carries an envelope, + `diagnostics` |` to both tables, and one sentence scoping the table to the + enveloped commands. Without that sentence the next audit reaches the same + conclusion this one did. +2. Write the `docs/spec/` exit-code contract R4 assigned: usage (2), runtime (1), + config (4), unknown command (127), internal (70), with the channel each uses. + That is where 4 belongs. + +Criterion: an e2e asserting `mcpp xpkg parse --format json` exits +1. `tests/e2e/93_xpkg_parse.sh` covers only the success shapes today. Assert the +exit code, not that stderr is non-empty. + +--- + +## 5. Finding 5 — `kKnownBuildKeys`, second drift + +### 5.1 Measured + +```toml +[build] +std-module = "gen/std.cppm" +std-compat-module = "gen/std.compat.cppm" +std-module-flags = ["-D_GNU_SOURCE"] +``` + +``` +warning: [build] has unsupported key 'std-compat-module' (ignored). ... +warning: [build] has unsupported key 'std-module' (ignored). ... +warning: [build] has unsupported key 'std-module-flags' (ignored). ... + Finished dev [unoptimized + debuginfo] in 0.08s +``` + +All three are read at `toml.cppm:556-560` and take effect. + +### 5.2 D5 + +Add the three keys to `kKnownBuildKeys` (`toml.cppm:1320-1328`). One line. + +### 5.3 D7 — why a one-line fix is not the whole answer + +This list is a transcription of the `doc->get_*("build.…")` calls above it. It +has drifted twice, the second time with a detailed narration of the first eight +lines below it, and §6.2 shows the same class in two more lists. A third +occurrence is a matter of time and the failure mode is a warning that lies. + +- **D7a (recommended).** One unit test per vocabulary, loading a manifest that + names every key the parser reads and asserting `schemaWarnings` is empty: + `[build]`, `[target..build]`, and the xpkg `target_cfg` block. The key + list in the test is another copy — that is the objection — but it is a copy + that **fails loudly** when it disagrees, which the current arrangement does + not. +- **D7b.** Derive the lists. Replace the free-standing `get_string("build.X")` + calls with a `{key, reader}` table and let both the parse and the allowlist + iterate it, the way `mcpp:` directives are already table-driven. Structural; + touches every read in the `[build]` block; a separate change from D5. + +D5 and D7a together. **Do not ship D5 without D7a** — a one-line fix to a list +that has drifted twice is a third opportunity, not a repair. + +Criterion for D7a: the test must fail against `aef5191`. Verify that before +believing it. + +--- + +## 6. Finding 6 — `cfg(c-abi = ...)`: a documented feature with no implementation + +### 6.1 Leg one: the predicate cannot ever be true + +`cfgpred::Ctx` is built by `context_for(targetTriple)` +(`src/build/prepare_inputs.cppm:62-88`) from the triple and nothing else. The +grammar comment at `:90-91` states the vocabulary outright: + +``` +key ∈ {os, arch, family, env} bareword ∈ {windows, unix, linux, macos} +``` + +`c-abi` is therefore not a missing branch in `match_kv` (`:113-119`): the value +it would compare against is not in scope, and by `docs/spec/target-side.md` §3.5 +it cannot be — the target side resolves only after dependency resolution, while +`merge_conditional_config` runs at `prepare.cppm:1958` from a triple-only +context. + +Measured, against a predicate that is true of the host on its own terms: + +```toml +[target.'cfg(env = "gnu")'.build] defines = ["PROBE_ENV=1"] +[target.'cfg(c-abi = "glibc")'.build] defines = ["PROBE_CABI=1"] +``` + +``` +error: #error "cfg(c-abi=glibc) did NOT apply" +``` + +`PROBE_ENV` was defined, `PROBE_CABI` was not, and **no diagnostic was emitted +about the section that did nothing**. An author following `docs/14` gets a +successful build with the wrong C-library configuration. + +### 6.2 Leg two: two lists for one axis, disagreeing with each other and with the type + +`stdModuleFlags` **is a member of `BuildInputs`** (`types.cppm:262`), and its +comment states why in exactly the terms of the documented example: + +> 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. […] `-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. + +That is not an inference. Commit `61c7446` (#494), which moved the three keys +from `[package]` to `[build]`, states the purpose in its message — + +> ⭐ 而放进 `[build]` 还白得一样能力:它立刻可以按目标侧条件化。 + +— and `.agents/docs/2026-08-24-target-side-architecture.md:222-232` gives the +worked example, `cfg(c-abi = "musl")` / `cfg(c-abi = "picolibc")`, verbatim as it +now appears in `docs/14`. The data model was reshaped **for** this. What was +never added is the read (`toml.cppm:1933-1947` does not include it) and the +allowlist entry. + +`privateIncludeDirs` is worse, because the same axis accepts it in the other +grammar. The xpkg descriptor's `target_cfg` block reads it +(`xpkg.cppm:1416`) and names it in its own error text (`:1421-1425`), and +commit `e187d3f` (#515) records adding it there for exactly the reason at issue: + +> `target_cfg` 的未知键报错列出「期望哪些键」,我加了 `private_include_dirs` 却没有 +> 把它加进那份列表 —— 于是错误信息会把一个**已经被接受**的键说成不存在。补上。 + +The policy difference between the two grammars is deliberate and recorded — +`xpkg.cppm:1410-1413`, "Unknown sub-keys stay a HARD ERROR here […] mcpp.toml's +own unknown-key policy is a separate question — #263". The **vocabulary** +difference is not stated anywhere, and `kKnownConditionalBuildKeys`'s own comment +claims the opposite: + +> The conditional axis carries BuildInputs and nothing else, so its vocabulary is +> exactly that struct's members. […] MUST stay in sync with the reads above and +> with types.cppm's BuildInputs. + +| key | `BuildInputs` member | xpkg `target_cfg` | mcpp.toml `[target..build]` | +|---|---|---|---| +| cflags, cxxflags, ldflags, sources, defines, flags, include_dirs, include_dirs_after | yes | accepted | accepted | +| `private_include_dirs` | yes | **accepted** | **rejected** | +| `std-module-flags` | yes | rejected | **rejected** | + +Measured with a predicate that *is* true: + +```toml +[target.'cfg(linux)'.build] +private_include_dirs = ["priv"] +std-module-flags = ["-D_GNU_SOURCE"] +defines = ["PROBE_TRUE_PREDICATE=1"] +``` + +``` +warning: [target.cfg(linux).build] has unsupported key 'private_include_dirs' (ignored). ... +warning: [target.cfg(linux).build] has unsupported key 'std-module-flags' (ignored). ... + Finished dev [unoptimized + debuginfo] in 0.08s +``` + +### 6.3 Blast radius + +`cfg(c-abi = …)` appears at **8 sites**, three chapters, both languages: + +| file | lines | +|---|---| +| `docs/14-target-side.md` | 239, 255, 258 | +| `docs/zh/14-target-side.md` | 203, 216, 219 | +| `docs/05-mcpp-toml.md` | 1380 | +| `docs/zh/05-mcpp-toml.md` | 1197 | + +`docs/14` §"Adaptation To The Resolved Target Side" is a full section with its +constraints worked out: why a feature selection is the wrong spelling, and why +the feature is scoped to `[build]` ("The target side is resolved after dependency +resolution, so a dependency selected by one would form a cycle"). That is design +intent with the two-pass structure already anticipated. + +### 6.4 What must not be done + +**Rewriting the examples to `cfg(env = "musl")` is wrong.** +`docs/spec/target-side.md:137` (rule 3.4, marked implemented) states that the +triple's `env` segment must be treated as a **request** for the `c-abi`, never as +its answer — the answer may come from the graph, e.g. `openkal-musl` supplying +musl under a `-gnu` triple. Substituting one for the other documents a different +behaviour and buries the finding. + +### 6.5 D6 — the two conditional keys (independent of the predicate work) + +`read_list("std-module-flags", cc.inputs.stdModuleFlags)` and +`read_paths("private_include_dirs", cc.inputs.privateIncludeDirs)` at +`toml.cppm:1933-1947`, plus both keys in `kKnownConditionalBuildKeys`. This +completes `61c7446`'s stated purpose and removes the divergence from the xpkg +grammar. It makes `[target.'cfg(linux)'.build] std-module-flags` work **today**, +without any predicate change. + +### 6.6 D8 — make the silence loud (highest value per line; do it early) + +An unknown `cfg()` key or bareword returns `false` and the section vanishes. +Emit a schema warning instead. + +Place the check in `toml.cppm`, beside the other unknown-key warnings, where the +predicate string is available at manifest load and `schemaWarnings` already +exists — not in the evaluator, which has no diagnostic channel and runs once per +call site. + +⚠️ **Share the vocabulary; do not transcribe it.** Export the key and bareword +sets from `cfgpred` and have the validator call into them. Writing the list a +second time in `toml.cppm` creates the fourth copy of exactly the defect §1 +names. + +Scope it to the inside of `cfg(...)`. The bare-triple namespace has a documented +escape hatch — `matches()`: "Unparseable keys (the explicit-section escape hatch) +fall back to exact string comparison" — which must keep working. + +Warning rather than error is the established policy and is what keeps forward +publication possible: `e187d3f` measured that an older engine reading a new key +warns in a root manifest and is silent in a dependency, and concluded that a +package may adopt a key before its consumers upgrade. The same holds here. + +### 6.7 What a package can already do without any of this + +The declarative axis is not the only spelling for "adapt to the resolved target +side", and most of `docs/14`'s example does not need it. `BuildProgramEnv` states +the design intent directly (`build_program.cppm`, field comments): + +> The resolved toolchain's payload root and the target's own C library root. +> Both exist so a package can **ASK instead of DECLARE** […] +> Three more answers a board-support package would otherwise hardcode. +> ⚠️ THE COUPLING THESE REMOVE IS INVISIBLE IN A MANIFEST. + +A `build.mcpp` receives `MCPP_TARGET_LIBC`, `MCPP_COMPILER`, +`MCPP_TARGET_SYSROOT`, `MCPP_TOOLCHAIN_DIR`, `MCPP_TARGET_BUILTINS_LIB` and +`MCPP_TARGET_LIBC_PROFILE` (`build_program.cppm:420-430`), and may emit +`mcpp:cfg=`, `mcpp:include-dir=`, `mcpp:include-dir-after=`, `mcpp:cxxflag=`. +So the `include_dirs` half of `docs/14`'s "Adaptation" section — `config/musl` +versus `config/picolibc` — is expressible today, imperatively, with no engine +change. `docs/05-mcpp-toml.md:1181-1184` documents the accessors. + +Three things that channel does **not** reach, and they are what the decision is +actually about: + +1. **`std-module-flags` has no directive.** The whole table — + `cxxflag`/`cflag`/`cfg`/`include-dir`/`link-*`/`source`/`generated` — reaches + the package's own translation units. The std module is compiled from + `tc->stdModuleFlags` in the toolchain layer (`prepare.cppm:8122` → + `clang.cppm:206`, `:361`), which no directive feeds. `-D_GNU_SOURCE` for musl + and not for picolibc — the motivating example — is exactly this key. +2. **`target_libc()` answers the payload question, not the resolved one.** + `e.targetLibc = tc->targetSysrootPkg` (`prepare.cppm:923`), and + `targetSysrootPkg` is fed *into* the resolver at `:7293` as + `in.payloadLibcRef` — it is an **input** to target-side resolution, not its + output. When the C library comes from the dependency graph + (`openkal-musl`), which is the case `docs/14` was written for, it is not the + answer. `docs/05:1181` nevertheless calls it "which C library was resolved". + **This is a defect in its own right, independent of the decision below, and + it currently misleads package authors.** +3. **A dependency's `build.mcpp` runs too early.** The dep loop is at + `prepare.cppm:6921`; the target side resolves at `:7351`. The ROOT program + was already moved past it once — "L3: ROOT build.mcpp (moved after dependency + resolution, design §3.1 item 4)" at `:7645` — and the dependency loop was + not. The motivating package is a dependency. + +### 6.8 D9 — implement the layer predicates + +The cost is smaller than "the merge runs before the answer exists" suggests, +because the ordering leaves a window and something already ships in it. + +The timeline, all in `prepare.cppm`: + +| line | event | what it fixes in place | +|---|---|---| +| 1958 / 4123 / 5525 | `merge_conditional_config` + `fold_build_defines_into_flags` | triple-only context | +| 3246 / 5159 / 5594 | `packages[]` snapshots — `PackageRoot` holds a `Manifest` **by value** (`:3249`) | the merged inputs | +| **7351** | `resolvedTargetSide = tsd::resolve(in)` | the answer exists | +| ~7660–7800 | **build.mcpp directive tails are mirrored into `packages[0]`** | precedent, see below | +| 7810 | `modgraph::scan_packages*` over `packages` | `sources` and `defines` (P1689 needs the final `-D` set) | +| 8070 | `pkg.manifest.buildConfig.stdModuleFlags` collected → `tc->stdModuleFlags` | the std module compile | +| 8227 | `canonical_compile_flags` → the fingerprint | everything | +| 8500+ | `compute_flags` at plan time | the compile lines | + +So **7351 → 7810 is an open window**, and mcpp already uses it for exactly this +shape. The build.mcpp path contributes build inputs after the snapshot and says +so in its own comment — "apply() mutated `*m`, but `packages[0].manifest` is a +[snapshot] … so mirror the directive TAILS into `packages[0]`" — mirroring +`sources`, `cflags`, `cxxflags` and the include dirs. A layer-conditional pass is +the same mechanism with a different producer. + +`fold_build_defines_into_flags` is explicitly re-runnable: its comment states +"Idempotent: clearing the vector after folding makes repeated calls harmless", +and `bc.defines.clear()` at `:329` is what makes that true. + +D9 is therefore: carry the resolved target side into `Ctx`, add the five layer +names (`compiler`, `compiler-runtime`, `kernel-abi`, `c-abi`, `c++-abi`) to +`match_kv`, and run one additional merge pass in that window. Note `61c7446`'s +constraint: the `compiler` layer reports the **family** (`llvm`), never the +driver (`clang`), because that is what users write. + +Four things must be decided rather than discovered, and they are the actual +content of this item: + +1. **Which `BuildInputs` members may be layer-conditioned.** Every one of them + fits the window — `sources`/`defines` need only to precede 7810, + `std-module-flags` 8070, `ldflags` plan time — so "all of them" is available. + It still has to be *stated*, because the answer is what the warning text in + `kKnownConditionalBuildKeys` will claim. +2. **Predicates must be classified, not merely evaluated.** `append()` is + additive, so a second pass that re-runs the triple-predicate sections + double-appends them. Each `[target.]` section needs a parse-time + marker for whether its predicate names a layer, and each pass runs only its + own half. +3. **All of `packages[]`, not just `packages[0]`.** The build.mcpp precedent + patches the root only, which is correct for build.mcpp. `docs/14`'s + motivating case is a *dependency* — a package supplying one C++ runtime over + several C libraries — so the pass must cover every entry. +4. **Dependencies stay excluded.** `[target..dependencies]` cannot be + layer-conditioned; `docs/14` already states why (the selection would form a + cycle with the resolution that produces the answer). Unchanged. + +Criterion: a dependency package whose `[target.'cfg(c-abi = "musl")'.build]` +contributes an `include_dirs` entry must show that directory on the consumer's +compile line for a musl target and not for a glibc one — assert on +`compile_commands.json`, with both legs, because a pass that never fires and a +pass that always fires both produce a green single-leg test. + +### 6.9 D16 — the third option: finish the channel that already exists + +Do not build a predicate axis. Close the three gaps in §6.7 instead: + +- **D16a.** Make the resolved `c-abi` reachable from a build program — either by + correcting `target_libc()` to report `resolvedTargetSide.cAbi` or by adding a + sibling accessor beside it, and by saying in `docs/05` which question each + answers. Required under D9 and D10 as well; §6.7 item 2 is a defect either way. +- **D16b.** Add `mcpp:std-module-flag=` to the directive table, plumbed to + `tc->stdModuleFlags` at `prepare.cppm:8122`. One directive. +- **D16c.** Move the dependency `build.mcpp` loop past target-side resolution, + following the precedent the root loop set at `:7645`. + +`docs/14`'s "Adaptation" section is then rewritten around a build program, and +the `cfg(c-abi = …)` examples are withdrawn with D10. + +**What actually separates D9 from D16.** Not `docs/14`'s stated argument — that +a feature selection "would oblige a project to restate what the target triple or +its dependency graph has already established". A build program does not restate +anything either; it *asks*, which is the same relation to the answer. That +argument rules out features and does not distinguish these two. + +The real difference is the cost floor. D16 obliges a package that wants **one** +conditional flag to ship a C++ program and run it on every configure. D9 is +declarative and costs the author nothing per package, at the price of a second +evaluation phase in the engine and a permanent obligation on every future +`BuildInputs` member to declare which phase it belongs to. + +D16 is also the only option whose parts are useful on their own: D16a is a +defect fix, D16b closes a gap in a documented table, and D16c aligns two call +sites that already disagree. + +### 6.10 D10 — withdraw + +If the layer predicates are not going to be implemented, withdraw the claim: +delete §"Adaptation To The Resolved Target Side" from `docs/14` and both +`docs/05` examples, in both languages, and say that conditioning on a resolved +layer has no spelling yet. Eight sites, four files. + +Do not leave it as written. A normative chapter describing a mechanism that +evaluates to false in silence costs the reader's trust in the rest of the +chapter. + +Criteria — D6: `[target.'cfg(linux)'.build] std-module-flags` reaches the +std-module compile; assert on the command line in `compile_commands.json`, not on +the absence of a warning. D8: a manifest with `cfg(nonsense = "x")` produces a +named warning and `--strict` fails. + +--- + +## 7. Finding 4 — `[features]` has no schema check + +### 7.1 Measured + +```toml +[features] +fast = { implies = [], include_dirs = ["nope"], totally_bogus = 1 } +``` + +Zero diagnostics. The same `include_dirs` misplaced into `[build]` or +`[target..build]` warns. + +The parse region (`toml.cppm:439-508`) looks up known keys and never enumerates: +`implies`, `forward`, `defines`, `sources`, `requires`, `provides`, `flags`. + +### 7.2 D4 + +Enumerate the feature table's keys against that set and warn on the rest. + +Two decisions to state rather than assume: + +- **`deps`.** The comment at `:437-438` calls `requires`/`provides`/`deps` + "reserved for later stages". `requires` and `provides` are now read; `deps` is + not. A reserved-but-unread key should warn, saying it is reserved — silence is + what made `action.blocking` invisible for a release cycle. +- **Forward compatibility is not at risk.** `schemaWarnings` surface only for the + ROOT manifest: `prepare.cppm:1238` runs on `m` before any dependency manifest + is loaded, and `e187d3f` measured the same thing from outside ("出现在**依赖** + 清单里 ⇒ 静默接受,退 0"). A published package using a future feature key + cannot make a consumer's build noisy. + +--- + +## 8. Finding 7, part one — the documents + +| file | line | says | +|---|---|---| +| `docs/13-baremetal.md` | 558 | "A declaration under `[xlings] deps` is not an install trigger […] it installs nothing" | +| `docs/zh/13-baremetal.md` | 500 | same | +| `docs/17-the-project-environment.md` | 113 | "a directory that has to be created and populated before the first build, and mcpp will not do it" | +| `docs/zh/17-the-project-environment.md` | 93 | same | + +Since #531, `prepare.cppm:3131-3208` provisions declared `[xlings] deps` on the +first build, and the comment argues the case: "the same 'declare it and mcpp +provisions it on first use' contract `[toolchain]` has had all along […] A build +environment should not have two grades of declaration." It also notes that +provisioning is what creates a named `[xlings] subos` that does not yet exist — +precisely what `docs/17:113` says mcpp will not do. + +**D11.** Update the documents, not the code. The rationale is sound and the +`[toolchain]` parallel is the right one. `docs/13`'s surrounding advice — a build +program should check `xpkg_dir` and warn when empty — stays correct; only the +"installs nothing" claim is false. Four files, both languages, as CI requires. + +Sequence D11 **after** §12, so the documents describe the behaviour that exists +rather than being rewritten twice. + +--- + +## 9. Finding 7, part two — the parity that was claimed and not delivered + +The #531 comment invokes `[toolchain]` as its precedent. Reading that precedent +is what turned up §12; it is worth stating separately what the precedent does. + +**It does not remember. It checks.** The toolchain path maps a spec plus a target +onto a payload, "installing it if absent — `autoInstall` was always true there" +(`prepare.cppm:2467-2469`). There is no "I already installed this" record. What +is persisted is the *choice* (`write_default_toolchain` into the global config), +never the fact of installation, because presence answers that question directly. + +**It gates auto-install on two knobs, and names the one that fired** +(`prepare.cppm:2235-2251`): + +> CI / offline / test opt-out: hard-error instead of silently pulling ~800 MB of +> toolchain. […] `--offline` / MCPP_OFFLINE subsumes MCPP_NO_AUTO_INSTALL: the +> older name only ever covered this one gate […] The old var is kept working (it +> predates offline mode and CI still exports it). + +The #531 path does neither. §12 is what that costs. + +--- + +## 10. Not recommended + +- **Adding exit code 4 to `docs/11`'s table as filed** (§4.1). Write the + `docs/spec/` contract instead; 4 belongs there. +- **Rewriting the `cfg(c-abi = …)` examples to `cfg(env = …)`** (§6.4). The spec + states these are different questions. +- **Gating #531's provisioning behind an opt-out flag** (§8). A second grade of + declaration is what it removed. The knob parity in §12.2 is a different thing. +- **Transcribing the cfg vocabulary into `toml.cppm` for D8** (§6.6). Export it. +- **Fixing `kKnownBuildKeys` without a test** (§5.3). +- **Adding an offline gate before fixing §12.1.** With the result unread, an + offline failure is swallowed like any other; gating first would hide the + larger defect behind a narrower one. + +--- + +## 11. Sequencing + +| | change | why here | +|---|---|---| +| 1 | **D12** — read the provisioning result (§12.1) | a declared package that does not exist is currently reported as provisioned, permanently. One-site fix with the correct idiom 750 lines away in the same file. | +| 2 | D13 + D14 — knob parity and presence over memory (§12.2, §12.3) | completes the parity §9 describes. D14 subsumes most of D13's edge cases. | +| 3 | D5 + D6 + D7a — the three `[build]` keys, the two conditional keys and their reads, and the test holding all three vocabularies | smallest self-contained group; stops four false warnings and completes `61c7446`'s stated purpose | +| 4 | D8 — an unknown cfg key is a diagnostic | highest value per line, correct under either D9 or D10, and what makes the 8 doc sites visible to their authors | +| 5 | D1 + D3 + D2a + D4 — the text corrections and the `[features]` check | mechanical; no interaction with anything above | +| 6 | D11 — `docs/13` and `docs/17`, four files | after 2, so the documents describe the gated behaviour | +| 7 | **D16a** — `target_libc()` reports the payload ref while `docs/05` calls it the resolved C library (§6.7 item 2) | a defect on its own terms, required under all three options below, and it misleads package authors today | +| 8 | D9 / D16 / D10 — the declarative axis, the imperative channel, or withdrawal | the only item needing a decision rather than an implementation | +| 9 | D15 — the tests §12.4 names | after the behaviour they assert exists | + +Items 1 through 7 are unambiguous. Item 8 wants a maintainer's answer, and D8 +buys the time to give it: once an unimplemented predicate says so, the eight doc +sites stop being a silent trap while the decision is made. + +**What item 8 is actually asking.** Not "is this feasible" — §6.8 shows the +window exists and that build.mcpp already contributes through it — and not "can a +package solve this at all", because §6.7 shows most of it is already solvable +imperatively. The question is narrower and is a contract question: + +> Does the resolved target side become something a **manifest** can branch on, or +> does it stay something only a **build program** can ask about? + +Answering "manifest" (D9) adds a second evaluation phase to the conditional axis +and obliges every future `BuildInputs` member to declare which phase it belongs +to. Answering "build program" (D16) keeps the engine simpler and charges every +package that wants one conditional flag the price of shipping and running a C++ +program. Answering "neither" (D10) is four files and eight sites today, and +stops being cheap the moment one published package depends on it. + +`docs/14` currently answers "manifest" and nothing implements it. + +--- + +## 12. What verifying finding 7 turned up + +### 12.1 The provisioning never checks whether it succeeded + +Measured. A manifest naming a package that cannot exist: + +```toml +[xlings] +deps = ["definitely-not-a-real-package-540"] +``` + +``` +$ MCPP_OFFLINE=1 mcpp build +Provisioning [xlings] deps (definitely-not-a-real-package-540) + Resolving toolchain + ... + Finished dev [unoptimized + debuginfo] in 0.12s +$ cat .mcpp/.xlings-deps.stamp +definitely-not-a-real-package-540 +$ mcpp build + Finished dev in 0.00s # no "Provisioning" line — recorded as done +``` + +xlings reports the failure correctly. Running the exact command +`xlings.cppm:1390` builds: + +```json +{"code":"E_NOT_FOUND","message":"package 'definitely-not-a-real-package-540' not found in the synced index …","recoverable":true} +{"exitCode":1,"kind":"result"} +``` + +and its **process** exit code is 0, by design — the NDJSON `result` line is where +the capability's status lives. `call()` handles this correctly +(`xlings.cppm:1274-1302`): it parses the result line into `CallResult::exitCode`, +records the `ErrorEvent` in `CallResult::error`, and returns +`std::expected` — **always in the value state**. The +`expected`'s error channel is for the call not happening at all. + +The provisioning site (`prepare.cppm:3191-3204`) tests only that: + +```cpp +auto r = mcpp::xlings::call(..., "install_packages", args.dump(), &progress); +if (!r) { /* "provisioning [xlings] deps failed: …" */ } +``` + +`r->exitCode` and `r->error` are never read, so the error branch — whose comment +promises "An ambiguous bare name ("mesa" matching two repos) lands here, and +xlings' own message names the candidates" — is unreachable for any failure xlings +reports through the protocol. + +**The correct idiom is in the same file.** The dependency install path at +`prepare.cppm:3959` reads `if (r && r->exitCode != 0 && …)`, and +`package_fetcher.cppm:410` logs `r->exitCode` for its callers to check. This is a +single-site omission, not an interface problem and not an xlings problem. + +The irony is exact: #531's own comment says the defect it fixed was "The +declaration looked accepted and did nothing — which is the worst shape a config +key can have." For a name that does not resolve, the fix reproduces that shape +and the stamp makes it permanent. + +**D12.** Read `r->exitCode` and `r->error` at `prepare.cppm:3194`, and write the +stamp only on success. Reuse the existing message at `:3199-3203`, which becomes +reachable. One site. + +### 12.2 Neither offline knob is honoured + +Measured, same project: + +``` +$ MCPP_OFFLINE=1 mcpp build → Provisioning … Finished dev +$ MCPP_NO_AUTO_INSTALL=1 mcpp build → Provisioning … Finished dev +``` + +Against the precedent at `prepare.cppm:2235-2251`, which hard-errors on either +and names the knob that fired. `MCPP_NO_AUTO_INSTALL` is what CI exports to +prevent exactly this, and this path has never heard of it. + +**D13.** Gate the install attempt on `offline_mode() || no_auto_install()`, +hard-error, name the knob that fired, and list the packages so they can be +installed out of band. Shape it on `:2244-2251`, which already solves the +"name the knob the user actually set" problem. + +⚠️ Gate the **attempt**, not the block. Under D14 an already-satisfied dependency +must still build offline; a blanket refusal would turn a working offline build +into a hard failure, which is the regression `prepare.cppm:3123-3130` warns about +for the sysroot entry. + +### 12.3 The stamp remembers the request; the precedent checks the effect + +`/.mcpp/.xlings-deps.stamp` (`prepare.cppm:3134`) holds the declared +list, while the installation goes to **global** scope via `make_xlings_env` — +deliberately, and the comment explains at length why project scope was measured +not to work. So the record and the effect live in different places and nothing +reconciles them: + +- the global package is removed, or `MCPP_HOME` is wiped or replaced (a CI + matrix, a second home, a sandbox), while the project stamp persists — mcpp + never re-provisions and says nothing; +- `mcpp clean` removes `target/` and optionally the cache + (`execute.cppm:1958`, `:1967`) and never touches `.mcpp/`, so the usual + recovery gesture does not clear it. + +The existing comment has the right instinct — "Idempotence by CONTENT, not by +existence" — and applied it to the content of the *declaration* rather than of +the *effect*. §9's precedent does the opposite and needs no stamp at all. + +**D14.** Make the fast path a presence check rather than a memory, matching +`[toolchain]`. Two ways, and the choice is a measurement, not an opinion: + +- **D14a.** Drop the stamp; call `install_packages` every build. Exact parity + with `[toolchain]`, and correct by construction. Costs one xlings round trip + per build. **Measure that round trip against a no-op incremental build before + choosing** — it is a process spawn, and the stamp exists because someone + judged it too expensive without recording a number. +- **D14b.** Keep a stamp as a pure cache, but move it into the registry keyed by + a hash of the list, so it is destroyed with the thing it records whenever the + home is wiped or replaced, and is shared between projects declaring the same + packages. Does not cover a user's `xlings remove`; no stamp does. + +A presence check keyed on the declared names is the third option and the best +one, but it is blocked on a detail the code already records: `resolve_xpkg_path` +requires `@` and rejects a bare `mesa`, while a manifest is +entitled to name a package without pinning it. A bare-name presence primitive has +to exist first. + +Note that D12 alone removes the sharpest edge: with the result read, the stamp is +only ever written for a provisioning that actually succeeded. + +### 12.4 The path has no test + +`grep -rn "xlings-deps.stamp" tests/` is empty. +`tests/e2e/88_xlings_environment.sh:51-52` asserts only that the deps are +*materialized into* `.xlings.json` — true before #531, and exactly the "declared +and nothing happened" state the change was made to end. + +**D15**, in the order they can be written: + +1. **Knob parity, network-free.** `MCPP_NO_AUTO_INSTALL=1` with a declared dep + must fail before any xlings call. Runs anywhere; no index needed. +2. **Failure is reported.** A declared name that cannot resolve must fail the + build and must NOT write the stamp. Needs a synced index but no install — + the probe in §12.1 is the test. +3. **Idempotence across two invocations**, with the denominator stated: a single + run cannot distinguish "provisioned" from "provisioned twice". + +--- + +## 13. Review record + +Four conclusions of the first draft changed on re-reading the design record. +They are kept because each was wrong in a way worth naming. + +**The exit-code finding was scoped too narrowly.** The first draft said "do not +add 4" and stopped. Reading R4 of the protocol design doc showed the table is +half of an assigned contract and the other half was never written, so the useful +answer is "add 1 here, and write the `docs/spec/` contract where 4 belongs" — +which makes the reporter half right rather than simply wrong. + +**`private_include_dirs` was reported as drift and is more than that.** The first +draft had it as one missing entry in one list. It is a divergence between two +grammars for the same axis, where the *other* grammar accepts the key and its +commit message records why — which moves it from "someone forgot" to "two lists +that must agree do not". + +**D12 and D13 were proposed as an offline gate and a stamp relocation, and the +real defect was underneath both.** The first draft never asked what +`install_packages` returns. Asking produced §12.1: the answer is parsed, carried +back in `CallResult`, and not read — so no gate would have helped, because the +failure it would report was already being discarded. The lesson is the one this +repository keeps recording: **verify the claimed parity by reading the precedent, +not by trusting the comment that claims it.** The comment said "the same contract +`[toolchain]` has had all along"; the precedent does not memoize and honours two +knobs, and this path does neither. + +**D8 nearly created the defect it fixes.** The first draft said to validate cfg +keys in `toml.cppm` without saying where the key list comes from. That is a +fourth transcription of the vocabulary §1 is about. Corrected to export the set +from `cfgpred`. + +**D9 was called architectural on the strength of one observation.** The first +draft saw that `merge_conditional_config` runs at three sites, all before the +target side resolves, and concluded that a second pass "at all three sites" was +needed and that the change was structural. Both halves were wrong. Reading +forward from the resolution point instead of backward from the merge showed a +~460-line window between it and the P1689 scan, and showed that build.mcpp +already contributes build inputs through that window by mirroring into +`packages[0]` — a shipped instance of the exact mechanism. The pass is one new +producer in an existing window, not three edits to an early phase. + +The general lesson is the same one §12 records in a different key: **an ordering +constraint is a claim about two points, and reading only the earlier one gives an +answer that sounds rigorous and is wrong.** "The merge runs before the answer +exists" is true and does not imply what the first draft drew from it. + +**The alternatives to D9 were stated without checking whether one already +existed.** The first draft framed item 7 as implement-or-withdraw and claimed +that withdrawal forces a package to be split per C library. Both were wrong. +`docs/14`'s "split per C library" sentence is about packages whose C libraries +require different **dependencies**, not different flags; and §6.7 shows that a +`build.mcpp` reading `MCPP_TARGET_LIBC` already expresses the `include_dirs` half +of the motivating example, through a channel whose field comments say it exists +so that "a package can ASK instead of DECLARE". + +Asking what the package could already do also produced D16 and, more usefully, +D16a — `target_libc()` reports the payload reference that is fed *into* +target-side resolution while `docs/05` describes it as the resolution's output. +That is a live defect misleading authors right now, it was invisible from the +predicate framing, and it needs fixing whichever way item 8 is decided. + +The shape to name: **when a feature looks unimplemented, ask what a user does +today instead.** The answer is either a workaround worth documenting, a second +mechanism that makes the feature redundant, or a defect in that second +mechanism. Here it was the third. + +**And one this document could not have found without building it.** Every +version of §6 argued about whether `cfg(c-abi = …)` should exist. None of them +asked what the value on the right-hand side would be. It is `gnu` on an +ordinary Linux host — `payload_libc_name` returns the triple's env segment +verbatim — while `docs/14`'s table, the chapter documenting the feature, has +always listed `glibc`, `musl`, `picolibc`. So the implementation landed, every +mechanism worked, and the documented spelling matched nothing. + +Nothing in the analysis was wrong; the question was absent. A predicate is a +comparison, and a comparison has two sides — the design work had examined the +grammar, the evaluation window, the merge, the snapshots, and never once the +vocabulary of the values. **A value that is only ever printed has no spelling +discipline, and promoting it to something users compare against imposes one +retroactively.** Worth checking whenever a reporting field becomes an input. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4458f15c..e58977cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,99 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.9.1.1] — 2026-09-01 + +#540 的七条审计,加上核验它们时挖出的四条没有人报过的。它们几乎全是同一族: +**mcpp 关于自己说了一句话,而 mcpp 不遵守它。** + +完整核验、量化与设计见 +[`.agents/docs/2026-08-31-issue540-seven-audit-findings.md`](.agents/docs/2026-08-31-issue540-seven-audit-findings.md)。 + +> **一条规则写在一处,却由它的一份手抄件来执行。** `kKnownBuildKeys`、 +> `kKnownConditionalBuildKeys` 与 xpkg 的 `target_cfg` 列表,都是别处已有机器可读 +> 形式(紧挨其上的 `doc->get_*()` 读取点、`BuildInputs` 的成员表)的转录,三份都 +> 漂移了 —— 而 `kKnownBuildKeys` 漂移了**两次**,第二次就发生在描述第一次的注释上方 +> 八行。代价不是少了一条警告,而是**一条假的警告**:`[build] has unsupported key +> 'std-module' (ignored)`,说的与实际发生的事情正相反。 + +### 修复 + +- **`[xlings] deps` 的供给从不检查自己是否成功。** `xlings::call` 返回 + `expected`,只要子进程跑起来就处于**值**态 —— 能力自身的状态 + 在 `CallResult` 里面,因为 xlings 讲完 NDJSON 协议后按设计退 0。调用点只测了 + `if (!r)`,于是 xlings 能报出的每一种失败都被读成了成功: + + ``` + $ mcpp build # deps = ["definitely-not-a-real-package"] + Provisioning [xlings] deps (definitely-not-a-real-package) + Finished dev [unoptimized + debuginfo] in 0.12s + ``` + + 记号随后把这次假成功变成**永久**的 —— 下一次构建连 `Provisioning` 都不再打印。 + #531 自己的注释写着它修的缺陷是「声明看起来被接受了却什么都没做,这是一个配置键 + 能有的最坏形态」;没人读结果,它的修法重现了那个形态。正确写法就在同一个文件里 + ——依赖安装路径写的是 `if (r && r->exitCode != 0 && …)`。 + +- **该路径不认 `MCPP_OFFLINE`,也不认 `MCPP_NO_AUTO_INSTALL`。** 它自称与 + `[toolchain]` 平权,而那条先例在任一开关下**硬错**并且报出触发的是哪一个。现在 + 两个都认,拦的是安装**动作**而不是整块 —— 已供给好的工程仍然离线构建得出来。 + +- **记号记录的是全局效果,却存在项目里。** 安装落在 registry(刻意如此,原注释说明 + 了理由),而 `/.mcpp/.xlings-deps.stamp` 记着它。清掉或换掉 `MCPP_HOME`, + 项目仍然声称已装;`mcpp clean` 只删 `target/`,也清不掉。改按依赖列表的哈希存进 + registry,并且**只在成功时写**。 + +- **`[build] std-module` / `std-compat-module` / `std-module-flags` 被读取,却被报成 + unsupported。** 三个键在 #494 被移入 `[build]` 正是为了让它们可条件化,而 + `kKnownBuildKeys` 从未收录 —— 唯一一句关于它们的话说反了。 + +- **条件轴拒绝 `BuildInputs` 的两个成员。** `std-module-flags`(#494 就是为这条轴 + 才把它挪上来的)与 `private_include_dirs` —— 后者更严重:xpkg 描述符的 + `target_cfg` 块,也就是**同一条轴的另一套语法**,是接受它的。两条列表的消息现在都 + 由列表本身生成。 + +- **`[features]` 是唯一一个完全没有 schema 检查的结构化段落。** 把 `include_dirs` + 误写进 feature 里会零诊断地构建成功,而同样的错误写在 `[build]` 里会被报出来。 + +- **`mcpp build --help` / `mcpp test --help` 说默认档位是 release,而它是 dev。** + 六处说得对(解析器、它的注释、docs/05、一条 e2e、mcpp 自己的 mcpp.toml、 + `mcpp pack --help`),两处说错。`prepare.cppm` 里那条字段注释是没被报告的第三处。 + +- **`mcpp index update ` 承诺按索引筛选,而它只筛项目级索引。** 限制此前只写在 + 一条注释里 —— 一个只有实现者看得到的地方,从外面看与「这功能坏了」无从区分。 + +### 新增 + +- **`[target.'cfg(<层> = "…")'.build]` —— 按已解析目标侧条件化(#494 / #540)。** + docs/14 用一整节记载了这个能力,包括为什么它不能用 feature 选择代替;而 + `cfgpred::Ctx` 只由三元组构造,所以每一个这样的段落都被**静默**丢弃,包成功构建 + 在错误的 C 库配置上。五个层名 `compiler` / `compiler-runtime` / `kernel-abi` / + `c-abi` / `c++-abi` 现在是谓词的键,可与三元组键在 `all`/`any`/`not` 下组合。 + + ⚠️ 层谓词**不能**选择依赖 —— 层是从依赖图解析出来的 —— 这种段落会被报出并忽略, + 而不是被静默丢弃。 + +- **mcpp 不认识的 cfg 键会被报出来。** 求值器过去对未知键返回假,而那与「这一段本就 + 不该匹配」读数完全相同。词汇表从求值器**导出**而不是被转录 —— 否则这条诊断自己就 + 会成为本次发布正在修的那第五份手抄件。 + +- **[`docs/spec/exit-codes.md`](docs/spec/exit-codes.md)(SPEC-003)。** 2026-08-08 + 的协议设计文档 §R4 把这份契约指派给了 `docs/spec/`,它一直没有写。`docs/11` 那张表 + 落地的是 usage/internal 的一半;命令**跑了并且失败**时返回的 `1` 既不在表里也不在 + 别处 —— 而按信封命令划定的那张表**给不出** `4`。 + +### 变更 + +- **`c-abi` 层报的是库名,不再是三元组的 env 段。** 二者在 `musl` 上重合,在 `gnu` + 上分叉:Linux 上它请求的是 glibc,Windows 上它命名的是工具链的 MinGW 形态,而后者 + 的 C 运行时是 UCRT。docs/14 一直把实现列作 `glibc`/`musl`/`picolibc`,e2e 296 的 + 文件头也把它期望的报告写作 `c-abi glibc (payload)`。在这个值只被打印的年代这只是 + 措辞不一致;它现在是用户书写的谓词值。请求那一侧保留三元组的拼写(规范 §3.4), + 两者经 `c_abi_request_satisfied` 比较而非按相等。 + +- **`mcpp::target_libc()` 的文档改为它实际回答的问题** —— 供给 sysroot 的那个**载荷** + 包,而这个值是目标侧解析的一项**输入**。要按已解析的层分支,用层谓词。 + ## [2026.8.30.2] — 2026-08-30 六处缺陷,来自 #527 / #529 的分析,外加一处在实现 review 时挖出来、没有人报过的。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index c7a53587..bd5518a2 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1114,16 +1114,31 @@ for arch/env conditions and combinators. `build` with `cflags` / `cxxflags` / `ldflags` / `sources` (mcpp 0.0.95+ — conditional source globs, e.g. gating `src/x86/**/*.asm` behind `cfg(arch = "x86_64")`; `!`-exclusion globs work here too), plus `flags` and - `include_dirs` / `include_dirs_after` (mcpp 0.0.102+). + `include_dirs` / `include_dirs_after` (mcpp 0.0.102+), plus + `private_include_dirs` and `std-module-flags` (mcpp 2026.9.1.1+). - **What `build` accepts is exactly the set of *additive build inputs*** — the things that combine by appending and are consumed after the predicate is - evaluated. `linkage`, `target`, and the profile knobs are deliberately not - among them: they are *inputs to* target selection (conditioning `target` on - a predicate evaluated against `target` is circular), or they need - override-rather-than-append semantics. + evaluated, which is the member list of `BuildInputs`. `linkage`, `target`, + and the profile knobs are deliberately not among them: they are *inputs to* + target selection (conditioning `target` on a predicate evaluated against + `target` is circular), or they need override-rather-than-append semantics. + A key outside the set is reported and ignored; the message lists the set it + checked against, so it cannot drift from the check. - **Evaluated against the resolved target** — the `--target` triple for a cross build, otherwise the host. So a native Linux build never even *downloads* a `[target.windows]` dependency. +- **Predicate keys**: `os`, `arch`, `family`, `env` — the triple's coordinates — + and, from mcpp 2026.9.1.1, the five target-side layer names `compiler`, + `compiler-runtime`, `kernel-abi`, `c-abi`, `c++-abi` + ([14 — The Target Side](14-target-side.md)). Barewords `linux` / `macos` / + `windows` / `unix` are sugar for the matching `os` / `family` test. A key + outside this set is reported as a schema warning and the section does not + apply — it used to answer false in silence, which is indistinguishable from + a section that correctly did not match. +- **A layer predicate cannot select dependencies.** A layer is resolved *from* + the dependency graph, so a dependency chosen by one would decide the answer + it is asking for. `[target.'cfg(c-abi = "musl")'.dependencies]` is reported + and ignored; the `build` inputs under the same predicate do apply. - **Precedence**: an exact-triple table wins over a `cfg`/alias table; multiple matching predicate tables have their flags concatenated. Conditional entries are appended **after** the unconditional `[build]` ones, so under GNU @@ -1178,11 +1193,21 @@ The value is an xpkg reference or the empty string; a bare name is rejected when the manifest is parsed, because accepting it would install nothing and then fail much later naming a missing libc. -A build program can ask which C library was resolved: `mcpp::target_libc()` -returns its package name and `mcpp::target_libc_profile()` the sub-directory for -the target's ISA profile. Both are empty on the zero-libc tier. See +A build program can ask which C library **payload** supplies the sysroot: +`mcpp::target_libc()` returns that package's name and +`mcpp::target_libc_profile()` the sub-directory for the target's ISA profile. +Both are empty on the zero-libc tier. See [13 — Bare-Metal and Freestanding Targets](13-baremetal.md). +⚠️ **That is not the same question as "which C library did the target side +resolve to".** `target_libc()` names the payload mcpp installed, and that value +is an *input* to target-side resolution — a package in the dependency graph can +supply the C library instead, in which case the resolved `c-abi` is not what +this returns. To branch on the resolved layer, use a layer predicate: +`[target.'cfg(c-abi = "musl")'.build]` ([14 — The Target +Side](14-target-side.md)). This paragraph said "which C library was resolved" +until 2026.9.1.1, which was the wrong one of the two. + ### 2.7.2 Bare metal (`os = none`) — freestanding targets `riscv64-none-elf` and `riscv32-none-elf` are targets with no operating system @@ -1304,6 +1329,13 @@ simd = { sources = ["src/simd/**"], flags = [ { glob = "src/simd/**/*.avx2.cpp", cxxflags = ["-mavx2"] } ] } ``` +- **The table form accepts exactly** `implies`, `forward`, `defines`, `sources`, + `flags`, `requires`, `provides`. Anything else is reported as a schema warning + and ignored (mcpp 2026.9.1.1+); `deps` is reported separately as reserved, + because it is planned rather than wrong. Before that release `[features]` was + the one structured section with no schema check at all, so a misplaced + `include_dirs` inside a feature built successfully with no diagnostic while + the identical mistake in `[build]` was reported. - `defines` are **bare** macro names (no `-D`); each desugars to `-D` on the package's own compile when the feature is active — exactly like `[targets.*] defines`. They are restricted by convention to the package's **own** namespaced diff --git a/docs/11-machine-output.md b/docs/11-machine-output.md index 68f1c26c..0bf3a4a4 100644 --- a/docs/11-machine-output.md +++ b/docs/11-machine-output.md @@ -100,15 +100,26 @@ A request that does not yet know what it will be given must not write into the channel the protocol owns. Combined with §1, a client's rule is complete: no JSON on stdout means "not supported", whatever the reason. -Exit codes: +Exit codes **of the enveloped commands** — the kinds `--protocol-version` +advertises. This table is scoped to them on purpose; a code another command +returns is not in it, and adding one would document something these commands +cannot produce. The full mapping across all of mcpp is +[the exit-code contract](spec/exit-codes.md). | code | meaning | |---|---| | 0 | success | +| 1 | the command ran and failed — see stderr, and `diagnostics` when stdout carries an envelope | | 2 | usage error — unknown option, unsupported value | | 70 | internal error (uncaught exception) | | 127 | unknown command | +⚠️ **`1` can arrive with an envelope on stdout.** `mcpp xpkg parse` reports a +descriptor that violates the name form as JSON *and* exits 1: the document is +the answer, and the exit code says the answer is a rejection. §1 still holds — +parse stdout, do not branch on the code — but a client that treats any non-zero +exit as "no output" will discard a document it was given. + ## 4. Effects — what a command does before it prints An IDE with an untrusted-workspace gate has to decide **before** running. diff --git a/docs/13-baremetal.md b/docs/13-baremetal.md index 2af6d6e0..8e8a8373 100644 --- a/docs/13-baremetal.md +++ b/docs/13-baremetal.md @@ -555,12 +555,22 @@ A board-support package is an ordinary mcpp package. It declares the emulator it needs under `[xlings] deps`, exports one C++ module for consumers, and emits its board facts from `build.mcpp`. -⚠️ **A declaration under `[xlings] deps` is not an install trigger.** It is what -lets `mcpp::xpkg_dir` answer *"where did that package land"*; it installs -nothing. A board package listed in the index descriptor's platform `deps` gets -its emulator installed with it, and never meets this. A project that declares -its own — because no board package serves its machines — will find `xpkg_dir` -empty on a clean machine and must say so: +**A declaration under `[xlings] deps` provisions the package on the first +build** (since 2026.8.29). It is also what lets `mcpp::xpkg_dir` answer *"where +did that package land"*. Both halves matter: the same declaration installs the +emulator and tells the build program where it went. + +The provisioning is the contract `[toolchain]` has always had — declare it, +mcpp installs it on first use — and it obeys the same two knobs: under +`--offline` / `MCPP_OFFLINE` or `MCPP_NO_AUTO_INSTALL` mcpp refuses instead, +naming the packages so they can be installed out of band. + +⚠️ **A build program still must not assume the directory exists.** Provisioning +runs for the package that DECLARES the deps; a build program can be reached +through paths where that has not happened — a dependency of a project that +declares nothing, an environment where the knobs above refused — so +`xpkg_dir` may still come back empty and the program must say so rather than +emitting a broken runner: ```cpp if (const char* dir = mcpp::xpkg_dir("xim", "qemu-riscv"); dir && *dir) { diff --git a/docs/14-target-side.md b/docs/14-target-side.md index 4303a1c0..d018a2f3 100644 --- a/docs/14-target-side.md +++ b/docs/14-target-side.md @@ -17,7 +17,7 @@ The target side of a build consists of five layers. | `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 | `glibc`, `musl`, `picolibc`, `ucrt`, `libSystem` | | `c++-abi` | the C++ library and its ABI runtime | `libc++` with `libc++abi`, `libstdc++`, MSVC STL | ### Membership Criteria @@ -263,10 +263,38 @@ 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. +The predicate keys are the five layer names, and their values are the interface +names in the table at the top of this chapter — the same strings the `Target` +report prints. They combine with the triple keys under `all`/`any`/`not`: + +```toml +[target.'cfg(all(linux, c-abi = "musl"))'.build] +cxxflags = ["-D_GNU_SOURCE"] +``` + +⚠️ **A layer names the library, not the triple's env segment.** They coincide +for `musl` and diverge for `gnu`: on Linux that segment asks for glibc, and on +Windows it names the MinGW flavour of the toolchain, whose C runtime is the same +UCRT the MSVC flavour links. The spelling is `c-abi = "glibc"`, never +`c-abi = "gnu"`; the request, as opposed to the answer, is `env = "gnu"` — a +different question (`docs/spec/target-side.md` §3.4). + +⚠️ **`env` and `c-abi` are not interchangeable.** `env` is what the triple +*asked* for; `c-abi` is what the graph and the payload *answered*. An +`openkal-musl` in the dependency graph supplies musl under an `x86_64-linux-gnu` +triple, and only `c-abi` sees that. + 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]`. +form a cycle; `[target.'cfg( = …)'.dependencies]` is reported and +ignored rather than silently dropped. A package whose C libraries require +different dependencies is split per C library, or depends on the union and +selects sources in `[build]`. + +A key mcpp does not know — a typo, or a predicate from a newer mcpp — is +reported as a schema warning and the section does not apply. It used to +evaluate to false in silence, which reads exactly like a section that correctly +did not match. ## Diagnostics diff --git a/docs/17-the-project-environment.md b/docs/17-the-project-environment.md index 4e86c43e..c0ac24d7 100644 --- a/docs/17-the-project-environment.md +++ b/docs/17-the-project-environment.md @@ -111,9 +111,18 @@ gives the full rule. question does not arise. Against that: an isolated environment is a directory that has to be created and -populated before the first build, and mcpp will not do it. A project whose -tools are ordinary and whose versions do not matter is better off declaring -nothing and inheriting the machine's. +populated, and the first build pays for it. Since 2026.8.29 mcpp does that +work — a declared `[xlings] deps` is provisioned on first use, and a named +`[xlings] subos` that does not exist yet is created rather than refused — but +the cost is real: the first build on a clean machine downloads and installs +before it compiles anything. A project whose tools are ordinary and whose +versions do not matter is better off declaring nothing and inheriting the +machine's. + +Under `--offline` / `MCPP_OFFLINE` or `MCPP_NO_AUTO_INSTALL`, mcpp refuses +instead of installing, and names the packages so they can be provisioned +out of band — the same two knobs `[toolchain]` honours, for the same reason: an +unasked-for download is not something a build decides on a project's behalf. ## 6. What belongs somewhere else diff --git a/docs/spec/README.md b/docs/spec/README.md index 5d392c7d..37516535 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -32,6 +32,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 | +| [SPEC-003](exit-codes.md) | 退出码契约(分类、语义、稳定性承诺) | 评审中 v1.0 | 2026-09-01 | mcpp >= 2026.9.1.1 | ## 文档约定 diff --git a/docs/spec/exit-codes.md b/docs/spec/exit-codes.md new file mode 100644 index 00000000..d15712ae --- /dev/null +++ b/docs/spec/exit-codes.md @@ -0,0 +1,101 @@ +# SPEC-003:退出码契约 + +| 项 | 值 | +|---|---| +| 规范编号 | SPEC-003 | +| 标题 | mcpp 的进程退出码:分类、语义与稳定性承诺 | +| 状态 | 评审中 v1.0 | +| 版本 | 1.0 | +| 最后修改 | 2026-09-01 | +| 对应实现 | mcpp >= 2026.9.1.1 | +| 相关设计文档 | `.agents/docs/2026-08-08-machine-readable-output-protocol-design.md` §R4、`.agents/docs/2026-08-31-issue540-seven-audit-findings.md` §4 | +| 相关 issue | #379、#540 | + +## 0. 这份规范为什么存在 + +机器可读输出协议的设计记录(§R4)在实测四个退出码之后写下: + +> 光接管 parse error 不够,还要把 usage / runtime / internal 的 rc 映射写成契约, +> 并覆盖异常边界 —— 否则客户端仍然要靠猜。**这条现在是 `docs/spec/` 的内容,不是 +> 代码。** + +被指派的那份契约一直没有写。`docs/11-machine-output.md` 落地了其中的 +usage / internal 一半(`2`、`70`、`127`),runtime 的一半 —— 也就是命令**跑了并且 +失败**时返回的 `1` —— 既不在那张表里,也不在别处。#540 由此把表读成「不完整」,并 +提出补 `4`;而 `4` 恰恰是那几个带信封的命令给不出的码。 + +两件事都是同一个缺口的症状:**没有一处说明 mcpp 一共会返回哪些码。** 这份规范是那 +一处。 + +## 1. 适用范围 + +本规范约束 `mcpp` 可执行文件**自身**的进程退出码。 + +**不**约束: + +- 子进程(编译器、链接器、ninja、xlings、`build.mcpp`)的退出码。它们由 mcpp 解释, + 不会直接透传; +- `mcpp test` 所运行的测试二进制的退出码。测试失败在 mcpp 这一层是一次运行期失败, + 按 §2 归入 `1`; +- 库层 API 的返回值。`status_severity()` 这类返回 `0..3` 的函数是**严重性排序**, + 与退出码无关,**禁止**被读作退出码。 + +## 2. 码表 ✅ 已实现 + +| 码 | 类别 | 含义 | 通道 | +|---|---|---|---| +| `0` | 成功 | 命令完成了它承诺的事 | 正常输出走 stdout | +| `1` | 运行期失败 | 命令跑了、请求合法、结果是失败 | 人类可读的原因走 stderr | +| `2` | 用法错误 | 未知选项、不支持的选项值、缺少必需参数 | stderr | +| `4` | 环境未就绪 | 全局配置加载 / 首次初始化失败(`$MCPP_HOME` 不可写、config.toml 损坏、引导 xlings 失败) | stderr | +| `70` | 内部错误 | 未捕获异常。`EX_SOFTWARE` | stderr | +| `127` | 未知命令 | 第一个位置参数不是一个子命令 | stderr | + +`4` 与 `1` 的分界是**谁需要被修**:`4` 说明 mcpp 自己的家还没有准备好,任何命令都会 +撞上同一堵墙;`1` 说明这一次请求失败了,而 mcpp 是可用的。把二者合并会让「我的工程 +有问题」和「我这台机器上的 mcpp 有问题」同读数。 + +`70` 与 `1` 的分界是**这是不是一个缺陷**:`70` 一律意味着 mcpp 有 bug,值得开 +issue;`1` 通常不是。 + +## 3. 规则 + +### 3.1 分类必须稳定 ✅ 已实现 + +一个已经发布的失败场景**禁止**在后续版本里改变它所属的类别。新增类别**可以**引入新 +的码;把既有场景从 `1` 挪到 `4`(或反向)是破坏性变更。 + +### 3.2 用法错误必须先于副作用 ✅ 已实现 + +返回 `2` 的路径**禁止**产生任何副作用。一个还不知道自己会被要求做什么的请求,不该 +已经写过磁盘。 + +### 3.3 退出码禁止用于协议识别 ✅ 已实现 + +客户端**禁止**用退出码判断「这个 mcpp 支不支持某项功能」。理由见 +`docs/11-machine-output.md` §1:在该协议出现之前发布的每个版本上,未知选项本身就是 +一次错误,而它当年走的是 stdout + 退出码 1。唯一跨版本成立的判据是**解析 stdout**。 + +### 3.4 `1` 可以与 stdout 上的信封同时出现 ✅ 已实现 + +一次失败**可以**同时是一份文档。`mcpp xpkg parse` 对一份违反名字形态的描述符会把 +判定作为 JSON 打到 stdout **并且**退 `1`。 + +因此客户端**禁止**把非零退出当作「没有输出」而跳过解析。这与 §3.3 是同一条规则的两 +个方向:退出码不携带「有没有输出」的信息。 + +### 3.5 非零退出必须在 stderr 上有原因 ✅ 已实现 + +任何非零退出**必须**在 stderr 上留下至少一行说明。空 stderr 加非零退出是缺陷。 + +## 4. 当前实现与本规范的差异 + +无。§2 的六个码是 `src/` 中出现的全部进程退出码(2026-09-01 穷举核对: +`return 0/1/2/4/127`、`main.cpp` 的 `rc = 70`;`3`、`5`、`9`、`19`、`23` 这几个数出现 +在严重性排序、ELF 重定位类型表与 MSVC 版本解析里,均不是退出码)。 + +## 5. 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| 1.0 | 2026-09-01 | 首版。落地 2026-08-08 协议设计文档 §R4 指派而未写的契约;补上 `1` 与 `4`,并说明为什么 `4` 不属于 `docs/11` 那张按信封命令划定的表(#540)。 | diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index e7e8f063..d035d798 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -978,13 +978,24 @@ cxxflags = ["-march=x86-64-v2"] 只有它的依赖集合受限定),以及带 `cflags` / `cxxflags` / `ldflags` / `sources` 的 `build`(mcpp 0.0.95+ —— 条件源码 glob,例如把 `src/x86/**/*.asm` 收在 `cfg(arch = "x86_64")` 之后;`!` 排除 glob 在此同样有效),再加 `flags` 与 - `include_dirs` / `include_dirs_after`(mcpp 0.0.102+)。 + `include_dirs` / `include_dirs_after`(mcpp 0.0.102+), + 以及 `private_include_dirs` 与 `std-module-flags`(mcpp 2026.9.1.1+)。 - **`build` 接受的恰好是*可叠加的构建输入*集合** —— 那些以追加方式合并、 - 并在谓词求值之后被消费的东西。`linkage`、`target` 与档案开关刻意不在其中: - 它们是**目标选择的输入**(用一个针对 `target` 求值的谓词去条件化 `target` - 是循环的),或者需要覆盖而非追加的语义。 + 并在谓词求值之后被消费的东西,也就是 `BuildInputs` 的成员表。`linkage`、`target` + 与档案开关刻意不在其中:它们是**目标选择的输入**(用一个针对 `target` 求值的谓词 + 去条件化 `target` 是循环的),或者需要覆盖而非追加的语义。 + 集合之外的键会被报出并忽略;消息里列出的正是它比对用的那份集合,因此不会与检查漂移。 - **按解析后的目标求值** —— 交叉构建取 `--target` 三元组,否则取宿主。因此原生 Linux 构建**根本不会下载** `[target.windows]` 依赖。 +- **谓词的键**:`os`、`arch`、`family`、`env` —— 三元组的坐标 —— 以及自 mcpp + 2026.9.1.1 起的五个目标侧层名 `compiler`、`compiler-runtime`、`kernel-abi`、 + `c-abi`、`c++-abi`(见[14 —— 目标侧](14-target-side.md))。裸词 + `linux` / `macos` / `windows` / `unix` 是对应 `os` / `family` 判定的糖。 + 集合之外的键会被报成一条 schema 警告,且该段不生效 —— 它过去静默地求值为假, + 而那与「这一段本就不该匹配」读数完全相同。 +- **层谓词不能选择依赖。** 层是**从**依赖图解析出来的,因此由它选出的依赖会决定它 + 正在询问的那个答案。`[target.'cfg(c-abi = "musl")'.dependencies]` 会被报出并忽略; + 同一谓词下的 `build` 输入照常生效。 - **优先级**:精确三元组表胜过 `cfg`/别名表;多个命中的谓词表,其 flag 按序拼接。 条件项追加在无条件 `[build]` 项**之后**,因此在 GNU「最后一个 flag 生效」的 规则下,条件规则会覆盖更宽的无条件规则。这正是让按 OS **移除**成为可表达的原因: @@ -1030,8 +1041,15 @@ sysroot = "" # no C library at all 取值是 xpkg 引用或空字符串;裸名在解析清单时即被拒绝,因为接受它会导致什么都不安装, 然后在很晚的时候以「缺少 libc」失败。 -构建程序可以询问解析到的是哪份 C 库:`mcpp::target_libc()` 返回其包名, -`mcpp::target_libc_profile()` 返回目标 ISA 档位对应的子目录。零 libc 档上两者均为空。 +构建程序可以询问供给 sysroot 的是哪个 C 库**载荷**:`mcpp::target_libc()` 返回该包的 +名字,`mcpp::target_libc_profile()` 返回目标 ISA 档位对应的子目录。零 libc 档上两者均 +为空。 + +⚠️ **这与「目标侧解析出的 C 库是哪一个」不是同一个问题。** `target_libc()` 命名的是 +mcpp 装上的那个载荷,而这个值是目标侧解析的一项**输入** —— 依赖图里的包可以改为供给 +C 库,那时解析出的 `c-abi` 就不是这里返回的东西。要按已解析的层分支,请用层谓词: +`[target.'cfg(c-abi = "musl")'.build]`(见[14 —— 目标侧](14-target-side.md))。 +这一段在 2026.9.1.1 之前写的是「解析到的是哪份 C 库」,那是两者里错的那一个。 参见[13 —— 裸机与 freestanding 目标](13-baremetal.md)。 ### 2.7.2 裸机(`os = none`)—— freestanding target @@ -1130,6 +1148,11 @@ simd = { sources = ["src/simd/**"], flags = [ { glob = "src/simd/**/*.avx2.cpp", cxxflags = ["-mavx2"] } ] } ``` +- **表形式恰好接受** `implies`、`forward`、`defines`、`sources`、`flags`、 + `requires`、`provides`。其余键会被报成一条 schema 警告并忽略(mcpp 2026.9.1.1+); + `deps` 单独报为「保留」,因为它是计划中的而不是写错的。在该版本之前,`[features]` + 是唯一一个完全没有 schema 检查的结构化段落 —— 把 `include_dirs` 误写进 feature 里 + 会零诊断地构建成功,而同样的错误写在 `[build]` 里会被报出来。 - `defines` 为**裸**宏名(不带 `-D`);feature 激活时每个脱糖为 `-D`,加到该包 自己的编译上——与 `[targets.*] defines` 完全一致。按约定仅限包**自有**的带命名 空间宏:feature **不**注入自由的包级 `cflags`/`ldflags`,否则会破坏加性的 feature diff --git a/docs/zh/11-machine-output.md b/docs/zh/11-machine-output.md index 4490c0b0..120cb5d9 100644 --- a/docs/zh/11-machine-output.md +++ b/docs/zh/11-machine-output.md @@ -82,13 +82,23 @@ $ echo $? 一个还不知道自己会得到什么格式的请求,不该往协议独占的通道里写东西。结合 §1,客户端的 规则就完整了:**stdout 上没有 JSON,就是不支持**,无论原因。 +**带信封命令**的退出码 —— 即 `--protocol-version` 声明的那几个 kind。这张表刻意只管 +它们:别的命令返回的码不在其中,写进来等于记录一个这些命令给不出的值。mcpp 全局的 +映射见[退出码契约](../spec/exit-codes.md)。 + | 退出码 | 含义 | |---|---| | 0 | 成功 | +| 1 | 命令跑了并且失败 —— 见 stderr;当 stdout 带信封时见 `diagnostics` | | 2 | 用法错误 —— 未知选项、不支持的值 | | 70 | 内部错误(未捕获异常) | | 127 | 未知命令 | +⚠️ **`1` 可能与 stdout 上的信封同时出现。** `mcpp xpkg parse` 会把一份违反名字形态的 +描述符作为 JSON 报出来**并且**退 1:文档就是答案,退出码说明这个答案是一次拒绝。§1 +仍然成立 —— 解析 stdout,不要按退出码分支 —— 但一个把任何非零退出都当作「没有输出」 +的客户端,会丢掉它已经拿到的文档。 + ## 4. effects —— 命令在输出前执行的操作 带 untrusted-workspace 门的 IDE 必须在**运行之前**决定。等信封到手,它描述的事情已经 diff --git a/docs/zh/13-baremetal.md b/docs/zh/13-baremetal.md index 26622b38..399a439b 100644 --- a/docs/zh/13-baremetal.md +++ b/docs/zh/13-baremetal.md @@ -497,10 +497,18 @@ error: no runner is configured for 'riscv64-none-elf' — a freestanding artifac 板级支持包是一个普通的 mcpp 包。它在 `[xlings] deps` 下声明所需的模拟器,为消费者 导出一个 C++ 模块,并从 `build.mcpp` 发出它的板级事实。 -⚠️ **`[xlings] deps` 里的声明不是安装触发器。** 它让 `mcpp::xpkg_dir` 能回答「那个包 -落在哪」,自己不装任何东西。写在索引描述符平台 `deps` 里的板级包,其模拟器**随包安装**, -遇不到这件事;而一个自己声明模拟器的工程 —— 因为没有哪个板级包服务它的那些机器 —— -会在干净机器上拿到空的 `xpkg_dir`,此时必须说出来: +**`[xlings] deps` 里的声明会在首次构建时供给该包**(2026.8.29 起)。它同时让 +`mcpp::xpkg_dir` 能回答「那个包落在哪」。两半都要紧:同一条声明既装上模拟器,也告诉 +构建程序它装到了哪。 + +这次供给用的正是 `[toolchain]` 一直以来的契约 —— 声明它,mcpp 在首次使用时装上 —— +并且遵守同样两个开关:在 `--offline` / `MCPP_OFFLINE` 或 `MCPP_NO_AUTO_INSTALL` 下 +mcpp 转为拒绝,并列出包名以便手动安装。 + +⚠️ **构建程序仍然不得假定目录存在。** 供给发生在**声明**了这些 deps 的那个包上; +构建程序可以从没有发生过供给的路径被走到 —— 一个自己什么都没声明的工程的依赖、 +上面两个开关拒绝掉的环境 —— 所以 `xpkg_dir` 仍可能返回空,此时必须说出来,而不是 +发出一个坏掉的 runner: ```cpp if (const char* dir = mcpp::xpkg_dir("xim", "qemu-riscv"); dir && *dir) { diff --git a/docs/zh/14-target-side.md b/docs/zh/14-target-side.md index f23ee673..ece0413e 100644 --- a/docs/zh/14-target-side.md +++ b/docs/zh/14-target-side.md @@ -15,7 +15,7 @@ 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 库 | `glibc`、`musl`、`picolibc`、`ucrt`、`libSystem` | | `c++-abi` | C++ 库及其 ABI 运行时 | `libc++` 与 `libc++abi`、`libstdc++`、MSVC STL | ### 成为层的判据 @@ -223,10 +223,33 @@ include_dirs = ["config/picolibc"] 若此处要求一次特性选择,将迫使工程重述目标三元组或其依赖图已经确立的事实, 并允许两处陈述互相矛盾。 +谓词的键就是五个层名,值就是本章开头那张表里的接口名 —— 与 `Target` 报告打印的是 +同一批字符串。它们可以与三元组键在 `all`/`any`/`not` 下组合: + +```toml +[target.'cfg(all(linux, c-abi = "musl"))'.build] +cxxflags = ["-D_GNU_SOURCE"] +``` + +⚠️ **层名的是库,不是三元组的 env 段。** 二者在 `musl` 上重合,在 `gnu` 上分叉: +在 Linux 上该段请求的是 glibc,在 Windows 上它命名的是工具链的 MinGW 形态, +而后者的 C 运行时与 MSVC 形态链接的是同一个 UCRT。写法是 `c-abi = "glibc"`, +而非 `c-abi = "gnu"`;与答案相对的那个「请求」是 `env = "gnu"` —— 另一个问题 +(`docs/spec/target-side.md` §3.4)。 + +⚠️ **`env` 与 `c-abi` 不可互换。** `env` 是三元组**请求**的东西;`c-abi` 是图与载荷 +**回答**的东西。依赖图里的 `openkal-musl` 会在 `x86_64-linux-gnu` 三元组下供给 musl, +而只有 `c-abi` 看得见这件事。 + 这些谓词仅在 `[build]` 段中可用。目标侧在依赖解析之后才被解析, -因此由它选择的依赖将构成环。一个在不同 C 库下需要不同依赖的包, +因此由它选择的依赖将构成环;`[target.'cfg(<层> = …)'.dependencies]` 会被报出并忽略, +而不是被静默丢弃。一个在不同 C 库下需要不同依赖的包, 按 C 库拆分,或依赖其并集并在 `[build]` 中选择源码。 +mcpp 不认识的键 —— 打错的字,或来自更新版本 mcpp 的谓词 —— 会被报成一条 schema +警告,并且该段不生效。它过去是静默地求值为假,而那与「这一段本就不该匹配」读数完全 +相同。 + ## 诊断 四种情形由引擎而非由编译器报出。 diff --git a/docs/zh/17-the-project-environment.md b/docs/zh/17-the-project-environment.md index 37077e15..972bc0a5 100644 --- a/docs/zh/17-the-project-environment.md +++ b/docs/zh/17-the-project-environment.md @@ -90,8 +90,15 @@ create/bootstrap that environment instead of falling back to active/default - **同一台机器上两个项目需要同一工具的不同版本。** 共享目录意味着必有一方落 败;私有环境让这个问题不成立。 -代价一侧:隔离环境是一个必须在首次构建前创建并填充的目录,而 mcpp 不会代劳。 -工具很普通、版本也无所谓的项目,不声明、直接继承机器的那份更划算。 +代价一侧:隔离环境是一个必须被创建并填充的目录,而这笔账由首次构建来付。 +2026.8.29 起 mcpp 会做这件事 —— 声明在 `[xlings] deps` 里的包在首次使用时被供给, +一个尚不存在的具名 `[xlings] subos` 会被创建而不是被拒绝 —— 但代价是实打实的: +干净机器上的第一次构建会先下载安装,然后才编译。工具很普通、版本也无所谓的项目, +不声明、直接继承机器的那份更划算。 + +在 `--offline` / `MCPP_OFFLINE` 或 `MCPP_NO_AUTO_INSTALL` 下,mcpp 转为拒绝而不是 +安装,并列出包名以便手动供给 —— 与 `[toolchain]` 遵守的是同样两个开关,理由也相同: +一次没被要求的下载,不该由构建替工程决定。 ## 6. 什么该写在别处 diff --git a/mcpp.toml b/mcpp.toml index 39ca2d93..49c41888 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.30.2" +version = "2026.9.1.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/manifest/src/targetside_model.cppm b/modules/manifest/src/targetside_model.cppm index b6c83c23..034a6502 100644 --- a/modules/manifest/src/targetside_model.cppm +++ b/modules/manifest/src/targetside_model.cppm @@ -443,13 +443,42 @@ struct Inputs { // 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. +// ⚠️ THE TRIPLE'S ENV SEGMENT IS A TRIPLE SPELLING, NOT A C LIBRARY'S NAME, AND +// THE TWO COINCIDE ONLY SOMETIMES. `musl` is both. `gnu` is neither: on Linux it +// means glibc, and on Windows it names the MinGW flavour of the toolchain, whose +// C runtime is the same UCRT the MSVC flavour links. +// +// Returning the segment verbatim reported `c-abi gnu (payload)` for an ordinary +// Linux build while docs/14 has always named the implementations `glibc`, +// `musl`, `picolibc` — and e2e 296's own header describes the report it expects +// as `c-abi glibc (payload)`. That was a cosmetic disagreement for as long as +// the value was only printed. It stopped being cosmetic when +// `[target.'cfg(c-abi = "glibc")'.build]` became a predicate a user writes: the +// documented spelling would have matched nothing, silently, which is the exact +// defect class this release exists to remove. +// +// The REQUEST keeps the segment's spelling — `requestedCAbi` is the triple's +// env verbatim by definition (docs/spec/target-side.md §3.4) — so the two are +// compared through `c_abi_request_satisfied` rather than by equality. inline std::string payload_libc_name(std::string_view os, std::string_view env) { + if (env == "gnu") return os == "windows" ? "ucrt" : "glibc"; if (!env.empty()) return std::string(env); if (os == "macos") return "libSystem"; if (os == "windows") return "ucrt"; return "glibc"; } +// Does the resolved C library answer what the triple's env segment asked for? +// Equality plus the one alias the segment carries, so that renaming the ANSWER +// above does not turn every `-gnu` build into a reported request mismatch. +inline bool c_abi_request_satisfied(std::string_view requested, + std::string_view resolved) { + if (requested == resolved) return true; + if (requested == "gnu") return resolved == "glibc" || resolved == "ucrt"; + if (requested == "glibc") return resolved == "gnu"; + return false; +} + // 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) { @@ -711,7 +740,11 @@ inline std::optional check_request(const TargetSide& ts) { return std::nullopt; if (ts.requestedCAbi.empty()) return std::nullopt; if (ts.cAbi.absent()) return std::nullopt; - if (ts.cAbi.interfaceName == ts.requestedCAbi) return std::nullopt; + // Through the alias helper, not by equality: the request keeps the triple's + // `gnu` spelling and the answer now names the library (`glibc`/`ucrt`), so + // plain equality would report a mismatch on every ordinary `-gnu` build. + if (c_abi_request_satisfied(ts.requestedCAbi, ts.cAbi.interfaceName)) + return std::nullopt; // A prebuilt or payload C library IS what the request selected — the // request is how it was selected. Only a supplier chosen by something else // can disagree with it. @@ -880,7 +913,7 @@ inline std::string format_report(const TargetSide& ts, std::string_view targetNa if (ts.envAxis == EnvAxis::ObjectFormat && !ts.requestedCAbi.empty() && !ts.cAbi.absent() && ts.cAbi.fromGraph() - && ts.cAbi.interfaceName != ts.requestedCAbi) { + && !c_abi_request_satisfied(ts.requestedCAbi, ts.cAbi.interfaceName)) { head += std::format(" ({} selects {}, not a C library)", ts.requestedCAbi, env_axis_noun(ts.envAxis, ts.requestedCAbi)); diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index ec293cc8..3eab6c4b 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -504,6 +504,53 @@ std::expected parse_string(std::string_view content, if (auto fwd = mcpp::pm::split_feature_forward_token(tok)) m.featureForwards[fname].push_back(std::move(*fwd)); m.featuresMap[fname] = std::move(localImplies); + + // #540: the table form is the ONE structured manifest section that + // had no schema check, so `include_dirs` written inside a feature + // built successfully with zero diagnostics — while the identical + // misplacement in `[build]` or `[target..build]` is reported. + // + // MUST stay in sync with the reads above. Warning, not error, and + // root-manifest-only in effect (prepare surfaces schemaWarnings for + // the root before any dependency manifest is loaded), so a package + // may adopt a future key before its consumers upgrade — the same + // property #515 measured for `[build] private_include_dirs`. + if (fval.is_table()) { + static constexpr std::string_view kKnownFeatureKeys[] = { + "defines", "flags", "forward", "implies", "provides", + "requires", "sources", + }; + for (auto& [fkey, fignored] : fval.as_table()) { + (void)fignored; + if (std::ranges::find(kKnownFeatureKeys, fkey) + != std::end(kKnownFeatureKeys)) continue; + // `deps` is named apart because it is RESERVED rather than + // wrong: the comment above this block has promised it since + // Feature System v2 and nothing reads it yet. Saying + // "unsupported" would deny a documented plan; saying nothing + // is what let it look implemented. + if (fkey == "deps") { + m.schemaWarnings.push_back(std::format( + "[features].{}.deps is reserved for a later stage and " + "is not read yet (ignored). Declare dependencies in " + "[dependencies] and gate them with " + "`optional = true` + `implies`.", fname)); + continue; + } + std::string supported; + for (auto k : kKnownFeatureKeys) { + if (!supported.empty()) supported += ", "; + supported += k; + } + m.schemaWarnings.push_back(std::format( + "[features].{} has unsupported key '{}' (ignored). " + "Supported keys: {}. A feature contributes build INPUTS " + "through `sources`, `defines` and `flags`; include " + "directories and compiler flags belong to [build] or to " + "a `flags` entry, not directly to the feature.", + fname, fkey, supported)); + } + } } } @@ -1325,6 +1372,12 @@ std::expected parse_string(std::string_view content, "private_include_dirs", "jobs", "ldflags", "macos_deployment_target", "module_extensions", "profile", "sources", "static_stdlib", "target", + // #540: read a few hundred lines above and, until now, absent here — + // the SECOND drift of this list, and the comment below narrates the + // first. Moved to `[build]` by #494 precisely so their flags could be + // conditioned; a manifest writing the documented spelling was told the + // key had been ignored while it was taking effect. + "std-compat-module", "std-module", "std-module-flags", }; if (auto* bt = doc->get_table("build")) { for (auto& [key, _] : *bt) { @@ -1945,6 +1998,22 @@ std::expected parse_string(std::string_view content, // a set of build inputs, so it reads the same way. read_paths("include_dirs", cc.inputs.includeDirs); read_paths("include_dirs_after", cc.inputs.includeDirsAfter); + // #540: two BuildInputs members the list below claimed to carry + // and this loop never read. + // + // `std-module-flags` is the one #494 moved into `[build]` FOR + // this axis — its comment on the struct member says membership + // "is what makes the cfg axis carry it", with `-D_GNU_SOURCE` + // right for musl and glibc and wrong for picolibc as the case. + // The member and the merge landed; the read did not. + // + // `private_include_dirs` is worse: the xpkg descriptor's + // `target_cfg` block, the OTHER grammar for this same axis, + // accepts it (xpkg.cppm) — so one spelling of a conditional + // private include dir worked and the other reported the key as + // unsupported. + read_list ("std-module-flags", cc.inputs.stdModuleFlags); + read_paths("private_include_dirs", cc.inputs.privateIncludeDirs); if (auto f = bt.find("flags"); f != bt.end()) { if (auto err = parse_glob_flags_value( f->second, @@ -1960,24 +2029,35 @@ std::expected parse_string(std::string_view content, // with types.cppm's BuildInputs. static constexpr std::string_view kKnownConditionalBuildKeys[] = { "cflags", "cxxflags", "defines", "flags", - "include_dirs", "include_dirs_after", "ldflags", "sources", + "include_dirs", "include_dirs_after", "ldflags", + "private_include_dirs", "sources", "std-module-flags", }; for (auto& [key, _] : bt) { bool known = false; for (auto k : kKnownConditionalBuildKeys) if (key == k) { known = true; break; } if (!known) { + // ⚠️ THE LIST IN THE MESSAGE IS THE SAME LIST, for the + // reason spelled out at kKnownBuildKeys: a hand-written + // second copy of a vocabulary drifts, and the drift + // surfaces as a message that names the wrong set. This + // one had spelled the eight keys out in prose and was + // two members behind the struct it claims to mirror. + std::string supported; + for (auto k : kKnownConditionalBuildKeys) { + if (!supported.empty()) supported += ", "; + supported += k; + } m.schemaWarnings.push_back(std::format( "[target.{}.build] has unsupported key '{}' (ignored). " "A conditional section may only contribute build INPUTS: " - "sources, cflags, cxxflags, ldflags, defines, flags, " - "include_dirs, include_dirs_after. Selection knobs " + "{}. Selection knobs " "(target, linkage) and profile settings are resolved " "before the predicate is evaluated and cannot be " "conditioned; the C++ runtime contract IS " "per-target, but it is spelled " "[target.].cxx_runtime, beside `linkage`.", - triple, key)); + triple, key, supported)); } } } diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index fb5f0929..19c99e35 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.30.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.1.1"; } // namespace mcpp diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 3e6a49fe..fb9ba677 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -253,6 +253,13 @@ void merge_conditional_config(mcpp::manifest::Manifest& m, const bool generatedPackage = mcpp::pack::is_distribution_package(m); for (auto const& cc : m.conditionalConfigs) { + // ⚠️ THE TWO PASSES MUST BE DISJOINT, AND `matches()` ALONE DOES NOT + // MAKE THEM SO. A layer key answers false here because `layersKnown` is + // false — but `cfg(any(linux, c-abi = "musl"))` still matches on its + // triple leg, and the second pass would match it again and `append()` + // the same inputs twice. Membership, not the answer, decides ownership: + // a predicate that NAMES a layer belongs to the second pass entirely. + if (cfgpred::uses_layer(cc.predicate)) continue; if (!cfgpred::matches(cc.predicate, ctx)) continue; const bool neutralWins = generatedPackage && (!cc.linkLibraryDirs.empty() || !cc.libraries.empty()); @@ -328,6 +335,57 @@ void fold_build_defines_into_flags(mcpp::manifest::BuildConfig& bc) { bc.defines.clear(); } +// ── The SECOND conditional pass: predicates that name a target-side layer ──── +// +// #540/#494. `docs/14` documents a package adapting to the C library it was +// built over — `[target.'cfg(c-abi = "musl")'.build] std-module-flags = +// ["-D_GNU_SOURCE"]`, wrong for picolibc — and `stdModuleFlags` was moved onto +// BuildInputs FOR this, its member comment saying membership "is what makes the +// cfg axis carry it". Nothing evaluated the predicate: `cfgpred::Ctx` was built +// from the triple alone, so every such section was dropped in silence and the +// package built with the wrong C-library configuration, successfully. +// +// WHY A SECOND PASS AND NOT AN EARLIER CONTEXT. A layer is answerable only +// after dependency resolution — a package in the graph may supply the C library +// (openkal-musl under a `-gnu` triple), which is exactly why the triple's `env` +// segment is a REQUEST and not the answer (docs/spec/target-side.md §3.4). The +// first merge runs before resolution because conditional DEPENDENCIES have to. +// +// WHERE IT RUNS. Between `tsd::resolve` and the P1689 scan — the same window in +// which build.mcpp already contributes build inputs by mirroring into +// `packages[0]`. Everything downstream reads the snapshot from there on: the +// scan, `stdModuleFlags` collection, the fingerprint, and `compute_flags`. +// +// SCOPE. Build INPUTS only, which is what docs/14 promises ("available in +// [build] sections only"). Dependencies are excluded by construction — they are +// already resolved by now — and a section that tries is reported rather than +// silently ignored; see `warn_layer_predicate_dependencies`. +bool merge_layer_conditional_config(mcpp::manifest::Manifest& m, + const cfgpred::Ctx& ctx) +{ + bool any = false; + for (auto const& cc : m.conditionalConfigs) { + if (!cfgpred::uses_layer(cc.predicate)) continue; + if (!cfgpred::matches(cc.predicate, ctx)) continue; + any = true; + mcpp::manifest::append(m.buildConfig, cc.inputs); + // Same mirror the first pass does: `modules.sources` is the scanner's + // own view and is not a BuildInputs member. + for (auto const& s : cc.inputs.sources) + m.modules.sources.push_back(s); + for (auto const& d : cc.linkLibraryDirs) + m.runtimeConfig.linkIntent.linkLibraryDirs.push_back(d); + for (auto const& l : cc.libraries) + m.runtimeConfig.linkIntent.libraries.push_back(l); + } + // Re-fold only when something was added. The call is safe either way — the + // function clears `defines` after folding and says so — but skipping it + // keeps this pass a no-op for the overwhelming majority of manifests, which + // name no layer at all. + if (any) fold_build_defines_into_flags(m.buildConfig); + return any; +} + // Feature-activation closure — THE single implementation (build.mcpp env // contract, Stage 2a feature-deps, and the main feature pass all call this): // seed = [features].default ∪ requested, expanded transitively over implies; @@ -764,7 +822,12 @@ export struct BuildOverrides { std::string target_triple; // empty = host triple, fall through to [toolchain] bool force_static = false; // --static (or implied by musl target) std::string package_filter; // -p : only build this workspace member - std::string profile; // --profile (default "release") + // --profile . Empty = fall through to `[build] default-profile`, then + // to `profile_fallback` below, whose own default is "dev". The comment here + // said "release" for as long as `mcpp build --help` did, and neither had + // been true since the global default moved (see resolve_profile_name and + // tests/e2e/87_build_default_profile.sh). + std::string profile; // What `resolve_profile_name` falls back to when neither the command line // nor `[build] default-profile` says. Empty = "dev", which is every // interactive command. `mcpp pack` sets "release": see resolve_profile_name. @@ -1232,6 +1295,54 @@ prepare_build(bool print_fingerprint, // Inject synthetic targets (e.g. test binaries from `mcpp test`). for (auto& t : extraTargets) m->targets.push_back(t); + // #540: a cfg() predicate mcpp cannot evaluate must say so. + // + // ⚠️ A PREDICATE THAT ANSWERS FALSE AND A PREDICATE THAT WAS NEVER + // UNDERSTOOD USED TO READ THE SAME. `cfgpred` returns false for an unknown + // key and for an unknown bareword, and a `[target..build]` section + // whose predicate is false is dropped without a word — so a typo, and every + // `cfg(c-abi = …)` section docs/14 documented before this release, produced + // a successful build configured as if the section had not been written. + // + // Reported here rather than in the manifest parser because the vocabulary + // lives with the evaluator, and a second copy of it in `toml.cppm` is the + // exact defect this release is fixing four other instances of. + // + // Scoped to the root manifest by where it sits, which matches the existing + // policy for every other schema warning: a dependency may adopt a predicate + // a consumer's older mcpp does not know, and its build stays quiet. + for (auto const& cc : m->conditionalConfigs) { + auto unknown = cfgpred::unknown_tokens(cc.predicate); + if (!unknown.empty()) { + std::string names; + for (auto const& u : unknown) { + if (!names.empty()) names += ", "; + names += '\'' + u + '\''; + } + m->schemaWarnings.push_back(std::format( + "[target.'{}'] names {} in its cfg() predicate, which mcpp does " + "not know, so the section never applies (ignored). {}", + cc.predicate, names, cfgpred::vocabulary_sentence())); + } + // A layer is resolved AFTER dependency resolution, so a dependency + // selected by one would form a cycle with the resolution that produces + // the answer — docs/14 states this. The section's build inputs are + // honoured by the second pass; its dependencies cannot be, and saying + // so is the difference between a documented limit and a silent drop. + if (cfgpred::uses_layer(cc.predicate) + && !(cc.dependencies.empty() && cc.devDependencies.empty() + && cc.buildDependencies.empty() && cc.featureDeps.empty())) { + m->schemaWarnings.push_back(std::format( + "[target.'{}'] conditions dependencies on a target-side layer " + "(ignored). A layer is resolved from the dependency graph, so a " + "dependency chosen by one would decide the answer it is asking " + "for. Build inputs under this predicate DO apply; move the " + "dependency to an unconditional [dependencies] entry, or " + "condition it on the triple instead.", + cc.predicate)); + } + } + // Surface non-fatal manifest schema warnings (e.g. unsupported [targets.*] // keys). Under --strict they become errors — same policy as the // feature/platform schema checks below. @@ -3130,8 +3241,30 @@ prepare_build(bool print_fingerprint, // author's declaration. const auto& declaredDeps = runtimeOwnerManifest.xlings.deps; if (materializeRootRuntime && !declaredDeps.empty()) { - const auto stamp = runtimeSelection.ownerRoot / ".mcpp" - / ".xlings-deps.stamp"; + // ⚠️⚠️ THE STAMP RECORDS A GLOBAL EFFECT, SO IT LIVES WHERE THE + // EFFECT DOES. It used to sit in `/.mcpp/`, while the + // installation goes to the registry a few lines below — the + // scope difference is deliberate and explained there. Two + // consequences followed from the mismatch: wiping or replacing + // `MCPP_HOME` left a project still claiming the packages were + // installed, and `mcpp clean` (which removes `target/` and + // never `.mcpp/`) could not clear it. Keyed by the LIST, not by + // the project, because the installation is shared: two projects + // declaring the same packages should pay for it once. + // + // This still does not survive a user's `xlings remove`. No + // stamp does; the honest fix is a presence check, and it is + // blocked on `resolve_xpkg_path` requiring `@` + // while a manifest is entitled to name a package unpinned. + const auto stampDir = mcpp::home::root() / "provisioned"; + auto stamp_key = [&] { + std::size_t h = 1469598103934665603ull; // FNV-1a + for (auto const& d : declaredDeps) + for (unsigned char ch : d + "\n") + { h ^= ch; h *= 1099511628211ull; } + return std::format("xlings-deps-{:016x}", h); + }; + const auto stamp = stampDir / stamp_key(); // Idempotence by CONTENT, not by existence: editing the list // has to re-provision, and an unchanged list must not pay for // an xlings round-trip on every build. @@ -3148,7 +3281,90 @@ prepare_build(bool print_fingerprint, std::string have; if (std::ifstream in{stamp}; in) have.assign(std::istreambuf_iterator(in), {}); + // The stamp the previous location left behind. Read, never + // deleted: an older mcpp sharing the checkout still uses it, + // and a stale extra file is cheaper than a downgrade that + // re-provisions on every build. + // + // ⚠️ IT DOES NOT MEAN "PROVISIONED SUCCESSFULLY". The release + // that wrote it did not read the result — that is the defect + // above — so it means only "this list was attempted". Treating + // it as proof would carry the bug across the very upgrade that + // fixes it: a project whose dependency never installed would + // adopt the stamp and stay silently broken. + // + // So it is consulted in ONE place, below, where the alternative + // is worse: the auto-install gate. Online, nothing is adopted + // and every project re-provisions once, which is a cheap round + // trip that re-validates the claim. + const auto legacyStamp = runtimeSelection.ownerRoot / ".mcpp" + / ".xlings-deps.stamp"; + auto legacy_stamp_matches = [&] { + std::string legacy; + if (std::ifstream in{legacyStamp}; in) + legacy.assign(std::istreambuf_iterator(in), {}); + return legacy == want; + }; if (have != want) { + // Set by the gate below when a legacy stamp lets an + // offline build proceed without an attempt. + bool provisioned = false; + // ⚠️ THE AUTO-INSTALL GATE, WHICH THIS PATH DID NOT HAVE. + // + // `[toolchain]` is the precedent this whole mechanism cites + // ("the same 'declare it and mcpp provisions it on first + // use' contract"), and that path refuses on either knob and + // names the one that fired — see the auto-install branch + // above. This one honoured neither, so a CI exporting + // MCPP_NO_AUTO_INSTALL specifically to prevent an unasked + // download got one anyway, from a path that had never heard + // of the variable. + // + // Placed inside `have != want`, so it gates the ATTEMPT and + // not the block: a project whose packages are already + // provisioned still builds offline, which is the behaviour + // that would otherwise regress. + if (mcpp::platform::env::offline_mode() + || mcpp::platform::env::no_auto_install()) { + // ⚠️ THE ONE PLACE THE LEGACY STAMP IS TRUSTED, and the + // reason is that relocating a record must not refuse a + // build that worked yesterday. Every project that had + // already provisioned carries the old stamp and no new + // one, so on the first build after upgrading it reads + // as un-provisioned — and here, with the network shut + // off, there is no way to find out otherwise. Refusing + // would be a regression caused entirely by moving a + // file, which is the least defensible kind. + // + // Proceeding is the pre-upgrade behaviour exactly: if + // the packages really are missing, the build fails + // downstream on a missing header, as it did before. + // The registry stamp is NOT written — nothing here + // verified anything. + if (!legacy_stamp_matches()) { + std::string_view release = + mcpp::platform::env::offline_mode() + ? "drop --offline / unset MCPP_OFFLINE" + : "unset MCPP_NO_AUTO_INSTALL"; + return std::unexpected(std::format( + "[xlings] deps are declared but not provisioned, " + "and auto-install is off.\n" + " declared: {}\n" + " install them yourself with:\n" + " xlings install {}\n" + " or {} to let mcpp do it.", + join_deps(", "), join_deps(" "), release)); + } + mcpp::log::verbose("xlings", + "[xlings] deps: auto-install is off and this project " + "carries a pre-2026.9.1.1 provisioning stamp for the " + "same list; proceeding without re-checking"); + // Deliberately NOT writing the registry stamp: nothing + // here verified anything, and a record of a check that + // did not happen is the defect this release removes. + provisioned = true; + } + if (!provisioned) { mcpp::ui::status("Provisioning", std::format("[xlings] deps ({})", join_deps(", "))); @@ -3191,7 +3407,48 @@ prepare_build(bool print_fingerprint, auto r = mcpp::xlings::call( mcpp::config::make_xlings_env(**cfg2), "install_packages", args.dump(), &progress); - if (!r) { + // ⚠️⚠️ `if (!r)` IS NOT THE FAILURE TEST, AND TESTING ONLY + // IT MADE THIS PATH REPORT SUCCESS FOR EVERY FAILURE XLINGS + // CAN REPORT. + // + // `xlings::call` returns `expected` and + // is in the VALUE state whenever the child ran at all — the + // error channel means "the call did not happen". A + // capability's own status arrives inside `CallResult`, + // parsed off the NDJSON `{"kind":"result","exitCode":N}` + // line, because the xlings process itself exits 0 by design + // once it has spoken the protocol. + // + // Measured before this fix: a manifest declaring a package + // that cannot exist printed `Provisioning [xlings] deps + // (…)`, xlings answered `E_NOT_FOUND` with `exitCode: 1`, + // and mcpp stamped it as done and reported a successful + // build. #531 was written because "the declaration looked + // accepted and did nothing" is the worst shape a config key + // can have; unread, its own fix reproduced that shape and + // the stamp made it permanent. + // + // The correct idiom is not new — the dependency install + // path in this same file reads `r->exitCode` — it was + // simply not applied here. + const bool called = r.has_value(); + const int childRc = called ? r->exitCode : -1; + if (!called || childRc != 0) { + // Prefer xlings' own message: for an unresolvable name + // it names the repos it searched and whether the index + // is current, which is the part the author can act on. + std::string why = !called ? r.error() + : (r->error ? r->error->message + : std::format("xlings exited {}", childRc)); + if (auto captured = progress.captured_error(); + !captured.empty() && called && !r->error) + why = captured; + // The hint is where "run `xlings update` if the package + // was just published" lives, and for the commonest + // failure — a name that is not in the synced index — + // it is the whole of the actionable content. + if (called && r->error && !r->error->hint.empty()) + why += "\n " + r->error->hint; // Shaped like the toolchain failure: say what failed and // hand back a command the user can run themselves. An // ambiguous bare name ("mesa" matching two repos) lands @@ -3200,11 +3457,16 @@ prepare_build(bool print_fingerprint, "provisioning [xlings] deps failed: {}\n" " you can install them manually with:\n" " xlings install {}", - r.error(), join_deps(" "))); + why, join_deps(" "))); } + // Written only on success, for the same reason the check + // above exists: a stamp is a record that the effect + // happened, and recording an effect that did not is worse + // than not recording it — the next build skips the attempt. std::error_code sec; std::filesystem::create_directories(stamp.parent_path(), sec); if (std::ofstream out{stamp}; out) out << want; + } // if (!provisioned) } } @@ -7065,6 +7327,12 @@ prepare_build(bool print_fingerprint, } mcpp::targetside::TargetSide resolvedTargetSide; + // Whether the block below ran at all. `resolvedTargetSide` is default + // constructed, so "no layer resolved" and "resolution has not happened" + // read identically off its members — and the layer-conditional pass must + // tell them apart: the first is an answer a predicate may legitimately + // fail to match, the second means the pass has no business running. + bool targetSideResolved = false; // What the packages supplying the target side's layers publish: the header // directories and interface flags the whole build is compiled against. @@ -7349,6 +7617,7 @@ prepare_build(bool print_fingerprint, } resolvedTargetSide = tsd::resolve(in); + targetSideResolved = true; // ⭐⭐ RECORDED ON THE TOOLCHAIN THE MOMENT IT IS KNOWN, because three // producers of a compile line need it and only one of them can see @@ -7643,6 +7912,82 @@ prepare_build(bool print_fingerprint, resolvedTargetSide, reportedTargetName, mcpp::log::is_verbose())); } + // ── L1b: conditional sections whose predicate names a target-side layer ── + // + // The second half of the conditional axis, and it runs HERE for the same + // reason the root build.mcpp below does: the target side is now resolved, + // and from this point on everything that consumes build inputs — the P1689 + // scan, the `stdModuleFlags` collection, the fingerprint, `compute_flags` — + // reads `packages[]` and `*m`, both of which are still writable. + // + // ⚠️ EVERY PACKAGE, NOT JUST THE ROOT. The build.mcpp mirror below patches + // `packages[0]`, which is right for build.mcpp because a build program + // speaks for its own package and the dep loop already handled the others. + // Here the motivating case IS a dependency — a package supplying one C++ + // runtime over several C libraries — so patching only the root would leave + // the one package this feature exists for unserved. + // + // `*m` as well as the snapshots: `canonical_compile_flags(*m)` feeds the + // fingerprint, so a contribution reaching the snapshots and not the + // manifest would compile with flags the fingerprint does not describe. + // ⚠️⚠️ MUTATING `pkg.manifest` IS NOT ENOUGH, AND THAT IS THE WHOLE + // DIFFICULTY OF A LATE PRODUCER. `makePackageRoot` snapshots the manifest's + // build inputs into `privateBuild` / `linkUsage`, and the compile and link + // edges read THOSE. The build.mcpp tail below solves the identical problem + // with `directives::mark` + `fold_private_tail`, so this uses the same two + // helpers rather than a second mechanism — measured first: writing only + // `pkg.manifest.buildConfig` produced a build in which every layer + // predicate matched and no flag reached the compiler. + if (targetSideResolved) { + auto layerCtx = cfgpred::context_for(overrides.target_triple); + layerCtx.layersKnown = true; + layerCtx.compiler = resolvedTargetSide.compiler.interfaceName; + layerCtx.compilerRuntime = resolvedTargetSide.compilerRuntime.interfaceName; + layerCtx.kernelAbi = resolvedTargetSide.kernelAbi.interfaceName; + layerCtx.cAbi = resolvedTargetSide.cAbi.interfaceName; + layerCtx.cxxAbi = resolvedTargetSide.cxx.interfaceName; + // The root manifest feeds `canonical_compile_flags`, and therefore the + // fingerprint: a contribution reaching the snapshots but not `*m` would + // compile with flags the fingerprint does not describe, and the next + // build would call that a cache hit. + merge_layer_conditional_config(*m, layerCtx); + for (auto& pkg : packages) { + const auto mark = markDirectiveTail(pkg.manifest); + const auto ldN = pkg.manifest.buildConfig.ldflags.size(); + const auto privN = pkg.manifest.buildConfig.privateIncludeDirs.size(); + if (!merge_layer_conditional_config(pkg.manifest, layerCtx)) continue; + // cflags / cxxflags / include_dirs / include_dirs_after. + foldDirectiveTailIntoPrivateBuild(pkg, pkg.manifest, mark); + // ldflags: the link reads linkUsage. + pkg.linkUsage.ldflags.insert( + pkg.linkUsage.ldflags.end(), + pkg.manifest.buildConfig.ldflags.begin() + + static_cast(ldN), + pkg.manifest.buildConfig.ldflags.end()); + // private_include_dirs: expanded at makePackageRoot and folded into + // privateBuild.includeDirs, which is what keeps them OUT of + // publicUsage. A conditional entry has to take the same route or a + // vendored header overlay would reach every consumer — the blast + // radius e2e 304 exists to hold. + for (auto it = pkg.manifest.buildConfig.privateIncludeDirs.begin() + + static_cast(privN); + it != pkg.manifest.buildConfig.privateIncludeDirs.end(); ++it) { + if (it->is_absolute()) { + auto n = *it; n.make_preferred(); + if (std::ranges::find(pkg.privateBuild.includeDirs, n) + == pkg.privateBuild.includeDirs.end()) + pkg.privateBuild.includeDirs.push_back(std::move(n)); + continue; + } + for (auto& dir : mcpp::modgraph::expand_dir_glob( + pkg.root, it->generic_string())) + if (std::ranges::find(pkg.privateBuild.includeDirs, dir) + == pkg.privateBuild.includeDirs.end()) + pkg.privateBuild.includeDirs.push_back(dir); + } + } + } + // ── L3: ROOT build.mcpp (moved after dependency resolution, design §3.1 // item 4) ──────────────────────────────────────────────────────────────── // Runs HERE — after dep resolution + feature activation (so the contract diff --git a/src/build/prepare_inputs.cppm b/src/build/prepare_inputs.cppm index b0add588..0cc5952e 100644 --- a/src/build/prepare_inputs.cppm +++ b/src/build/prepare_inputs.cppm @@ -52,7 +52,31 @@ namespace cfgpred { // and the failure surfaces at link time naming a symbol instead of a predicate. // manifest/types.cppm's ConditionalConfig has documented the fallback since it // was written; this makes the bare-triple branch honour it. -struct Ctx { std::string os, arch, family, env, triple; }; +// The five target-side layers (docs/14) join the triple coordinates here, but +// they arrive LATER and from a different place: the triple is known before +// dependency resolution, a layer only after it, because a package in the graph +// may supply the C library. `layersKnown` is the difference, and it is a member +// rather than an inference from emptiness because "no layer resolved" and "not +// resolved yet" are different answers and only one of them may be reported. +// +// ⚠️ `compiler` carries the FAMILY (`llvm`), never the driver (`clang`) — #494 +// settled that, on the grounds that every place a user writes the name they +// write the family, and reporting the driver would make +// `requires = ["mcpp:compiler=llvm"]` permanently unsatisfiable. +struct Ctx { + std::string os, arch, family, env, triple; + bool layersKnown = false; + std::string compiler, compilerRuntime, kernelAbi, cAbi, cxxAbi; + + std::string_view layer_value(std::string_view k) const { + if (k == "compiler") return compiler; + if (k == "compiler-runtime") return compilerRuntime; + if (k == "kernel-abi") return kernelAbi; + if (k == "c-abi") return cAbi; + if (k == "c++-abi") return cxxAbi; + return {}; + } +}; // Derive the cfg context from the resolved --target triple, falling back to // the host for a native build. Parsing goes through triple.cppm — the single @@ -86,16 +110,59 @@ inline Ctx context_for(std::string_view targetTriple) { return c; } +// ── The cfg() vocabulary ──────────────────────────────────────────────────── +// +// ⚠️ ONE LIST PER CATEGORY, AND EVERY READER READS IT. #540 found four +// hand-written copies of other vocabularies in this repository, all drifted; +// the diagnostic added below would have been the fifth if it had transcribed +// these names instead of sharing them. +// +// TRIPLE keys are answerable from the target triple alone, which is what the +// conditional merge has before dependency resolution. LAYER keys name a +// target-side layer (docs/14) and are answerable only after the graph is +// resolved — see `merge_layer_conditional_config` in prepare.cppm for the +// second pass that evaluates them. +inline constexpr std::string_view kCfgTripleKeys[] = { + "arch", "env", "family", "os", +}; +inline constexpr std::string_view kCfgLayerKeys[] = { + "c++-abi", "c-abi", "compiler", "compiler-runtime", "kernel-abi", +}; +inline constexpr std::string_view kCfgBarewords[] = { + "linux", "macos", "unix", "windows", +}; + +inline bool is_cfg_layer_key(std::string_view k) { + return std::ranges::find(kCfgLayerKeys, k) != std::end(kCfgLayerKeys); +} + // Recursive-descent evaluator over the inside of `cfg(...)`: // expr := all(list) | any(list) | not(expr) | key="value" | bareword -// key ∈ {os, arch, family, env} bareword ∈ {windows, unix, linux, macos} +// key ∈ kCfgTripleKeys ∪ kCfgLayerKeys bareword ∈ kCfgBarewords +// +// ⚠️ THE EVALUATOR IS ALSO THE VALIDATOR. `seenKeys`/`seenWords` let one +// traversal answer three questions — does it match, does it name a layer, does +// it name anything at all — because a separate validator would be a SECOND +// parser of the same grammar, and this repository has already paid for one of +// those (`[hooks]` re-parsing mcpp.toml and reporting every TOML error as an +// invalid hook configuration). struct Parser { std::string_view s; std::size_t i = 0; const Ctx& c; + std::vector* seenKeys = nullptr; // every `key=` key, in order + std::vector* seenWords = nullptr; // every bareword void ws() { while (i < s.size() && std::isspace((unsigned char)s[i])) ++i; } bool eat(char ch) { ws(); if (i < s.size() && s[i] == ch) { ++i; return true; } return false; } std::string ident() { ws(); std::size_t b = i; - while (i < s.size() && (std::isalnum((unsigned char)s[i]) || s[i] == '_')) ++i; + // ⚠️ `-` and `+` ARE IDENTIFIER CHARACTERS, because the layer names are + // `c-abi`, `c++-abi`, `compiler-runtime` and `kernel-abi`. Without them + // `cfg(c-abi = "musl")` scanned as the bareword `c` followed by + // garbage, so the one thing a diagnostic could report was the letter + // `c`. No valid pre-existing predicate contains either character + // outside a quoted value, so widening the scanner changes nothing that + // used to parse. + while (i < s.size() && (std::isalnum((unsigned char)s[i]) + || s[i] == '_' || s[i] == '-' || s[i] == '+')) ++i; return std::string(s.substr(b, i - b)); } std::string str() { @@ -104,17 +171,25 @@ struct Parser { auto v = std::string(s.substr(b, i - b)); if (i < s.size()) ++i; return v; } bool match_alias(const std::string& a) { + if (seenWords) seenWords->push_back(a); if (a == "windows") return c.os == "windows"; if (a == "linux") return c.os == "linux"; if (a == "macos") return c.os == "macos"; if (a == "unix") return c.family == "unix"; - return false; // unknown bareword → no match + return false; // unknown bareword → no match, and `seenWords` reports it } bool match_kv(const std::string& k, const std::string& v) { + if (seenKeys) seenKeys->push_back(k); if (k == "os") return c.os == v; if (k == "arch") return c.arch == v; if (k == "family") return c.family == v; if (k == "env") return c.env == v; + // A layer key is not answerable until the target side is resolved. In + // the first (triple-only) pass this returns false and the section is + // skipped — which is correct, because the second pass owns it and would + // otherwise append the same inputs twice through `append()`. + if (is_cfg_layer_key(k)) + return c.layersKnown && c.layer_value(k) == v; return false; } bool expr() { @@ -173,6 +248,72 @@ inline bool matches(const std::string& predicate, const Ctx& c) { return predicate == triple; } +// ── One traversal, three answers ──────────────────────────────────────────── +// +// `scan_predicate` runs the REAL evaluator with a throwaway context purely to +// record which tokens the predicate names. Everything below derives from it, so +// the grammar has exactly one implementation and a predicate that the evaluator +// cannot answer is, by construction, a predicate the diagnostic reports. +struct PredicateScan { + std::vector keys; // every `key=` key, in order + std::vector barewords; // every bareword +}; + +inline PredicateScan scan_predicate(const std::string& predicate) { + PredicateScan out; + std::string_view k = predicate; + // Only the `cfg(...)` namespace. A bare alias or a bare triple is the + // documented escape hatch — `matches()` falls back to an exact string + // comparison for keys it cannot parse — and validating it would reject the + // explicit-section spelling that hatch exists to allow. + if (k.starts_with("cfg(") && k.ends_with(")")) { + Ctx scratch; + Parser p{ k.substr(4, k.size() - 5), 0, scratch, &out.keys, &out.barewords }; + (void)p.expr(); + } + return out; +} + +// True when the predicate names a target-side layer and therefore cannot be +// answered before dependency resolution. This is the classifier that keeps the +// two merge passes disjoint: `append()` is additive, so a section evaluated by +// both would contribute its inputs twice. +inline bool uses_layer(const std::string& predicate) { + auto scan = scan_predicate(predicate); + return std::ranges::any_of(scan.keys, + [](auto const& k) { return is_cfg_layer_key(k); }); +} + +// Tokens outside the vocabulary. A predicate naming one of these used to +// evaluate to false in silence, which is indistinguishable from a predicate +// that correctly did not apply — so `[target.'cfg(c-abi = "musl")'.build]` was +// dropped without a word for the entire time docs/14 documented it. +inline std::vector unknown_tokens(const std::string& predicate) { + auto scan = scan_predicate(predicate); + std::vector out; + auto add = [&](const std::string& t) { + if (t.empty()) return; + if (std::ranges::find(out, t) == out.end()) out.push_back(t); + }; + for (auto const& k : scan.keys) + if (std::ranges::find(kCfgTripleKeys, k) == std::end(kCfgTripleKeys) + && !is_cfg_layer_key(k)) + add(k); + for (auto const& w : scan.barewords) + if (std::ranges::find(kCfgBarewords, w) == std::end(kCfgBarewords)) + add(w); + return out; +} + +// The message body, built FROM the vocabulary rather than beside it. +inline std::string vocabulary_sentence() { + std::string keys, words; + for (auto k : kCfgTripleKeys) { if (!keys.empty()) keys += ", "; keys += k; } + for (auto k : kCfgLayerKeys) { if (!keys.empty()) keys += ", "; keys += k; } + for (auto w : kCfgBarewords) { if (!words.empty()) words += ", "; words += w; } + return std::format("Supported keys: {}. Supported barewords: {}.", keys, words); +} + } // namespace cfgpred std::filesystem::path target_dir(const mcpp::toolchain::Toolchain& tc, diff --git a/src/cli.cppm b/src/cli.cppm index c4446219..6dc3e597 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -348,7 +348,7 @@ int run(int argc, char** argv) { .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Build only the named workspace member")) .option(cl::Option("profile").takes_value().value_name("NAME") - .help("Build profile: release (default) | dev | dist | <[profile.*] name>")) + .help("Build profile: dev (default) | release | dist | <[profile.*] name>")) .option(cl::Option("release").help("Shorthand for --profile release")) .option(cl::Option("dev").help("Shorthand for --profile dev (-O0 -g)")) .option(cl::Option("features").takes_value().value_name("LIST") @@ -411,7 +411,7 @@ int run(int argc, char** argv) { .option(cl::Option("workspace-timeout").takes_value().value_name("SECS") .help("Stop the --workspace fan-out after SECS seconds and report what did run (default 0 = no limit)")) .option(cl::Option("profile").takes_value().value_name("NAME") - .help("Build profile for the test build: release (default) | dev | dist | <[profile.*] name>")) + .help("Build profile for the test build: dev (default) | release | dist | <[profile.*] name>")) .option(cl::Option("features").takes_value().value_name("LIST") .help("Activate root-package features for the test build (comma-separated)")) .option(cl::Option("cap").takes_value().value_name("LIST") @@ -641,8 +641,18 @@ int run(int argc, char** argv) { .description("Remove a registry") .arg(cl::Arg("name").help("Registry name").required())) .subcommand(cl::App("update") - .description("Refresh local registry clones") - .arg(cl::Arg("name").help("If given, update only this index"))) + // #540: the argument selects among the PROJECT's custom + // indices only. The global repos are always synced wholesale, + // because `xlings update` has no per-index mode to call — a + // limitation index_management.cppm has recorded as a follow-up + // since it was written, in a comment no one typing the command + // can read. Saying it here is not the fix; it is the honest + // description until the upstream mode exists. + .description("Refresh local registry clones " + "(global repos always sync in full)") + .arg(cl::Arg("name").help( + "Update only this PROJECT-level custom index " + "(the global repos sync regardless)"))) .subcommand(cl::App("status") .description("Show local index presence/freshness (offline)")) .subcommand(cl::App("pin") diff --git a/src/pm/index_management.cppm b/src/pm/index_management.cppm index 60895fde..e062acfd 100644 --- a/src/pm/index_management.cppm +++ b/src/pm/index_management.cppm @@ -127,8 +127,14 @@ export int index_remove(const std::string& name) { // // NOTE on `name`: it only filters the PROJECT-level custom indices below. The // global repos are always synced wholesale because `xlings update` has no -// per-index mode to call. Tracked as a follow-up; the help text says "if given, -// update only this index", which is not what happens for the global set. +// per-index mode to call. Still tracked as a follow-up — the fix is upstream, +// in xlings. +// +// #540: the CLI now says this too. It used to promise "If given, update only +// this index", so the only place the limitation was written down was this +// comment, which nobody typing the command can read. A limitation recorded +// where only its implementers look is indistinguishable, from outside, from a +// feature that is broken. export int index_update(const std::string& filterName) { auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; } diff --git a/tests/e2e/327_declared_xlings_deps_report_their_own_failure.sh b/tests/e2e/327_declared_xlings_deps_report_their_own_failure.sh new file mode 100755 index 00000000..aa70f8bc --- /dev/null +++ b/tests/e2e/327_declared_xlings_deps_report_their_own_failure.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# 327_declared_xlings_deps_report_their_own_failure.sh — #540. +# +# #531 made `[xlings] deps` provision on first build. The call site tested +# `if (!r)` on `xlings::call`, which returns `expected` and +# is in the VALUE state whenever the child ran at all — a capability's own +# status arrives INSIDE CallResult, because xlings exits 0 once it has spoken +# the NDJSON protocol. So every failure xlings can report was read as success: +# +# $ mcpp build # deps = ["definitely-not-a-real-package"] +# Provisioning [xlings] deps (definitely-not-a-real-package) +# Finished dev [unoptimized + debuginfo] in 0.12s +# $ cat .mcpp/.xlings-deps.stamp +# definitely-not-a-real-package ← recorded as DONE +# +# #531's own comment says the defect it fixed was "the declaration looked +# accepted and did nothing — the worst shape a config key can have". Unread, +# the fix reproduced that shape and the stamp made it permanent. +# +# Two properties, and the second is the one a single run cannot show: +# (1) a provisioning that fails, fails the build; +# (2) it is NOT recorded, so the NEXT build tries again instead of +# reporting success. +# +# Plus the auto-install gate `[toolchain]` has always had and this path did +# not — `MCPP_OFFLINE` and `MCPP_NO_AUTO_INSTALL`, naming the knob that fired. +# Those two legs need no network and no index, which is why they run first. +# +# requires: unix-shell +set -e + +MCPP="${MCPP:-mcpp}" +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p proj/src +cat > proj/mcpp.toml <<'EOF' +[package] +name = "provproj" +version = "0.1.0" + +[xlings] +deps = ["definitely-not-a-real-package-540"] +EOF +echo 'int main() { return 0; }' > proj/src/main.cpp +cd proj + +# ── (1) MCPP_NO_AUTO_INSTALL: refuse, and name THAT knob ──────────────────── +# ⚠️ The knob that fired, not a fixed one. Telling someone who exported +# MCPP_NO_AUTO_INSTALL to drop --offline sends them looking for a variable they +# never set; the toolchain gate solved this years ago and this path is copying +# its shape, so the test holds the shape too. +set +e +MCPP_NO_AUTO_INSTALL=1 "$MCPP" build > noauto.log 2>&1 +rc=$? +set -e +[ "$rc" -ne 0 ] || { cat noauto.log; echo "FAIL: MCPP_NO_AUTO_INSTALL did not refuse"; exit 1; } +grep -q "MCPP_NO_AUTO_INSTALL" noauto.log || { + cat noauto.log; echo "FAIL: the refusal does not name MCPP_NO_AUTO_INSTALL"; exit 1; } +grep -q "definitely-not-a-real-package-540" noauto.log || { + cat noauto.log; echo "FAIL: the refusal does not name the package to install"; exit 1; } +grep -q "Provisioning" noauto.log && { + cat noauto.log; echo "FAIL: it announced provisioning and then refused"; exit 1; } +echo " ok MCPP_NO_AUTO_INSTALL refuses, names the knob and the package" + +# ── (2) MCPP_OFFLINE: same refusal, the other knob ────────────────────────── +set +e +MCPP_OFFLINE=1 "$MCPP" build > offline.log 2>&1 +rc=$? +set -e +[ "$rc" -ne 0 ] || { cat offline.log; echo "FAIL: MCPP_OFFLINE did not refuse"; exit 1; } +grep -q "MCPP_OFFLINE\|--offline" offline.log || { + cat offline.log; echo "FAIL: the refusal does not name the offline knob"; exit 1; } +echo " ok MCPP_OFFLINE refuses and names the offline knob" + +# ── (3) A declared package that cannot be provisioned fails the build ─────── +# …and, the half a single run cannot show, is not recorded as done. +# +# ⚠️ THE DENOMINATOR IS TWO INVOCATIONS. The stamp is what made the original +# defect permanent, and a stamp written for a failure is invisible in the run +# that wrote it — the build that mattered already reported success. Only the +# SECOND build distinguishes "failed" from "failed and remembered as done". +# +# Asserted on behaviour rather than on the stamp's path: where the record lives +# is an implementation detail that has already moved once (project → registry), +# and a test keyed to the path would have passed through the move while the +# property it exists for was broken. +set +e +"$MCPP" build > first.log 2>&1 +rc1=$? +"$MCPP" build > second.log 2>&1 +rc2=$? +set -e +[ "$rc1" -ne 0 ] || { cat first.log; echo "FAIL: an unprovisionable [xlings] dep built successfully"; exit 1; } +[ "$rc2" -ne 0 ] || { + cat second.log + echo "FAIL: the SECOND build succeeded — the failed provisioning was recorded as done" + exit 1; } +for f in first.log second.log; do + grep -q "definitely-not-a-real-package-540" "$f" || { + cat "$f"; echo "FAIL: $f does not name the package that could not be provisioned"; exit 1; } +done +echo " ok a failed provisioning fails the build, twice — it is not stamped as done" + +# ── (4) relocating the record must not refuse a build that worked before ──── +# The stamp moved from `/.mcpp/.xlings-deps.stamp` into the registry, +# because it records a GLOBAL effect and lived in the project. Every project +# that had already provisioned therefore reads as un-provisioned on its first +# build after upgrading. Online that costs one round trip and self-corrects; +# with auto-install off, leg (1) above would refuse a build that worked +# yesterday, for packages that are in fact installed. +# +# ⚠️ THE LEGACY STAMP IS EVIDENCE ONLY HERE. The release that wrote it did not +# read the provisioning result, so it means "attempted", not "succeeded" — +# trusting it anywhere else would carry that defect across the upgrade that +# fixes it. This leg pins the one place it is allowed to count. +cd "$TMP" +mkdir -p legacy/src/.keep && rm -rf legacy/src/.keep +mkdir -p legacy/src legacy/.mcpp +cat > legacy/mcpp.toml <<'EOF' +[package] +name = "legacyproj" +version = "0.1.0" + +[xlings] +deps = ["definitely-not-a-real-package-540"] +EOF +echo 'int main() { return 0; }' > legacy/src/main.cpp +printf 'definitely-not-a-real-package-540\n' > legacy/.mcpp/.xlings-deps.stamp +cd legacy +set +e +MCPP_NO_AUTO_INSTALL=1 "$MCPP" build > legacy.log 2>&1 +rc=$? +set -e +[ "$rc" -eq 0 ] || { + cat legacy.log + echo "FAIL: moving the stamp refused a build that a pre-upgrade mcpp allowed" + exit 1; } +echo " ok a pre-upgrade stamp keeps an auto-install-off build working" + +# …and it is NOT promoted into the registry: nothing verified anything, so the +# next build with the network available must still check. +cd "$TMP/proj" +set +e +MCPP_NO_AUTO_INSTALL=1 "$MCPP" build > again.log 2>&1 +rc=$? +set -e +[ "$rc" -ne 0 ] || { + cat again.log + echo "FAIL: a project with no legacy stamp was let through by another project's" + exit 1; } +echo " ok the legacy stamp is not promoted, and does not leak between projects" + +echo "PASS: 327 declared [xlings] deps report their own failure" diff --git a/tests/e2e/328_layer_predicates_apply_and_unknown_ones_say_so.sh b/tests/e2e/328_layer_predicates_apply_and_unknown_ones_say_so.sh new file mode 100755 index 00000000..1f41d183 --- /dev/null +++ b/tests/e2e/328_layer_predicates_apply_and_unknown_ones_say_so.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# 328_layer_predicates_apply_and_unknown_ones_say_so.sh — #540 / #494. +# +# docs/14 documents a package adapting to the C library it was built over: +# +# [target.'cfg(c-abi = "musl")'.build] +# std-module-flags = ["-D_GNU_SOURCE"] # wrong for picolibc +# +# and #494 moved those keys onto BuildInputs FOR this, its member comment +# saying membership "is what makes the cfg axis carry it". Nothing evaluated +# the predicate: `cfgpred::Ctx` was built from the target triple alone, so +# `match_kv` knew only os/arch/family/env and every layer section was dropped — +# in SILENCE, because a predicate that answers false and a predicate that was +# never understood produced the same nothing. Measured on 2026.8.30.2: a +# `cfg(c-abi = "glibc")` section contributed no define and emitted no warning. +# +# Three properties: +# (1) a layer predicate that matches APPLIES; +# (2) a layer predicate that does not match does NOT apply — otherwise (1) +# is satisfied by a pass that fires unconditionally; +# (3) a key outside the vocabulary is REPORTED, and is an error under +# --strict. +# +# requires: gcc unix-shell +set -e + +MCPP="${MCPP:-mcpp}" +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# The resolved c-abi for this build, read from mcpp's own report rather than +# assumed. ⚠️ The report prints layers whose origin is not the compiler payload +# by default, so the zero-config case needs MCPP_VERBOSE to list all five. +mkdir -p probe/src +cat > probe/mcpp.toml <<'EOF' +[package] +name = "probe" +version = "0.1.0" +EOF +echo 'int main() { return 0; }' > probe/src/main.cpp +( cd probe && MCPP_VERBOSE=1 "$MCPP" build > report.log 2>&1 ) || { + cat probe/report.log; echo "FAIL: the probe build errored"; exit 1; } +CABI=$(sed -n 's/.*c-abi *\([A-Za-z0-9_+-]*\).*/\1/p' probe/report.log | head -1) +[ -n "$CABI" ] || { cat probe/report.log; echo "FAIL: could not read the resolved c-abi"; exit 1; } +echo " .. this host resolves c-abi = '$CABI'" + +# ⚠️ A NAME NO LAYER CAN TAKE, for the negative leg. Deriving it (rather than +# hardcoding "musl") keeps the leg meaningful on a musl host, where a hardcoded +# counter-example would be the TRUE case and the test would assert nothing. +NOTCABI="not-${CABI}" + +# ── (1) + (2) a matching layer predicate applies; a non-matching one does not ── +mkdir -p layers/src +cat > layers/mcpp.toml < layers/src/main.cpp <<'EOF' +#ifndef PROBE_MATCHED +#error "a matching cfg(c-abi = ...) section did not apply" +#endif +#ifdef PROBE_MUST_NOT_APPLY +#error "a NON-matching cfg(c-abi = ...) section applied" +#endif +#ifndef PROBE_COMBINED +#error "cfg(all(unix, c-abi = ...)) did not apply" +#endif +int main() { return 0; } +EOF +( cd layers && "$MCPP" build > b.log 2>&1 ) || { + cat layers/b.log; echo "FAIL: layer predicates did not behave"; exit 1; } +echo " ok a matching layer predicate applies, a non-matching one does not" + +# ⚠️ EXACTLY ONCE, NOT AT LEAST ONCE. The pass that evaluates layer predicates +# runs AFTER the triple-only merge, and `append()` is additive — so a predicate +# with a triple leg (`any(unix, c-abi = ...)`) would be matched by both passes +# and contribute twice. Counting the flag is the only way to see that; a +# preprocessor check cannot tell one -D from two. +mkdir -p once/src +cat > once/mcpp.toml < once/src/main.cpp +( cd once && "$MCPP" build > b.log 2>&1 ) || { + cat once/b.log; echo "FAIL: the mixed-predicate build errored"; exit 1; } +n=$(grep -o '\-DPROBE_ONCE=1' once/compile_commands.json | wc -l | tr -d ' ') +[ "$n" -eq 1 ] || { + echo "FAIL: a predicate with both a triple leg and a layer leg contributed $n times, expected 1" + grep -o '\-DPROBE_ONCE=1' once/compile_commands.json + exit 1; } +echo " ok a predicate naming both a triple key and a layer applies exactly once" + +# ── (3) an unknown key is reported, and --strict makes it an error ────────── +mkdir -p unknown/src +cat > unknown/mcpp.toml <<'EOF' +[package] +name = "unknown" +version = "0.1.0" + +[target.'cfg(no-such-key = "x")'.build] +defines = ["NEVER"] +EOF +echo 'int main() { return 0; }' > unknown/src/main.cpp +( cd unknown && "$MCPP" build > w.log 2>&1 ) || { + cat unknown/w.log; echo "FAIL: an unknown cfg key must warn, not fail"; exit 1; } +grep -q "no-such-key" unknown/w.log || { + cat unknown/w.log; echo "FAIL: the unknown cfg key was not named"; exit 1; } +# The message must list the vocabulary it checked against — built FROM that +# list, so it cannot drift from the check the way three other key lists did. +grep -q "c-abi" unknown/w.log || { + cat unknown/w.log; echo "FAIL: the message does not list the supported keys"; exit 1; } +echo " ok an unknown cfg key is named, with the vocabulary it was checked against" + +set +e +( cd unknown && "$MCPP" build --strict > s.log 2>&1 ) +rc=$? +set -e +[ "$rc" -ne 0 ] || { cat unknown/s.log; echo "FAIL: --strict did not turn the warning into an error"; exit 1; } +echo " ok --strict makes it an error" + +echo "PASS: 328 layer predicates apply, and unknown ones say so" diff --git a/tests/e2e/329_exit_codes_match_the_contract.sh b/tests/e2e/329_exit_codes_match_the_contract.sh new file mode 100755 index 00000000..2bffcc9c --- /dev/null +++ b/tests/e2e/329_exit_codes_match_the_contract.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# 329_exit_codes_match_the_contract.sh — #540, SPEC-003. +# +# The machine-output chapter shipped an exit-code table carrying 0 / 2 / 70 / +# 127 — which is the usage/internal half of what the protocol design record +# (§R4) asked for. The runtime half was never written down, so `1` — the code +# `mcpp xpkg parse` returns for every descriptor it rejects — was undocumented, +# and an audit reading that table concluded the missing code was `4`. It is not: +# no enveloped command can return 4, because `self env --format json` is built +# specifically to avoid the `load_or_init` path that produces it. +# +# docs/spec/exit-codes.md is now the contract. This holds it. +# +# ⚠️ ASSERTS THE CODE, NOT "STDERR IS NON-EMPTY". A criterion whose "no" is also +# what a crash produces cannot tell the two apart — and the whole point of the +# table is that a client can distinguish a rejection from a broken mcpp. +# +# requires: unix-shell +set -e + +MCPP="${MCPP:-mcpp}" +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +run_rc() { # run_rc