fix: #540 的七条审计,以及核验它们时挖出的四条 (2026.9.1.1) - #542
Conversation
七条里六条成立,一条判据打偏。核验过程本身挖出四条没有人报过的,其中一条比原报告 的全部七条都严重。它们几乎全是同一族:**mcpp 关于自己说了一句话,而 mcpp 不遵守它。** 完整核验、量化与设计见 `.agents/docs/2026-08-31-issue540-seven-audit-findings.md`。 ── 1. 供给从不检查自己是否成功(未报告,最严重)──────────────── `xlings::call` 返回 `expected<CallResult, string>`,只要子进程跑起来就处于**值**态 —— 能力自身的状态在 `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(刻意如此),而 `<project>/.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 <name>` 承诺按索引筛选而只筛项目级。限制此前只写在一条注释里 —— 一个只有实现者看得到的地方,从外面看与「这功能坏了」无从区分。 * 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` 和两个。
一个 32 位宿主会把 64 位的 FNV offset basis 截断,于是它拥有一份与别人不同的键空间 而没有任何东西说明为什么。碰撞本身两侧都不是正确性问题 —— 记号文件存的是**列表**, 比较也是针对内容的,所以两个共键的列表会重新供给而不是悄悄采用对方的记录。
把 `have != want` 换成一个 `needProvision` 布尔,于是自动安装闸可以在供给块**之前** 求值并在采信旧记号时把它关掉 —— 供给块本身保持原来的嵌套层级。行为不变;改的是让 一个千行的 PR 里这一段仍然读得下去。
自审读出:`deps` 的那条消息提供了 `optional = true`,而 mcpp 从来没有这个键。 一条把读者送去一个解析器不认识的键的诊断,与本次发布正在移除的那些警告是同一个 缺陷,只是外了一层。文档化的机制是 `[feature-deps.<name>]`(docs/05 §2.8.2)。 同步中英两份 docs/05。
自审读出:328 此前每一条断言都能被一个只给 packages[0] 打补丁的实现满足。而 docs/14 是为「供给某一层、并支持其下方多个实现的包」写的这个特性 —— 那是一个**库**, 以别人的依赖身份被走到。与本 pass 共用同一段窗口的 build.mcpp tail 只打补丁给根包 (对它自己的用途是对的),照抄那个形状会让这个特性唯一存在的对象没被服务,而所有 只看根包的断言照样全绿。 新增的这条带否定对照:依赖里不匹配的那一段必须不生效。
它是机器接口上的一个字段,而取值从 `gnu` 变成了 `glibc`/`ucrt`。§6 承诺的是 字段的**含义**不变 —— 含义确实没变 —— 但按字面量取值的客户端会受影响,而契约页 不说这件事,就只能靠对方撞上。
实现了这条诊断,却从没跑过它 —— 而「判据写了、绿了、却从没跑到」正是本次发布在修的 那一族。带一条同样重要的对照:同一个谓词下的 build 输入必须**仍然生效**,否则一条 警告就是把一次静默丢弃换成了另一次。
自审记录第一版实现全绿之后又读了一遍,读出四条。都不是编译错误,四条里三条测试全绿。 1. 挪记录 + 加闸 = 升级悬崖两处修改各自都对:记号记录全局效果却存在项目里(搬进 registry),以及该路径不认两个 单独 review 每一处都发现不了,因为交互只在升级后的第一次构建上出现,而本地工作树 旧记号现在只在唯一一处被采信 —— 就是那道闸,因为在那里网络关着,没有别的办法 2. 一条诊断指向一个不存在的键
3. e2e 328 此前每一条断言都能被一个只补
|
`make@4.4` 与 `cmake@3.28` —— 索引里是 make 4.3 与 cmake 4.4.2/4.0.2,两个都从没 解析过。这一点此前不可见,因为 #531 的供给不读自己的结果:xlings 报出的每一种失败都 被当成成功。于是**这条最直接覆盖 `[xlings] deps` 的测试,是建立在一次从未成功的安装 之上做断言的** —— 正是 #531 想要终结的那个状态。 结果被读之后,fixture 的错误第一次可见:构建停下来了。这不是回归,是这条修复第一个 抓到的真实例子,而它抓到的是仓库自己的测试。 改用 `ninja@1.12.1`:mcpp 能构建的地方它一定已装,所以供给短路,这条测试仍然不花 任何下载。
e2e 88 的 fixture 声明 `make@4.4` 与 `cmake@3.28`,而索引里是 make 4.3、 cmake 4.4.2/4.0.2 —— 两个版本从来不存在。这条测试在 #531 的整个生命周期里都是绿的, 因为供给不读自己的结果。 ⭐ 一般形状:**fixture 里的取值在有东西开始检查它们的那一刻,就不再是随意的了。** 在「这段文字有没有到达那个文件」是唯一断言的时候,它们只是自由字符串。
§4 原本给了一个退出码清单并声称穷举,而那个清单少了三处 `return 4`(pack/pipeline、 cmd_toolchain、pm/commands —— 都是同一个 `load_or_init` 失败,所以 `4` 的含义没变, 共 11 处而非 8 处),也没提 `20` 与 `1024`。 改成:贴出产生读数的命令,然后**按出处**逐个归类。⚠️ `4` 同时落在两栏 —— 它在 `index_management.cppm` 里是退出码,在 `runtime/elf.cppm` 里是 `R_RISCV_COPY`。 一份声称穷举的规范,自己就得能被复核。
⚠️ **`cfg(all(unix, …))` 在 Windows 上正确地为假**,于是 fixture 的 #error 触发, Windows e2e 1/2 变红。这条腿要证明的是「三元组键与层键**组合**」,那么它的三元组 那一半就必须在测试会跑的每个平台上为真。改成 `any(unix, windows)`。 「恰好一次」那条腿同理:三元组腿为假的地方,只有一条路能匹配,这个守卫就不再守任何 东西 —— 它存在的理由正是第一趟会经三元组腿匹配而第二趟经层腿匹配。⚠️ **注释里的反引号落在**未加引号**的 heredoc 里,变成了命令替换。** fixture 要 插值 $CABI 所以 heredoc 不能加引号;套件因此打印 `syntax error: unexpected end of file` 而**测试照样通过**。注释移到 heredoc 之外。 顺带:`-DPROBE_ONCE=1` 的计数改为同时接受 `/D`(Windows 自举可能驱动 MSVC)。 本机全量:355 passed / 3 failed —— 三条(62、168、208)在已发布的 2026.8.30.2 上以 相同消息失败,是本机 musl/glibc 载荷的缺口,对照已跑。
Closes #540.
七条里六条成立,一条判据打偏。核验过程本身挖出四条没有人报过的,其中一条比
原报告的全部七条都严重。完整核验、量化与设计:
.agents/docs/2026-08-31-issue540-seven-audit-findings.md。基线
aef5191(即 issue 审计的那个 commit)。所有读数取自该 commit 的 detachedworktree,不取自工作树。
逐条判定
--profile帮助说 release,实际 devindex update <name>只过滤项目级索引[features]静默吞未知键std-module三键被读却报 unsupportedcfg(c-abi = ...)例子两条腿都断BuildInputs的两个成员,其中一个被同一条轴的另一套语法接受MCPP_OFFLINE,也不认MCPP_NO_AUTO_INSTALLmcpp::target_libc()报载荷引用,而文档说它是「已解析的 C 库」最严重的一条(未报告)
xlings 报得完全正确(
E_NOT_FOUND+{"exitCode":1,"kind":"result"}),call()也解析对了并存进
CallResult::exitCode。调用点只测了if (!r)—— 而expected的错误态表示「这次调用没发生」,能力自身的状态在值里面。正确写法就在同一个文件里:依赖安装路径
写的是
if (r && r->exitCode != 0 && …)。#531 的注释说它修的缺陷是「声明看起来被接受了却什么都没做,这是一个配置键能有的最坏
形态」。没人读结果,它的修法重现了那个形态。
新增能力:
[target.'cfg(<层> = "…")'.build]docs/14 用一整节记载它,连「为什么不能用 feature 选择代替」都论证过;而
cfgpred::Ctx只由三元组构造,于是每一个这样的段落被静默丢弃,包成功构建在错误的 C 库配置上。8 处文档如此(中英各 4)。
实现落在目标侧解析与 P1689 扫描之间的空窗 —— build.mcpp 已经在用同一段窗口做同样的
事(把 directive tail 镜像进
packages[0]),所以第二趟合并复用它的directives::mark+fold_private_tail,不另造机制。matches()做不到:cfg(any(linux, c-abi="musl"))的三元组腿在第一趟就为真,第二趟会再匹配一次而
append()是追加式的。按是否命名了层归属。e2e 328 数
-D的出现次数来守这条 —— 预处理器分不开一个-D和两个。实现它才暴露的一条
谓词是一次比较,而比较有两侧;此前的设计工作从没问过右侧的取值是什么。它在普通 Linux
宿主上是
gnu,而 docs/14 的表一直写着glibc/musl/picolibc,e2e 296 的文件头也把期望的报告写作
c-abi glibc (payload)。一个只被打印的值没有拼写纪律,把它提升为用户比较的对象会追溯地强加一条。 现在层名
的是库,请求侧保留三元组的拼写(规范 §3.4),两者经
c_abi_request_satisfied比较。Windows 上
-gnu命名的是 MinGW 形态而其 C 运行时是 UCRT,因此映射按 OS 分叉。无感升级
记号从项目搬进 registry 是必要的(它记录的是全局效果),但自审时发现:搬迁后每个已供给
的工程读起来都是「未供给」,配上新的自动安装闸,离线首次构建会被拒 —— 一次纯粹由挪
文件造成的回归。
旧记号因此在唯一一处被采信:就是那道闸,因为在那里网络关着,没有别的办法查证。
它绝不被提升进 registry —— 写它的那个版本不读结果,所以它的含义是「尝试过」而不是
「成功了」,别处采信等于把缺陷带过修它的这次升级。e2e 327 第 4、5 条断言守这个。
测试
test_manifest+6(三份词汇表各一正一负),test_targetside+2。--strict。2026.8.30.2 上同样失败 —— 本机 musl 载荷的环境缺口,非回归,对照已跑。
规范/文档
docs/spec/exit-codes.md(SPEC-003)。2026-08-08 的协议设计文档 §R4 把这份契约指派给了
docs/spec/,一直没有写。docs/spec/README.md索引。