From 59132b841d6f110be47436cb1ce01d86a80fa374 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 17:09:50 +0800 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20=E6=9C=BA=E5=99=A8=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E8=BE=93=E5=87=BA=E5=8D=8F=E8=AE=AE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=AF=B9=20RFC=20#379=20=E7=9A=84=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E4=B8=8E=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逐条实跑核对 #379 的事实主张(不看代码推断)。基本成立,但有一个结构性遗漏会让它 的阶段 0–2 全部落空。 **未知选项的报错走 stdout,rc=1,stderr 为空** —— 而且那段代码在依赖包 `mcpplibs.cmdline` 里,不在 mcpp。RFC 的阶段 0 只规定未知**值**,但客户端探测能力 时在旧版本上先撞到的是未知**选项**;只规定值,等于把最常走的那条路留在污染状态, 而修它是跨包工作,不在 RFC 的行数估算里。 由此推出更关键的一条:RFC 提议的 `mcpp --protocol-version` **解决不了它被设计来解决 的问题**。在任何尚未实现它的 mcpp 上,它自己就是那个「可能失败的命令」,且失败与 成功同通道。换 `--json` 拼写也一样 —— 拼写之争对发现机制毫无影响。唯一稳健的客户端 规则是正向识别:读 stdout 尝试 JSON 解析,含 schemaVersion 与 kind 才算数;不得以 退出码或「没报错」为判据。 第三条:`destructive` 只放 envelope 不够。VSCode 的 untrusted 门要在**执行之前**知道, 而拿到 envelope 时副作用已经发生。建议同时进 `--protocol-version` 的静态表。 核对过程本身有一处自纠:我先用 grep 断定 `cache list --json` 不存在,实跑后推翻 —— flag 注册不带横线。文档里每条结论都以实跑为准。 另补一条可执行纪律:golden fixture 必须反向验证过(改字段名要变红)。只「跑通了」 而没有「改坏了会红」的 fixture,和没有 fixture 等价 —— 本轮我自己写过三个因为错误 的理由而通过的测试,都是主动把实现改坏才发现的。 --- ...machine-readable-output-protocol-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .agents/docs/2026-08-08-machine-readable-output-protocol-design.md diff --git a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md new file mode 100644 index 00000000..f1a7edca --- /dev/null +++ b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md @@ -0,0 +1,196 @@ +# 机器可读输出协议 —— 对 RFC #379 的核对与修正 + +> 状态:设计待 review。核对基准 mcpp 2026.8.8.3(本机构建),xlings 2026.8.8.1。 +> 本文不复述 #379,只写**核对结果**与**它需要改的地方**。 + +## 0. 结论先行 + +RFC #379 的分析基本成立,逐条实测核对见 §1。但它有一个**结构性遗漏**,会让阶段 0 +到阶段 2 全部落空: + +> **未知选项的报错走 stdout,退出码 1,stderr 为空 —— 而且这段代码在依赖包 +> `mcpplibs.cmdline` 里,不在 mcpp。** + +RFC 花了整个阶段 0 去规定「未知**格式值**怎么办」,但客户端在真实使用中先撞到的是 +「未知**选项**」——因为它要探测的正是「这个版本认不认 `--format`」。而那条路径今天 +把人类文本打进了协议要独占的通道。 + +由此推出的第二件事更关键:**RFC 提议的协商入口 `mcpp --protocol-version`,解决不了 +它被设计来解决的那个问题**(§2.2)。 + +--- + +## 1. 逐条核对 + +方法:全部在本机对已构建的 mcpp 实跑,不看代码推断。一处我用 grep 得出的结论是错的 +(以为 `cache list --json` 不存在),实跑后推翻 —— flag 注册不带横线,grep `"--json"` +搜不到。**下面每一条都以实跑为准。** + +| RFC 主张 | 核对 | 结果 | +|---|---|---| +| §1.1 `--json` 是已发布的事实拼写 | `xpkg parse --json` ✓、`cache list --json` ✓ 均有输出 | **成立** | +| §1.3 `self env` 无 JSON | `--format json` / `--json` 均报 unknown option | **成立** | +| §1.4 `xpkg parse --json` 无 schemaVersion | 输出 `{"namespace":"","name":"7zip","form":"A"}` | **成立** | +| §1.5 `xlings interface` 的 outputSchema 全空 | `--list` 20 个 capability,**20/20** 只有 `exitCode` | **成立** | +| §1.6 CDB 早于 ninja 写出 | `ninja_backend.cppm:1549` `write_compile_commands`,其后才起 ninja | **成立** | +| §1.1 `pack --format` 语义冲突 | `pack --format bogus` → rc=2,人类文本走 **stderr** | **成立** | + +### 1.1 新发现 F1:未知选项污染 stdout + +``` +$ mcpp cache list --format json +stdout: Error: unknown option: --format +stderr: (空) +rc: 1 +``` + +对照 mcpp 自己的错误路径: + +``` +$ mcpp xpkg parse /nonexistent.lua +stdout: (空) +stderr: error: cannot open '/nonexistent.lua' +``` + +**mcpp 自己的 `ui::error` 是对的**(`src/ui.cppm:327`,写 stderr)。洞恰好开在参数解析 +的边界上,而且代码在依赖里: + +``` +mcpplibs-x-cmdline/0.0.2/cmdline-0.0.2/src/cmdline.cppm:127 + std::println("Error: {}", result.error().message); // ← stdout +``` + +于是当前有**四种**未知输入行为,而 RFC 只识别出后两种(且它们都还只在 #372 里): + +| 场景 | 通道 | rc | +|---|---|---| +| 未知**选项**(`cache list --format …`) | **stdout** | 1 | +| 未知**值**(`pack --format bogus`) | stderr | 2 | +| #372 `ide snapshot --format yaml` | stdout | 3 | +| #372 `ide configure --format json` | stderr | 2 | + +--- + +## 2. 这改变了什么 + +### 2.1 阶段 0 的范围划错了 + +RFC 的阶段 0 只规定未知**值**。但客户端探测能力时发的第一个请求,在旧版本上命中的 +是未知**选项**。只规定值,等于把最常走的那条路留在污染状态。 + +**修正:阶段 0 必须覆盖选项层,而这意味着要动 `mcpplibs.cmdline`**(改成写 stderr, +或让 mcpp 不用它内建的错误打印)。这是 RFC 的行数估算里没有的一项跨包工作。 + +### 2.2 协商入口解决不了它被设计来解决的问题 + +RFC 给 `--protocol-version` 的理由是: + +> 客户端启动时判断该走哪条路,不用先 spawn 一个可能失败的命令再解析错误消息 + +实测,在**任何尚未实现它的 mcpp** 上: + +``` +$ mcpp --protocol-version +stdout: Error: unknown option: --protocol-version +stderr: (空) +rc: 1 +``` + +它自己就是「那个可能失败的命令」,而且失败与成功**同通道**。客户端仍然只能靠解析 +stdout 内容来判断 —— 正是要避免的事。 + +换 `--json` 拼写也一样:两种拼写在旧版本上都走同一条未知选项路径。**所以拼写之争 +(`--format` vs `--json`)对发现机制毫无影响**,RFC 用「布尔无法表达未知值分支」来论证 +`--format` 是对的,但不要把它当成解决了发现问题。 + +**修正:唯一对旧版本稳健的客户端规则是「正向识别」**—— + +> 读 stdout,尝试 JSON 解析;解析成功**且**含 `schemaVersion` 与 `kind` 才认为拿到了 +> 协议输出。**不得**以退出码 0、或「命令没报错」作为判据。 + +这条规则要写进 `docs/spec/`,并且是 envelope 必须**自识别**的理由:客户端没有别的 +可靠信号。`--protocol-version` 仍值得有(新版本上省一次 spawn),但它是优化,不是 +契约地基。 + +### 2.3 `destructive` 字段:同意,但它落在错误的一侧 + +RFC 提议在 envelope 里放 `destructive`。这对**已经拿到输出**的客户端有用,但 +VSCode 的 untrusted-workspace 门需要在**执行之前**知道 —— 而拿到 envelope 的时候, +副作用已经发生了。 + +**修正:`destructive` 必须同时出现在两个地方**—— + +- envelope 里(事后自述,便于日志与审计) +- `mcpp --protocol-version` 的输出里,以「命令 → destructive」的静态表形式给出 + +后者才是 untrusted 门能用的东西。这也顺带回答 RFC §七 给 @sunrisepeak 的第 4 问: +接受这个字段,但只放 envelope 不够。 + +--- + +## 3. 修正后的阶段划分 + +改动集中在阶段 0 与阶段 1,阶段 2 之后与 RFC 一致。 + +### 阶段 0(前置)—— stdout 归属,而不只是「未知格式」 + +1. **`mcpplibs.cmdline` 的解析错误改写 stderr**,并给出非 0 退出码。跨包改动,需要 + 发一个 cmdline 版本;mcpp 侧同步 bump。 +2. mcpp 侧统一:未知**值**与未知**选项**都走 stderr + rc=2(用法错误)。 + —— RFC 原文提议未知格式「走 stdout + envelope」。**建议改为 stderr + rc=2**: + 一个还不知道自己该输出什么格式的请求,不该往协议通道里写东西;客户端按 §2.2 的 + 正向识别规则,stdout 无 JSON 即判定为「不支持」,语义完整且不需要额外约定。 +3. 加一条 e2e:对**每个**声明支持 `--format` 的命令,断言未知值与未知选项在通道、 + 退出码上一致。这是防止 §1.1 那张表重新长出来的唯一办法。 + +### 阶段 1 —— `mcpp.wire` envelope + +与 RFC 一致(从 #372 的 `src/ide/model.cppm` / `snapshot.cppm` 提升),补两点: + +- envelope **必须自识别**:`schemaVersion` + `kind` 是客户端唯一的正向判据(§2.2) +- `--protocol-version` 的输出**包含命令 → destructive 的静态表**(§2.3) + +### 阶段 2–4 + +与 RFC 一致。`--json` 永久别名、入口一处归一、不打 deprecation 警告 —— 这套做法在 +本仓库有先例(`src/toolchain/compat.cppm`),照搬即可。 + +`pack --format tar|dir`:建议走 (a)**声明为例外**。它没有 stdout 机器输出,不参与本 +协议;加 `--layout` 别名是为一个不存在的一致性付迁移成本。 + +--- + +## 4. 必须守住的纪律(RFC §四)—— 同意,补一条 + +RFC 说「envelope 的价值全在 `data` 被真正版本化和文档化上」,并以 xlings 的 +20/20 空 outputSchema 为反例。核对属实。 + +补一条**可执行**的约束,否则「只增不改」会像那 20 个 schema 一样退化成口号: + +> 每个 `kind` 的 golden fixture 必须**在测试里被反向验证**:改一个字段名要让测试变红。 +> 只有「跑通了」而没有「改坏了会红」的 fixture,和没有 fixture 等价。 + +这一条不是文风要求。本轮工作里我自己就写过三个「因为错误的理由而通过」的测试: +一个 e2e 分支够不到它要测的模式,一个判据选成「跑不跑得起来」(在任何有宿主工具链 +的机器上恒真),一个 `ldd` 解释器行造成的误报。三个都是靠**主动把实现改坏、看测试 +会不会红**才发现的。 + +--- + +## 5. 与 #372 的关系 + +同意 RFC §五 的 A/B/C 拆分。补充一点:A 部分里的 +`write_fresh_compile_commands` + 原子替换,**独立于本协议就应该合入** —— 现状是 +`ofstream` 截断写,clangd 可能读到半截 JSON。那是既有缺陷,不该等协议设计定稿。 + +--- + +## 6. 待确认(在 RFC 七问之外) + +1. **`mcpplibs.cmdline` 的解析错误改走 stderr** —— 这是跨包改动且影响所有使用者, + 是否接受?若不动依赖,替代方案是 mcpp 不用 `App::run()` 的内建错误打印、自己接管 + `ParseResult`(mcpp 侧约 20 行,但要覆盖每个命令入口)。 +2. 未知格式**走 stderr + rc=2**(本文 §3 阶段 0.2)还是 RFC 原案的 **stdout + + envelope**?我倾向前者,理由见 §3;但若客户端强烈希望「任何情况下 stdout 都是 + JSON」,后者也自洽 —— 需要先定,因为它决定 §2.2 的客户端规则怎么写。 +3. `destructive` 静态表放进 `--protocol-version` 输出 —— 是否接受(§2.3)? From 10967c56bbe213cc397938735bbad16fef2bf897 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 17:19:26 +0800 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20=E8=BF=BD=E5=8A=A0=20CDB=20?= =?UTF-8?q?=E5=BC=95=E5=8F=B7=E7=BC=BA=E9=99=B7(=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0,=E4=B8=8D=E9=99=90=20Windows),=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E4=B8=8B=E5=B7=B2=E5=AE=9A=E7=9A=84=E4=B8=A4=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报 Windows 上 CDB 的 `-fmodule-file=` 带多余引号,每次构建后要手工删。核实后 比报告的更严重,而且在 Linux 上就能复现 —— 带空格的项目路径 + llvm 工具链: '-fmodule-file=std=/tmp/.../my project/.../std.pcm <- 闭合引号没了 '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' 前两条的闭合引号被切掉:一个参数被从中间劈成两半,还带着孤立的开引号。clangd 逐字 exec `arguments`,拿到的是两个残缺参数。 机制是四步各自合理的叠加:`shell_quote_arg` 的 kNeedsQuote 含反斜杠(Windows 路径必 含,所以那里每个带路径的 flag 都被加引号)→ 加引号对 ninja 是对的 → `split_flags` 撤销 ninja 转义并重新分词 → 但它不认引号。 `split_flags` 自己的注释把原则写对了(「消费者逐字 exec,所以转义必须撤销」),却只 实现了一半 —— 漏掉同一理由下的 shell 引号。 判据必须是「任何 token 不得以引号开头结尾,且带空格的路径是一个 token」,不能是 「clangd 能用了」。回归测试在 Linux 上就能跑;它至今没被发现,正是因为所有 e2e 的 项目路径都不含空格。 同时记下已定的两条:未知格式走 stderr + rc=2;pack --format 声明为例外。 --- ...machine-readable-output-protocol-design.md | 75 ++++++++++++++++++- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md index f1a7edca..16f145b3 100644 --- a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md +++ b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md @@ -177,6 +177,68 @@ RFC 说「envelope 的价值全在 `data` 被真正版本化和文档化上」, --- +## 4.5 追加优化项:CDB 的 `arguments` 带着 shell 引号(实测复现,不限 Windows) + +用户报告 Windows 上 CDB 里的 `-fmodule-file=` 带多余双引号,每次构建后要手工删掉才能 +配好 clangd。**是真问题,而且比报告的更严重** —— 在 Linux 上用带空格的项目路径复现了 +同一形状: + +``` +'-fmodule-file=std=/tmp/.../my project/.../std.pcm <- 闭合引号没了 +'-fmodule-file=std.compat=/tmp/.../my project/.../ +'-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' +``` + +前两条的**闭合引号被切掉了**:一个参数被从中间劈成两半,还带着一个孤立的开引号。 +clangd 逐字 exec `arguments`(不经 shell),于是它拿到的是 +`'-fmodule-file=std=/tmp/.../my` 和 `project/.../std.pcm'` 两个参数。 + +### 机制 + +四步,每一步单独看都合理: + +1. `flags.cppm::shell_quote_arg` 的 `kNeedsQuote` 含反斜杠和空格 —— + **Windows 路径必然含反斜杠**,所以在 Windows 上*每个*带路径的 flag 都被加引号; + Linux 上只有路径含空格时才触发。这解释了为什么它被当成 Windows 专属问题。 +2. 加引号是为了让 flag 在 ninja 的 `sh -c "<整条命令>"` 里存活 —— 对 ninja 是**对的**。 +3. `compile_commands.cppm::split_flags` 把那条字符串重新切成 token,并撤销 ninja 的 + `$ ` / `$:` / `$$` 转义。 +4. 但它**不认引号**:既不剥掉,也不把引号内的空格当作非分隔符。 + +### 这是同一个「注释写对了一半」 + +`split_flags` 自己的注释把原则写对了: + +> CDB consumers like clangd exec the `arguments` array literally — no ninja +> involved — so escaped chars must be undone + +它识别出**ninja 转义**要撤销,却漏了**同一个理由下**的 shell 引号。代码实现了原则的 +一半。与本文档 §2 是同一形状。 + +### 判据与修法 + +**判据(必须是这个,不能是「clangd 能用了」)**:CDB 的 `arguments` 里,任何 token 都 +不得以引号开头或结尾;且带空格的路径必须是**一个** token。前者防引号泄漏,后者防这次 +发现的「劈成两半」。 + +两条路: + +- **(a) 让 `split_flags` 认引号**(建议)。它本来就是那个分词器,分词器认引号是本分。 + 注意顺序:ninja 的 `$ ` 反转义必须发生在**引号内**,否则被引号包住的空格会先把 token + 切断 —— 这正是现在发生的事。 +- (b) 让 CDB 不再从「拼好的 shell 字符串」重新分词,而是让 argv 以 `vector` + 一路传下来。更干净,但要动 `compute_flags` 的返回形状,范围大得多。 + +**回归测试**:带空格路径的项目 + llvm 工具链(gcc 不发 `-fmodule-file`,覆盖不到), +断言上述判据。这条在 Linux 上就能跑,不需要 Windows 机器 —— 而它至今没被发现,正是 +因为所有 e2e 的项目路径都不含空格。 + +### 与本协议的关系 + +CDB 是 mcpp 已经在发的**机器可读输出**,只是没走 envelope。它今天就在破坏一个真实 +客户端(clangd),而且用户在手工修补它。**建议把它排在阶段 1 之前**:协议做得再好, +也救不了一个内容本身就坏掉的出口。 + ## 5. 与 #372 的关系 同意 RFC §五 的 A/B/C 拆分。补充一点:A 部分里的 @@ -190,7 +252,12 @@ RFC 说「envelope 的价值全在 `data` 被真正版本化和文档化上」, 1. **`mcpplibs.cmdline` 的解析错误改走 stderr** —— 这是跨包改动且影响所有使用者, 是否接受?若不动依赖,替代方案是 mcpp 不用 `App::run()` 的内建错误打印、自己接管 `ParseResult`(mcpp 侧约 20 行,但要覆盖每个命令入口)。 -2. 未知格式**走 stderr + rc=2**(本文 §3 阶段 0.2)还是 RFC 原案的 **stdout + - envelope**?我倾向前者,理由见 §3;但若客户端强烈希望「任何情况下 stdout 都是 - JSON」,后者也自洽 —— 需要先定,因为它决定 §2.2 的客户端规则怎么写。 -3. `destructive` 静态表放进 `--protocol-version` 输出 —— 是否接受(§2.3)? +2. `destructive` 静态表放进 `--protocol-version` 输出 —— 是否接受(§2.3)? + +## 7. 已定(2026-08-08) + +- **未知格式走 stderr + rc=2**(不是 stdout + envelope)。客户端按 §2.2 的正向识别 + 规则,stdout 无 JSON 即判定为「不支持」,语义完整,且一个还不知道该输出什么格式的 + 请求不往协议通道写东西。 +- **`pack --format tar|dir` 声明为例外**,不加 `--layout` 别名。它没有 stdout 机器 + 输出,不参与本协议;为一个不存在的一致性付迁移成本不值。 From 1b21b00c54378db240293ff8a143384cd8f1cb9b Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 17:31:43 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(cdb):=20arguments=20=E6=98=AF=20argv,?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=20shell=20=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消费者(clangd)逐字 exec `arguments`,不经 shell。所以一个仍带着 shell 引号的 token 不是 flag,而是一个不存在的文件名。 用户报的是 Windows 上 `-fmodule-file=` 带多余双引号、每次构建后要手工删。核实后比 报告更严重,而且在 Linux 上就能复现 —— 带空格的项目路径 + llvm: '-fmodule-file=std=/tmp/.../my project/.../std.pcm <- 闭合引号没了 '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' 第一条不只是多个引号:token 在引号**内部**的空格处被切断,一个参数变成两个,其中一个 带着永不闭合的开引号。 机制是四步各自合理的叠加:`shell_quote_arg` 的触发集含反斜杠(Windows 路径必含,所以 那里每个带路径的 flag 都被引号包住)→ 加引号对 ninja 是对的 → `split_flags` 撤销 ninja 转义并重新分词 → 但它不认引号。 `split_flags` 原来的注释把原则写对了 ——「消费者逐字 exec,所以转义必须撤销」—— 只实现 了一半,漏掉同一理由下的 shell 引号。现在三件事都做,且**顺序**是要点:ninja 的 `$ ` 反转义必须发生在引号**内**,否则被引号包住的空格先把 token 切断,那正是原来的 bug。 中间的引号是数据不是引用:`-DGREETING="hi"` 保留内部引号,否则宏的含义就变了。 判据写成「任何 token 不以引号开头/结尾,且带空格的路径是一个 token」,不是「clangd 能 用了」—— 后者在不含空格的路径上恒真,正是它至今没被发现的原因:所有 e2e 的项目路径都 不含空格。 `split_flags` 由匿名 namespace 提升为导出:它就是那份契约,该由测试钉住而不是靠通读 生成文档来推断。 tests/unit/test_compile_commands.cpp +5(9 条);端到端复验:带引号 token 归零 --- ...08-08-wire-protocol-implementation-plan.md | 95 +++++++++++++++++++ src/build/compile_commands.cppm | 84 ++++++++++++---- tests/unit/test_compile_commands.cpp | 80 ++++++++++++++++ 3 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 .agents/docs/2026-08-08-wire-protocol-implementation-plan.md diff --git a/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md b/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md new file mode 100644 index 00000000..f807b0c5 --- /dev/null +++ b/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md @@ -0,0 +1,95 @@ +# 机器可读输出协议 —— 拆分实施计划 + +> 设计:`2026-08-08-machine-readable-output-protocol-design.md`(对 RFC #379 的核对与修正) +> 已定:未知格式走 **stderr + rc=2**;`pack --format` **声明为例外**。 + +## 顺序与理由 + +设计文档 §4.5 把 CDB 引号缺陷排在阶段 1 之前,理由是「协议做得再好,也救不了一个内容 +本身就坏掉的出口」。照此排: + +``` +W0 CDB 引号(§4.5) —— 今天就在坏,且与协议正交,先修 +W1 stdout 归属(阶段 0) —— 未知选项/未知值统一 stderr + rc=2 +W2 mcpp.wire 模块(阶段 1) —— envelope + destructive + --protocol-version +W3 --format 归一(阶段 2) —— --json 永久别名,入口一处归一 +W4 接入(阶段 3a) —— self env / xpkg parse / cache list +``` + +W4 之后的 `metadata` / `--configure-only`(阶段 3b/3c)**不在本次范围** —— 它们是新增 +能力而非契约统一,且依赖 #372 的拆分结论。 + +## W0 —— CDB 的 `arguments` 带 shell 引号 + +**File:** `src/build/compile_commands.cppm` + +- [ ] **Step 1** 红:带空格路径 + llvm 的单测,断言 + ① 任何 token 不以引号开头/结尾;② 带空格的路径是**一个** token +- [ ] **Step 2** 跑,确认 FAIL(今天两条都不满足) +- [ ] **Step 3** 让 `split_flags` 认引号。**顺序陷阱**:ninja 的 `$ ` 反转义必须发生在 + 引号**内**,否则被引号包住的空格先把 token 切断 —— 那正是现在的 bug +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +判据不能写成「clangd 能用了」——在不含空格的路径上恒真,正是它至今没被发现的原因。 + +## W1 —— stdout 归属 + +**Files:** `src/cli.cppm`、`src/main.cpp`(+ 依赖 `mcpplibs.cmdline` 的处置) + +- [ ] **Step 1** 红:e2e 断言未知**选项**与未知**值**在通道(stderr)与退出码(2)上一致 +- [ ] **Step 2** 跑,FAIL(今天:未知选项 → stdout/rc=1;未知值 → stderr/rc=2) +- [ ] **Step 3** 实现。`mcpplibs.cmdline:127` 用 `std::println` 写 stdout,在依赖里 —— + **本次不改依赖**,改为 mcpp 侧接管 `ParseResult`:不走 `App::run()` 的内建错误 + 打印,自己输出到 stderr 并返回 2 +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +## W2 —— `mcpp.wire`(独立模块) + +**New:** `src/wire.cppm` + +- [ ] **Step 1** 单测:envelope 形状(`schemaVersion` / `kind` / `destructive` / + `mcpp.version` / `mcpp.protocol{min,max}` / `data` / `diagnostics`) +- [ ] **Step 2** 实现。`Diagnostic/Position/Range/Severity` 与 envelope 构造从 #372 的 + `src/ide/model.cppm`、`src/ide/snapshot.cppm` 提升(设计文档 §5-B) +- [ ] **Step 3** `mcpp --protocol-version`:输出 `{min,max}` **加命令 → destructive 静态表** + (设计 §2.3 —— untrusted 门要在执行前知道) +- [ ] **Step 4** golden fixture,且**反向验证过**(改字段名要变红,设计 §4) +- [ ] **Step 5** Commit + +## W3 —— `--format` 归一 + +**Files:** `src/cli.cppm`(入口归一)、各 `cmd_*.cppm` + +- [ ] **Step 1** 单测:`--json` 与 `--format json` 产出**逐字节相同** +- [ ] **Step 2/3** 入口一处 `--json` → `--format json`;核心只见 `--format` +- [ ] **Step 4** 绿;`--json` 永久保留、**不打 deprecation 警告** +- [ ] **Step 5** Commit + +`pack --format tar|dir` 不动(已定为例外),文档写明。 + +## W4 —— 接入 envelope + +**Files:** `cmd_self.cppm`(新增 `self env --format json`)、`cmd_xpkg.cppm`、`cmd_cache.cppm` + +- [ ] **Step 1** 每个 `kind` 一个 golden fixture(反向验证过) +- [ ] **Step 2** `self env` 覆盖 mcpp-vscode#8 §2.4:MCPP_HOME / registry / xlings home / + index repos / default toolchain +- [ ] **Step 3** `xpkg parse` / `cache list` 包进 envelope(`schemaVersion` 随之而来) +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +## 平台特化的去处 + +按要求:平台差异进 `src/platform/`,协议本体独立 `.cppm`。 + +- 协议本体 → **`src/wire.cppm`**(新模块,不依赖任何命令) +- 路径/引号的平台差异 → 已在 `src/platform/`(`env::path_list_separator` 等);W0 的 + 引号识别是**平台无关**的(引号本身两平台都要剥),不新增平台分支 + +## 交付 + +- 全部并入 **PR #385**(与 docs 一起) +- 版本已是 2026.8.8.3(刚发布)⇒ 本次**跳过版本 bump**,除非 CI 要求 +- xlings pin 已是 2026.8.8.1(索引最新)⇒ 无需再抬 diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index f2438d3a..d02d3cfc 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -21,6 +21,13 @@ import mcpp.libs.json; export namespace mcpp::build { +// Split one flag string into CDB `arguments` tokens. +// +// Exported because it IS the contract: what a consumer receives in +// `arguments` is decided here, and that contract needs pinning by test +// rather than by inspection of a whole generated document. +std::vector split_flags(std::string_view s); + // Generate compile_commands.json content as a string. std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& flags); @@ -54,50 +61,87 @@ bool is_c_source(const std::filesystem::path& src) { return ext == ".c" || ext == ".m"; } -// Split a flag string into individual tokens AND un-escape ninja-style -// path escapes (`$ ` → space, `$:` → `:`, `$$` → `$`). +} // namespace + +// Split one flag string into CDB `arguments` tokens. +// +// Three things happen here, and the ORDER between them is the whole point. +// +// 1. Ninja escapes are undone (`$ ` -> space, `$:` -> `:`, `$$` -> `$`). +// `flags.cppm::escape_path` adds them so a path survives embedding in a +// ninja rule string. A CDB consumer execs `arguments` literally -- no +// ninja -- so `C$:\Users\...` would be a path that does not exist. +// +// 2. Shell quoting is removed. `flags.cppm::shell_quote_arg` wraps a token +// whose characters would split or alter a word in `sh -c` / cmd.exe -- +// and its trigger set contains the BACKSLASH, so on Windows every +// path-bearing flag is quoted. That quoting is correct for ninja and +// wrong for a consumer that never invokes a shell: the quotes arrive as +// part of the filename. +// +// 3. Tokens split on spaces -- but not on a space that came from `$ `, and +// not on one inside quotes. +// +// Getting (3) wrong is what shipped: quoting was ignored entirely, so a +// quoted path containing a space was cut in half at that space. Measured on +// a real build under `/tmp/.../my project`: +// +// '-fmodule-file=std=/tmp/.../my project/.../std.pcm <- closing quote gone +// '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' // -// `flags.cppm::escape_path` ninja-escapes path arguments so they survive -// embedding in ninja rule strings. Those escaped strings are then captured -// into `f.cxx` / `f.cc` which is what we receive here. CDB consumers like -// clangd exec the `arguments` array literally — no ninja involved — so -// escaped chars must be undone or paths like `C:\Users\...` come through -// as `C$:\Users\...` and break clangd's path resolution on Windows. (The -// same issue would silently affect any POSIX path containing a space or -// `$` — those just happen to be rare.) +// One argument became two, one of them opening a quote that never closes. +// The comment that used to live here stated the right principle -- consumers +// exec literally, so escapes must be undone -- and implemented half of it. // -// Splitting and un-escaping in one pass is correct: a literal space inside -// a path appears as `$ ` in the input, which we must NOT treat as a token -// separator. +// A quote in the MIDDLE of a token is data: `-DGREETING="hi"` keeps its +// inner quotes, or the define changes meaning. Only a quote that OPENS a +// region is quoting. std::vector split_flags(std::string_view s) { std::vector out; std::string token; + bool started = false; // token has begun (so "" is a real empty arg) + char quote = 0; // active quote char, 0 when outside + auto flush = [&] { - if (!token.empty()) { - out.push_back(std::move(token)); - token.clear(); - } + if (started) { out.push_back(std::move(token)); token.clear(); } + started = false; }; + for (std::size_t i = 0; i < s.size(); ++i) { char c = s[i]; + + // Ninja escapes first, and INSIDE quotes too -- a quoted path's space + // arrives as `$ ` because flags.cppm escapes before it quotes. if (c == '$' && i + 1 < s.size()) { char nc = s[i + 1]; if (nc == ' ' || nc == ':' || nc == '$') { token.push_back(nc); + started = true; ++i; continue; } } - if (c == ' ') { - flush(); - } else { + + if (quote) { + if (c == quote) { quote = 0; continue; } // closes the region token.push_back(c); + continue; } + + // Opens a region only at a token boundary; elsewhere it is data. + if ((c == '"' || c == '\'') && !started) { quote = c; started = true; continue; } + + if (c == ' ') { flush(); continue; } + + token.push_back(c); + started = true; } flush(); return out; } +namespace { + std::vector local_include_args(const CompileUnit& cu) { std::vector args; args.reserve(cu.localIncludeDirs.size()); diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index b894106a..7be570db 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -92,3 +92,83 @@ TEST(CompileCommandsMerge, MalformedExistingFallsBackToFresh) { EXPECT_NE(merged.find("src/main.cpp"), std::string::npos) << merged; EXPECT_NE(merged.find("-O2"), std::string::npos) << merged; } + +// ── CDB arguments must be argv, not shell words ───────────────────────────── +// +// A consumer (clangd) execs `arguments` LITERALLY — no shell. So a token that +// still carries the quoting a shell would have removed is not a flag, it is a +// filename that does not exist. +// +// This is reachable on every platform, and was found on Windows first only +// because `shell_quote_arg`'s trigger set contains the backslash: every +// Windows path has one, so every path-bearing flag there gets quoted. On +// POSIX it takes a space in the path — which is why no e2e caught it: none of +// their project paths have one. +// +// Measured before the fix, from a real build under `/tmp/.../my project`: +// +// '-fmodule-file=std=/tmp/.../my project/.../std.pcm <- closing quote gone +// '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' +// +// The first is worse than a stray quote: the token was split at the space +// INSIDE the quotes, so one argument became two, one of them opening a quote +// that never closes. +namespace { + +bool is_quoted(std::string_view s) { + if (s.size() < 2) return false; + return (s.front() == '"' && s.back() == '"') + || (s.front() == '\'' && s.back() == '\''); +} +bool has_edge_quote(std::string_view s) { + return !s.empty() + && (s.front() == '"' || s.front() == '\'' + || s.back() == '"' || s.back() == '\''); +} + +} // namespace + +TEST(CompileCommandsArgs, StripsPosixQuotingFromAToken) { + auto out = mcpp::build::split_flags("-O2 '-fprebuilt-module-path=/a/b' -g"); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[1], "-fprebuilt-module-path=/a/b"); + for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; +} + +TEST(CompileCommandsArgs, StripsWindowsQuotingFromAToken) { + auto out = mcpp::build::split_flags(R"(-O2 "-IC:\Users\x\inc" -g)"); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[1], R"(-IC:\Users\x\inc)"); + for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; +} + +// The one that bit: a quoted path containing a space is ONE argument. The +// space arrives ninja-escaped (`$ `) because flags.cppm escapes before it +// quotes, so the un-escape has to happen INSIDE the quotes — do it in the +// wrong order and the token splits exactly here. +TEST(CompileCommandsArgs, AQuotedPathWithASpaceStaysOneToken) { + auto out = mcpp::build::split_flags( + "-O2 '-fmodule-file=std=/tmp/my$ project/std.pcm' -g"); + ASSERT_EQ(out.size(), 3u) << "the quoted path was split"; + EXPECT_EQ(out[1], "-fmodule-file=std=/tmp/my project/std.pcm"); + for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; +} + +// Unquoted input must keep behaving exactly as before: ninja escapes undone, +// split on spaces. +TEST(CompileCommandsArgs, UnquotedInputIsUnchanged) { + auto out = mcpp::build::split_flags("-IC$:/x -DA=1 -Idir$ with$ space"); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0], "-IC:/x"); + EXPECT_EQ(out[1], "-DA=1"); + EXPECT_EQ(out[2], "-Idir with space"); +} + +// A quote in the MIDDLE of a token is data, not quoting: `-DA="x"` must keep +// its inner quotes or the define changes meaning. +TEST(CompileCommandsArgs, InnerQuotesAreNotStripped) { + auto out = mcpp::build::split_flags(R"(-DGREETING="hi")"); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], R"(-DGREETING="hi")"); + EXPECT_FALSE(is_quoted(out[0])); +} From 3a4ddb36c46e343abadda152a4af51e3e5ce5e9d Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 17:43:31 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(cli):=20usage=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=B8=8D=E8=BF=9B=20stdout;=E5=B9=B6=E6=8A=8A=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E8=BD=AE=20review=20=E5=86=99=E8=BF=9B=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W1(阶段 0):`App::run(argc, argv)` 的 parse 错误用 std::println 打 **stdout** 并返回 1。 stdout 是机器可读请求独占的通道,于是 `mcpp cache list --format json | jq` 拿到的是 `Error: unknown option: --format`,而 stderr 是空的 —— 客户端无从区分「这个 mcpp 太旧」 和「命令失败了」。 那句 print 在 mcpplibs.cmdline(已发布依赖)里。改为 mcpp 侧接管 ParseResult,不跨包 发版,并给整个 CLI 一条规则:mcpp 说自己的话一律走 stderr。usage 错误 rc=2,与 `pack --format bogus` 对齐 —— 后者原本就对,而且是两者中唯一对的。 e2e 202 断言未知选项与未知值在通道与退出码上一致。 同时把第二轮 review 写进设计文档(综合 @wellwei / @Ximiaw 反馈)。开头先记一条: **第一轮没有做需求侧分析** —— 全在核对 mcpp 现在的行为,没读消费者的实际需求。 wellwei 的四条里有两条正是因此漏掉的: - `--json` 的 payload 兼容:`cache list --json` 顶层是 {entries,root},包进 envelope 是破坏性变更,本仓库 e2e 就会断。第一轮把它当纯拼写别名,错了。 - `self env` 的副作用。这条我第一次也测错了:只在全新 home 上跑一次、看到 6 个文件就 下结论,没有排除「任何命令的首次初始化」。加对照才定住 —— `--version` 在全新 home 上连目录都不建,所以是 `self env` 首次运行触发一次性初始化,已初始化后只读。结论 不变(新机器上标 destructive:false 仍是谎),但要拆的是「读 vs 首次初始化」,不是 「读 vs 写」。方法教训也写进去了:「跑一次看有没有文件」不足以给副作用定责,必须 有一个不走同一路径的对照命令。 另外三条:destructive 单个 bool 不够(要效应集合,exec-build-script 单列);全局 schemaVersion 把不相关 kind 耦合;manifest 固定段的 supported-key 只覆盖少数段 (Ximiaw 实测 24 段)。 结论:W2/W3/W4 在 payload 兼容策略定案前不动手 —— 那是唯一决定 wire v1 形状的分叉。 W0 与 W1 与它正交,已完成。 67 单测;e2e 202 通过 --- ...machine-readable-output-protocol-design.md | 141 ++++++++++++++++++ src/cli.cppm | 26 +++- tests/e2e/202_machine_output_contract.sh | 52 +++++++ 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100755 tests/e2e/202_machine_output_contract.sh diff --git a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md index 16f145b3..0c4c4524 100644 --- a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md +++ b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md @@ -261,3 +261,144 @@ CDB 是 mcpp 已经在发的**机器可读输出**,只是没走 envelope。它 请求不往协议通道写东西。 - **`pack --format tar|dir` 声明为例外**,不加 `--layout` 别名。它没有 stdout 机器 输出,不参与本协议;为一个不存在的一致性付迁移成本不值。 + +--- + +# 第二轮 review(2026-08-08,综合 @wellwei / @Ximiaw 反馈) + +## R0. 先承认一件事:第一轮**没有做需求侧分析** + +第一轮全部是供给侧 —— 核对 mcpp 现在的行为,找实现层缺陷。**没有**读 +mcpp-vscode#8 / #5 的实际需求,也没有验证「提议的接口是否闭合了它们」。 + +wellwei 指出的四条里,**两条正是需求侧分析才会抓到、而第一轮漏掉的**(R1、R3)。 +这一节存在的意义不是自责,而是记下判据:**契约设计里,「我方能提供什么」和 +「对方需要什么」是两次独立的核对,做了前者不等于做了后者。** + +## R1. `--json` 的 payload 兼容 —— 第一轮把它当纯拼写别名,错了 + +实测: + +``` +mcpp cache list --json 顶层键 = ["entries", "root"] ← 没有 envelope +mcpp xpkg parse --json 顶层 = {"namespace","name","form"} ← 没有 envelope +``` + +第一轮跟着 RFC 写「入口一处归一化,核心只见 `--format`」,默认了**拼写兼容 ⇒ payload +兼容**。不成立:如果核心随后统一输出 `{schemaVersion, kind, data, diagnostics}`,任何 +依赖旧顶层结构的消费者都会断,**包括本仓库的 e2e**。 + +**修正 —— 三选一必须先定,否则 wire v1 不能冻结:** + +| 方案 | 含义 | 代价 | +|---|---|---| +| **(A) `--json` 永久 legacy payload,`--format json` 才是 envelope** | 两条出口,语义不同 | 每个命令两份序列化;但**没有任何现存消费者会断** | +| (B) 老命令保留旧顶层,只做增量字段;envelope 只用于新命令 | 老命令永远拿不到 `schemaVersion` | mcpp-vscode#8 §7.1 的请求落空 | +| (C) 有意 breaking,给迁移窗口 | 干净 | 与「`--json` 永久保留、不打警告」的既定纪律冲突 | + +**倾向 (A)**,理由是它与本仓库既有范式一致(`compat.cppm`:旧拼写永久接受,核心只见 +规范形式),而这里要永久接受的是**旧 payload**,不是旧拼写。代价是明确且有界的。 + +## R2. `destructive` —— 与第一轮结论一致,但 wellwei 补了一刀 + +第一轮已指出它做不了 preflight 门(§2.3),wellwei 独立确认,并补充: +**单个 bool 也不够** —— 「只写 CDB」「写全局缓存」「可能触网」「执行工作区代码」是四种 +不同的边界,VSCode 的门需要区分。 + +**修正**:`--protocol-version` 的静态表不是 `destructive: bool`,而是**效应集合**: + +```jsonc +"commands": { + "self env": { "effects": [] }, + "xpkg parse": { "effects": [] }, + "metadata": { "effects": ["read-project"] }, + "metadata --resolved": { "effects": ["read-project","write-cache","network"] }, + "build --configure-only": { "effects": ["read-project","write-project","write-cache","exec-build-script"] } +} +``` + +`exec-build-script` 单独成项,因为 `build.mcpp` 会执行工作区里的代码 —— 那是 untrusted +门唯一真正在乎的一条。 + +## R3. `self env` 首次运行会初始化 —— 不是「每次都写」,但对契约是同一件事 + +wellwei 指出 `doctor.cppm::env_report()` 调 `config::load_or_init()`,不是只读。核实。 + +**第一次测法不足以定责**:我只在全新 `MCPP_HOME` 上跑了一次 `self env`,看到 6 个文件 +就下了结论 —— 但那没有排除「任何 mcpp 命令的首次初始化」。分离之后: + +| 测法 | 结果 | +|---|---| +| 全新 home + `mcpp --version` | **目录都没创建** | +| 全新 home + `mcpp self env` | 创建 `config.toml` `registry` `cache` `bin` `build-cache` `log` | +| **已初始化** home + `mcpp self env` | **无变化,只读** | + +所以准确的表述是:**`self env` 不是每次都写,而是首次运行时触发一次性初始化**; +`--version` 不走那条路径,所以「所有命令都这样」不成立。 + +**这改变修法,不改变结论:** + +- 标 `destructive: false` 仍然是错的 —— 在一台新机器上它是谎,而 untrusted-workspace + 门恰恰在新机器上最需要生效。契约按**最坏情况**写。 +- 但要拆的不是「读 vs 写」——`env_report()` 在已初始化的机器上本来就只读。 +- 要拆的是**「读 vs 首次初始化」**:JSON 路径用一个只计算路径、只读已存在配置的 + resolver,未初始化时返回推导值 + `initialized: false`,**不建目录、不 bootstrap、 + 不触网**。人类输出保持现状(用户对 `mcpp self env` 顺带初始化是有预期的)。 + +顺带记下这次的方法教训:**「跑一次,看有没有文件」不足以给副作用定责** —— 必须有一个 +不走同一路径的对照命令。这条不是这一处的技巧,是所有「某命令有无副作用」的判定通法。 + +## R4. 阶段 0 的边界比第一轮画的更宽 + +实测退出码: + +``` +未知子命令 rc=127 (cli.cppm:610,stderr,正确) +未知选项 rc=2 ← 本轮 W1 已修(原 rc=1 + stdout) +未知值(pack) rc=2 (stderr,原本就对) +未捕获异常 rc=70 (main.cpp,stderr) +``` + +W1 修掉了最坏的一条。但 wellwei 说得对:**光接管 parse error 不够**,还要把 +usage / runtime / internal 的 rc 映射写成契约,并覆盖异常边界 —— 否则客户端仍然要靠 +猜。这条现在是 `docs/spec/` 的内容,不是代码。 + +## R5. 全局 `schemaVersion` 把不相关的 kind 耦合在一起 + +wellwei 的观察成立。一个全局版本号意味着 `mcpp.env` 的字段变更会推高 `mcpp.xpkg` 的 +版本,客户端无从判断哪个 kind 真的变了。 + +**修正**:envelope 版本与 kind 数据版本分开;并且 `--protocol-version` 不能只回 +`{min,max}`,要回**它支持哪些 kind 及各自版本**: + +```jsonc +{ "envelope": { "min": 1, "max": 1 }, + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 } } +``` + +## R6. Ximiaw 的实测修正:supported-key 词汇表只覆盖部分段 + +他实测 24 段 + 源码核对,结论是「parser 已掌握每段 supported keys」**只对少数段成立**: + +- 有白名单的:`[build]`、`[resources]` 等 6 处常量(`src/manifest/toml.cppm`) +- 开放词汇段(dependencies / indices / toolchain …):无键清单,只校验值形态 +- **其余固定段(package / profile / runtime / xlings / workspace / pack):未知键被静默 + 吞掉**,合法键散在解析逻辑里 + +所以「顺带导出词汇表」不是现成的序列化。**但第三档的收敛有独立价值**:把「打错键名 +毫无提示」变成警告,对 mcpp 自身就是健壮性收益 —— 这一条应当独立于本协议推进,不该 +被 wire v1 阻塞。 + +## R7. 修正后的范围 + +| | 第一轮 | 本轮修正 | +|---|---|---| +| W0 CDB 引号 | 做 | **不变**(已完成) | +| W1 stdout 归属 | 做 | **已完成**,但 rc 映射契约要补进 spec(R4) | +| W2 `mcpp.wire` | envelope + `destructive: bool` | envelope + **效应集合**;`--protocol-version` 回 **kinds 及各自版本**(R2/R5) | +| W3 `--format` 归一 | 纯拼写别名 | **先定 payload 兼容策略(R1),否则不能冻结 v1** | +| W4 接入 | self env ~15 行 | **先拆只读 resolver**(R3);`cache list` / `xpkg parse` 按 R1 的结论决定走哪条 | +| 新增 | — | manifest 固定段的 supported-key 收敛(R6),**独立推进** | + +**结论:W2/W3/W4 在 R1 定案前不应动手** —— 那是唯一会决定 wire v1 形状的分叉。W0 与 +W1 与它正交,已完成。 diff --git a/src/cli.cppm b/src/cli.cppm index 5e6c6c10..630820fb 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -612,8 +612,30 @@ int run(int argc, char** argv) { } } - auto app_rc = app.run(trimmed_argc, trimmed_argp); - if (app_rc != 0) return app_rc; + // Parse and dispatch separately, rather than `app.run(argc, argv)`. + // + // `App::run` prints its parse errors with `std::println` -- to STDOUT -- + // and returns 1. stdout is the channel a machine-readable request owns, so + // a client doing `mcpp cache list --format json | jq` got + // `Error: unknown option: --format` fed to its parser, with nothing to + // distinguish "this mcpp is too old" from "the command failed". stderr was + // empty. + // + // That print lives in mcpplibs.cmdline, a published dependency. Taking the + // ParseResult here fixes it without a cross-package release, and keeps one + // rule for the whole CLI: anything mcpp says ABOUT ITSELF goes to stderr. + // + // Exit 2 for a usage error, matching `pack --format bogus` -- which was + // already right, and was the only one of the two that was. + auto parsed = app.parse(trimmed_argc, trimmed_argp); + if (!parsed) { + // `--help` / `--version` come back as a non-error "failure": the + // parser handled them and printed. Nothing to add, nothing to report. + if (!parsed.error().is_error()) return 0; + mcpp::ui::error(parsed.error().message); + return 2; + } + app.run(*parsed); return action_rc; } diff --git a/tests/e2e/202_machine_output_contract.sh b/tests/e2e/202_machine_output_contract.sh new file mode 100755 index 00000000..d2341675 --- /dev/null +++ b/tests/e2e/202_machine_output_contract.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# 202_machine_output_contract.sh — stdout belongs to the protocol. +# +# A client that asks for machine-readable output has exactly one channel for +# it. Anything mcpp says about ITSELF -- a usage error, an unsupported format +# -- has to go somewhere else, or the client's parser eats it. +# +# Measured before this test existed: +# +# mcpp cache list --format json -> stdout: "Error: unknown option: --format" +# stderr: (empty) rc=1 +# mcpp pack --format bogus -> stderr: "invalid --format ..." rc=2 +# +# Two shapes for the same class of mistake, and the first one writes human text +# into the channel a protocol owns. `mcpp cache list --format json | jq` gets a +# parse error with nothing to distinguish "this mcpp is too old" from "the +# command failed". +# +# Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md §2 +set -uo pipefail + +fail=0 +check() { # description expected_rc cmd... + local what=$1 want=$2; shift 2 + local out err rc + out=$("$@" 2>/dev/null); rc=$? + err=$("$@" 2>&1 >/dev/null) + if [[ -n "$out" ]]; then + echo "FAIL: $what wrote to stdout: $(echo "$out" | head -1 | cut -c1-70)" + fail=1 + fi + if [[ -z "$err" ]]; then + echo "FAIL: $what said nothing on stderr" + fail=1 + fi + if [[ "$rc" != "$want" ]]; then + echo "FAIL: $what exited $rc, expected $want" + fail=1 + fi +} + +# An option this command does not have. +check "unknown option (cache list)" 2 "$MCPP" cache list --format json +check "unknown option (self env)" 2 "$MCPP" self env --format json +check "unknown option (top level)" 2 "$MCPP" --no-such-option + +# A value this command does not accept. Already correct today; asserted so it +# stays that way while the option path is changed around it. +check "unknown value (pack)" 2 "$MCPP" pack --format bogus + +[[ "$fail" -eq 0 ]] || exit 1 +echo "PASS: usage errors stay off stdout and exit 2" From 5db9096b7272f8fa561d846e26e96b20f89c4a9e Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 17:45:49 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20R3=20=E7=9A=84=E7=9C=9F=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E6=98=AF=E4=BD=9C=E7=94=A8=E5=9F=9F,=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E7=9C=9F=E5=81=87(=E8=A1=A5=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补了对照测量,推翻两个说法 —— 包括我自己前一版的: --version 0 项 xpkg parse --json 0 项 <- mcpp-vscode#8 实际消费的 cache list --json 0 项 <- 同上 self env 6 项 已初始化 home + self env 只读 「首次运行必然初始化,所以任何命令都 destructive」不成立:插件真正消费的两个命令 什么都不建,字段不会退化成恒真。 而我前一版写「标 destructive:false 是谎」也过重了 —— 真正的分叉是**作用域**: untrusted 门在乎的是碰不碰用户项目、执不执行项目代码,mcpp 建自己的 home 不属于这类。 倾向用效应集合把作用域写进名字(self env = [init-mcpp-home]),既不把它打成危险,也 不让 false 被读成「什么都不写」。 方法教训单独记:「跑一次看有没有文件」不足以给副作用定责,必须有不走同一路径的对照 命令 —— 缺了它就会得出一个由测法而非事实支撑的结论。 --- ...machine-readable-output-protocol-design.md | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md index 0c4c4524..e8d5665a 100644 --- a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md +++ b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md @@ -320,33 +320,40 @@ mcpp xpkg parse --json 顶层 = {"namespace","name","form"} ← 没有 envelo `exec-build-script` 单独成项,因为 `build.mcpp` 会执行工作区里的代码 —— 那是 untrusted 门唯一真正在乎的一条。 -## R3. `self env` 首次运行会初始化 —— 不是「每次都写」,但对契约是同一件事 +## R3. `self env` 首次运行会初始化 —— 真问题是**作用域**,不是真假 -wellwei 指出 `doctor.cppm::env_report()` 调 `config::load_or_init()`,不是只读。核实。 +wellwei 指出 `env_report()` 调 `config::load_or_init()`,不是只读。核实,并把两次测法 +都记下来,因为**第一次测法不足以定责**。 -**第一次测法不足以定责**:我只在全新 `MCPP_HOME` 上跑了一次 `self env`,看到 6 个文件 -就下了结论 —— 但那没有排除「任何 mcpp 命令的首次初始化」。分离之后: +第一次:只在全新 `MCPP_HOME` 上跑一次 `self env`,看到 6 个文件就下结论 —— 没有排除 +「任何 mcpp 命令的首次初始化」。加对照后: -| 测法 | 结果 | +| 命令 | 全新 home 上创建项数 | |---|---| -| 全新 home + `mcpp --version` | **目录都没创建** | -| 全新 home + `mcpp self env` | 创建 `config.toml` `registry` `cache` `bin` `build-cache` `log` | -| **已初始化** home + `mcpp self env` | **无变化,只读** | - -所以准确的表述是:**`self env` 不是每次都写,而是首次运行时触发一次性初始化**; -`--version` 不走那条路径,所以「所有命令都这样」不成立。 - -**这改变修法,不改变结论:** - -- 标 `destructive: false` 仍然是错的 —— 在一台新机器上它是谎,而 untrusted-workspace - 门恰恰在新机器上最需要生效。契约按**最坏情况**写。 -- 但要拆的不是「读 vs 写」——`env_report()` 在已初始化的机器上本来就只读。 -- 要拆的是**「读 vs 首次初始化」**:JSON 路径用一个只计算路径、只读已存在配置的 - resolver,未初始化时返回推导值 + `initialized: false`,**不建目录、不 bootstrap、 - 不触网**。人类输出保持现状(用户对 `mcpp self env` 顺带初始化是有预期的)。 - -顺带记下这次的方法教训:**「跑一次,看有没有文件」不足以给副作用定责** —— 必须有一个 -不走同一路径的对照命令。这条不是这一处的技巧,是所有「某命令有无副作用」的判定通法。 +| `mcpp --version` | 0 | +| **`mcpp xpkg parse --json`** | **0** | +| **`mcpp cache list --json`** | **0** | +| `mcpp self env` | **6**(`config.toml` `registry` `cache` `bin` `build-cache` `log`) | +| 已初始化 home + `self env` | **无变化,只读** | + +两条结论: + +1. **「首次运行必然初始化」不成立。** mcpp-vscode#8 实际消费的两个命令 + (`xpkg parse` / `cache list`)**什么都不建**。所以按「任何写盘都算」的口径, + `destructive` **仍然携带信息** —— 它不会退化成恒真。 +2. `self env` 是首次运行触发一次性初始化,**不是每次都写**。 + +**因此真正的分叉是作用域,不是真假。** untrusted-workspace 门在乎的是「碰不碰用户的 +项目」「执不执行项目代码」;mcpp 建**自己的 home** 不属于这一类。两条路: + +- **(a) 效应集合,作用域写在名字里**(倾向)。`self env` = `["init-mcpp-home"]`, + 客户端自己决定要不要在意;既不把它打成「危险」,也不让 `false` 被读成「什么都不写」。 +- (b) 直接定「作用域 = 工作区」,`self env` 标 `false`。更简单,但 spec 必须写死 + 「本字段不涵盖 mcpp 自身 home 的初始化」,否则 `false` 有歧义。 + +方法教训单独记:**「跑一次看有没有文件」不足以给副作用定责,必须有一个不走同一路径的 +对照命令。** 这一处若不加对照,就会得出「所有命令都会初始化,所以这个字段没意义」—— +一个由测法而非事实支撑的结论。 ## R4. 阶段 0 的边界比第一轮画的更宽 From 56731be3dd25b7041ac9448a460353bb1a14af6d Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 18:02:19 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(wire):=20=E6=9C=BA=E5=99=A8=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E8=BE=93=E5=87=BA=E5=8D=8F=E8=AE=AE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BF=A1=E5=B0=81=E3=80=81=E6=95=88=E5=BA=94?= =?UTF-8?q?=E3=80=81--format=20=E5=BD=92=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 .agents/docs/2026-08-08-machine-readable-output-protocol-design.md 实施 W2–W4(W0 CDB 引号、W1 stdout 归属已在前两个提交)。 **协议本体单独成模块**(`src/wire.cppm`),不认识任何命令 —— 命令→效应表放在 `cli.cppm` 里、紧邻命令注册处,否则新增一条命令要改两个文件且容易漏。 三条设计,每条都来自实测而非偏好: 1. **信封自识别,客户端靠解析识别,不靠退出码。** `--protocol-version` 解决不了它 看起来能解决的问题:在它出现之前的每个 mcpp 上,它自己就是未知选项,而未知选项 过去把人类文本打到 stdout、rc=1、stderr 为空 —— 成功与失败同通道。换 `--json` 拼写也一样。所以判据只能是「stdout 能解析出 schemaVersion + kind」。 2. **效应集合,不是 destructive 布尔。** 实测:全新 MCPP_HOME 上 `xpkg parse` 与 `cache list` 什么都不建,`self env` 建 6 项。布尔分不开「mcpp 给自己做初始化」和 「执行工作区里的代码」,而 IDE 的门只在乎后者。效应同时进 `--protocol-version` 的静态表 —— 门必须在运行前决定,等信封到手事情已经发生了。 3. **`--json` 永久保留 legacy payload,`--format json` 才带信封。** 拼写兼容不等于 payload 兼容:`cache list --json` 顶层是 `{root, entries}` 且本仓库 e2e 已断言。 两种拼写由同一个来源产出,不会漂移。 `self env --format json` 走独立的只读路径,不调 `load_or_init`:客户端问「东西在哪」 不该成为把东西放到那儿的原因。全新 home 上返回完整路径 + `initialized: false`, 创建项数 **0**(人类路径不变,仍会初始化)。 未知选项与不支持的值统一 stderr + rc=2,stdout 一字不写。 文档:`docs/11-machine-output.md` 与中文版,含「你可以依赖什么」一节 —— 并写明每个 kind 都有一个「改字段名就变红」的测试,因为没人能弄坏的 schema 不是 schema。 tests/unit/test_wire.cpp +14;68 单测;e2e 202 --- docs/11-machine-output.md | 231 ++++++++++++++++++++ docs/README.md | 1 + docs/zh/11-machine-output.md | 202 ++++++++++++++++++ docs/zh/README.md | 1 + src/bmi_cache/maintenance.cppm | 52 +++-- src/cli.cppm | 52 ++++- src/cli/cmd_cache.cppm | 19 ++ src/cli/cmd_self.cppm | 64 +++++- src/cli/cmd_xpkg.cppm | 51 ++++- src/wire.cppm | 259 +++++++++++++++++++++++ tests/e2e/202_machine_output_contract.sh | 25 ++- tests/unit/test_wire.cpp | 161 ++++++++++++++ 12 files changed, 1080 insertions(+), 38 deletions(-) create mode 100644 docs/11-machine-output.md create mode 100644 docs/zh/11-machine-output.md create mode 100644 src/wire.cppm create mode 100644 tests/unit/test_wire.cpp diff --git a/docs/11-machine-output.md b/docs/11-machine-output.md new file mode 100644 index 00000000..d3060866 --- /dev/null +++ b/docs/11-machine-output.md @@ -0,0 +1,231 @@ +# 11 — Machine-Readable Output + +mcpp writes for two audiences. This chapter is the contract for the second one: +programs. If you are building an editor extension, a CI script, or anything +that parses mcpp's output, this is what you may rely on. + +Design and the measurements behind it: +`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`. + +## 1. The rule that matters most + +> **Detect the protocol by parsing stdout. Never by exit code, and never by +> "the command did not fail".** + +Read stdout, try to parse it as JSON, and require `schemaVersion` and `kind` +to be present. If either is missing, this mcpp does not speak the protocol you +asked for. + +This is not a stylistic preference. `mcpp --protocol-version` looks like it +should be the entry point, and on a version that has it, it is a useful +shortcut. But on **every mcpp released before it existed**, that command is +itself an unknown option — and an unknown option used to print human text to +*stdout* with exit code 1 and an empty stderr. Success and failure arrived on +the same channel. Spelling it `--json` instead changes nothing; both hit the +same path. + +So positive detection is the only rule that works across versions. Everything +below is designed around it. + +## 2. The envelope + +Every enveloped response has this shape: + +```jsonc +{ + "schemaVersion": 1, // the ENVELOPE's version + "kind": "mcpp.env", // which document this is + "kindVersion": 1, // this kind's own data version + "effects": [], // what running the command did — see §4 + "mcpp": { + "version": "2026.8.8.3", + "protocol": { "min": 1, "max": 1 } + }, + "data": { /* specific to `kind` */ }, + "diagnostics": [] +} +``` + +`schemaVersion` and `kindVersion` are separate on purpose. One global number +would mean that adding a field to `mcpp.env` moves the version a client reads +for `mcpp.xpkg`, with no way to tell which actually changed. + +`effects` is always present. An empty array means "nothing"; an absent array +would mean "unknown", which is a different claim. + +### Diagnostics + +```jsonc +{ + "code": "MCPP_MANIFEST_UNKNOWN_KEY", + "severity": "error" | "warning" | "note", + "source": "mcpp", + "message": "unknown key 'standrad'", + "path": "mcpp.toml", // omitted when there is none + "range": { "start": {"line": 3, "column": 1}, + "end": {"line": 3, "column": 9} } // omitted when there is none +} +``` + +Positions are 1-based. `column` counts UTF-8 **bytes**, so it indexes the same +file mcpp read. + +A diagnostic without a location omits `path` and `range` rather than sending +zeros — `line: 0` would point at a position that does not exist. + +`code` is always present. Parse `code`; never parse `message`. + +## 3. Asking for machine output + +``` +mcpp --format json +``` + +`json` is the only supported value today. `ndjson` is reserved for a future +streaming case and is **not** accepted — asking for it is an error, not a +silent fallback. + +### Unsupported values and unknown options + +Both go to **stderr** with **exit code 2**, and write nothing to stdout: + +``` +$ mcpp self env --format yaml +error: unsupported --format 'yaml'; expected: json # stderr +$ echo $? +2 +``` + +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: + +| code | meaning | +|---|---| +| 0 | success | +| 2 | usage error — unknown option, unsupported value | +| 70 | internal error (uncaught exception) | +| 127 | unknown command | + +## 4. Effects — what a command does before it prints + +An IDE with an untrusted-workspace gate has to decide **before** running. +By the time an envelope arrives, whatever it describes has already happened. +So the same information is available statically: + +``` +mcpp --protocol-version +``` + +```jsonc +{ + "schemaVersion": 1, + "kind": "mcpp.protocol", + "envelope": { "min": 1, "max": 1 }, + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 }, + "commands": { + "self env": { "effects": ["init-mcpp-home"] }, + "xpkg parse": { "effects": [] }, + "cache list": { "effects": [] } + } +} +``` + +Effects are named rather than a `destructive: true|false`, because a boolean +cannot separate the harmless from the thing a gate exists for: + +| effect | meaning | +|---|---| +| `init-mcpp-home` | may create `$MCPP_HOME` on first use. **Outside your project.** | +| `read-project` | reads the manifest and sources | +| `write-project` | writes into the project tree (`target/`, the compile DB) | +| `write-global-cache` | writes the shared build cache | +| `network` | may fetch | +| `exec-build-script` | **runs code from the workspace** (`build.mcpp`) | + +Most gates care about `exec-build-script` and `write-project`, and can ignore +`init-mcpp-home` — mcpp setting itself up is not the workspace acting. + +## 5. `--json` is not `--format json` + +Two commands shipped a `--json` flag before this protocol existed: + +``` +mcpp xpkg parse --json -> {"namespace": …, "name": …, …} +mcpp cache list --json -> {"root": …, "entries": [ … ]} +``` + +Those payloads are **bare** — no envelope — and consumers already read them. +So: + +> **`--json` keeps its payload for ever. `--format json` is the enveloped one.** + +`--json` is not deprecated, and using it prints no warning: clients parse this +output, and a warning would land in the middle of it. + +Both spellings are produced from the same source, so they always describe the +same thing — one answer, two shapes. + +## 6. What you may rely on, and what changes + +For each `kind`, within a `kindVersion`: + +- fields are **added**, never removed +- the meaning of a field never changes +- a breaking change bumps the version and, where a window is needed, + `protocol.min`/`max` overlap so both are readable + +That promise is only worth something if it is enforced, so each kind has a +test that fails when a field name changes. A schema nobody can break is not a +schema — `xlings interface --list` declares 20 capabilities whose +`outputSchema` is, for all 20, only `{"exitCode": integer}`, and a client that +sees a version number assumes there is a contract behind it. + +## 7. Kinds + +### `mcpp.env` — where mcpp keeps things + +``` +mcpp self env --format json +``` + +```jsonc +{ + "initialized": false, // is there a config.toml yet? + "mcppHome": "/home/u/.mcpp", + "registry": "/home/u/.mcpp/registry", + "xlingsHome": "/home/u/.mcpp/registry", + "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", + "config": "/home/u/.mcpp/config.toml", + "buildCache": "/home/u/.mcpp/build-cache/v1", + "mcppVersion": "2026.8.8.3" +} +``` + +This path is read-only, deliberately. The human `mcpp self env` initialises +`$MCPP_HOME` if it is missing — someone typing it at a prompt expects that — +but a client asking *where things are* should not be what puts them there. On +a machine that has never run mcpp you get the paths it **would** use and +`initialized: false`, and the disk is untouched. + +That is why this exists at all: without it a client has to reimplement mcpp's +home resolution, including the part where the `mcpp` on `PATH` may be an +xlings shim rather than the real binary. + +### `mcpp.xpkg` — a parsed descriptor + +``` +mcpp xpkg parse --format json +``` + +`data` is the same document `--json` prints bare. + +### `mcpp.cache` — the global build cache + +``` +mcpp cache list --format json +``` + +`data` is `{root, entries[]}`, the same document `--json` prints bare. diff --git a/docs/README.md b/docs/README.md index 4d8637f0..2118e5ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [08 - Toolchain Internals](08-toolchain-internals.md) - [09 - Releasing mcpp](09-release.md) - [10 - Publishing a Library to mcpp-index](10-publishing-a-library.md) +- [11 - Machine-Readable Output](11-machine-output.md) ## Specifications diff --git a/docs/zh/11-machine-output.md b/docs/zh/11-machine-output.md new file mode 100644 index 00000000..2b72788a --- /dev/null +++ b/docs/zh/11-machine-output.md @@ -0,0 +1,202 @@ +# 11 — 机器可读输出 + +mcpp 面向两类读者。本章是对第二类 —— **程序** —— 的契约。如果你在写编辑器扩展、CI +脚本,或任何解析 mcpp 输出的东西,这里写的是你可以依赖的部分。 + +设计与背后的实测:`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`。 + +## 1. 最重要的一条规则 + +> **靠解析 stdout 来识别协议。不要靠退出码,也不要靠「命令没失败」。** + +读 stdout,尝试按 JSON 解析,并要求 `schemaVersion` 与 `kind` 都在。缺任一,说明这个 +mcpp 不支持你要的东西。 + +这不是风格偏好。`mcpp --protocol-version` 看起来像是入口,在支持它的版本上确实是个 +捷径。但在**它出现之前发布的每一个 mcpp** 上,它自己就是一个未知选项 —— 而未知选项 +过去会把人类文本打到 **stdout**、退出码 1、stderr 为空。成功与失败同一通道。换成 +`--json` 拼写也一样,两者走同一条路径。 + +所以「正向识别」是唯一跨版本成立的规则。下面所有设计都围绕它。 + +## 2. 信封 + +```jsonc +{ + "schemaVersion": 1, // 信封自身的版本 + "kind": "mcpp.env", // 这是哪种文档 + "kindVersion": 1, // 该 kind 的数据版本 + "effects": [], // 运行这条命令做了什么 —— 见 §4 + "mcpp": { "version": "2026.8.8.3", "protocol": { "min": 1, "max": 1 } }, + "data": { /* 随 kind 而定 */ }, + "diagnostics": [] +} +``` + +`schemaVersion` 与 `kindVersion` 分开是有意的:单一全局版本号意味着给 `mcpp.env` 加 +一个字段会推高客户端为 `mcpp.xpkg` 读到的版本,而它无从判断真正变的是哪个。 + +`effects` 永远存在。空数组表示「没有」;字段缺失会表示「未知」,那是另一个断言。 + +### 诊断 + +```jsonc +{ + "code": "MCPP_MANIFEST_UNKNOWN_KEY", + "severity": "error" | "warning" | "note", + "source": "mcpp", + "message": "unknown key 'standrad'", + "path": "mcpp.toml", // 没有则省略 + "range": { "start": {"line":3,"column":1}, + "end": {"line":3,"column":9} } // 没有则省略 +} +``` + +位置从 1 开始。`column` 数的是 **UTF-8 字节**,因此索引的是 mcpp 读到的同一份文件。 + +没有位置的诊断**省略** `path` 与 `range`,而不是填 0 —— `line: 0` 会把客户端指向一个 +不存在的位置。 + +`code` 永远存在。**解析 `code`,永远不要解析 `message`。** + +## 3. 请求机器输出 + +``` +mcpp <命令> --format json +``` + +目前只支持 `json`。`ndjson` 保留给未来真正需要流式的场景,**现在不接受** —— 请求它是 +错误,不是静默回落。 + +### 不支持的值 / 未知选项 + +两者都走 **stderr**、退出码 **2**,且**不往 stdout 写任何东西**: + +``` +$ mcpp self env --format yaml +error: unsupported --format 'yaml'; expected: json # stderr +$ echo $? +2 +``` + +一个还不知道自己会得到什么格式的请求,不该往协议独占的通道里写东西。结合 §1,客户端的 +规则就完整了:**stdout 上没有 JSON,就是不支持**,无论原因。 + +| 退出码 | 含义 | +|---|---| +| 0 | 成功 | +| 2 | 用法错误 —— 未知选项、不支持的值 | +| 70 | 内部错误(未捕获异常) | +| 127 | 未知命令 | + +## 4. effects —— 命令在输出之前做了什么 + +带 untrusted-workspace 门的 IDE 必须在**运行之前**决定。等信封到手,它描述的事情已经 +发生了。所以同一份信息也静态提供: + +``` +mcpp --protocol-version +``` + +```jsonc +{ + "kind": "mcpp.protocol", + "envelope": { "min": 1, "max": 1 }, + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 }, + "commands": { + "self env": { "effects": ["init-mcpp-home"] }, + "xpkg parse": { "effects": [] }, + "cache list": { "effects": [] } + } +} +``` + +用具名 effects 而不是 `destructive: true|false`,因为布尔分不开「无害的」和「门存在的 +理由」: + +| effect | 含义 | +|---|---| +| `init-mcpp-home` | 首次使用时可能创建 `$MCPP_HOME`。**在你的项目之外。** | +| `read-project` | 读 manifest 与源码 | +| `write-project` | 写入项目树(`target/`、compile DB) | +| `write-global-cache` | 写共享构建缓存 | +| `network` | 可能联网 | +| `exec-build-script` | **执行工作区里的代码**(`build.mcpp`) | + +多数门只在乎 `exec-build-script` 与 `write-project`,可以忽略 `init-mcpp-home` —— +mcpp 给自己做初始化不是工作区在动作。 + +## 5. `--json` 不等于 `--format json` + +有两条命令在本协议之前就发布了 `--json`: + +``` +mcpp xpkg parse --json -> {"namespace": …, "name": …, …} +mcpp cache list --json -> {"root": …, "entries": [ … ]} +``` + +这些 payload 是**裸的**(没有信封),而且已经有消费者在读。所以: + +> **`--json` 永久保留它的 payload。`--format json` 才是带信封的那个。** + +`--json` 不弃用,使用它也**不打任何警告** —— 客户端在解析这份输出,警告会落在它中间。 + +两种拼写由同一个来源产出,所以永远描述同一件事:一个答案,两种形状。 + +## 6. 你可以依赖什么 + +对每个 `kind`,在同一 `kindVersion` 内: + +- 字段只**新增**,不删除 +- 字段含义不改变 +- 破坏性变更抬版本;需要过渡窗口时,`protocol.min`/`max` 重叠,两版都可读 + +这个承诺只有被强制执行才值钱,所以每个 kind 都有一个「改字段名就变红」的测试。 +**没人能弄坏的 schema 不是 schema** —— `xlings interface --list` 声明了 20 个 +capability,其 `outputSchema` 全部只有 `{"exitCode": integer}`,而客户端看到版本号就会 +以为背后有契约。 + +## 7. 各 kind + +### `mcpp.env` —— mcpp 把东西放在哪 + +``` +mcpp self env --format json +``` + +```jsonc +{ + "initialized": false, // 还没有 config.toml + "mcppHome": "/home/u/.mcpp", + "registry": "/home/u/.mcpp/registry", + "xlingsHome": "/home/u/.mcpp/registry", + "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", + "config": "/home/u/.mcpp/config.toml", + "buildCache": "/home/u/.mcpp/build-cache/v1", + "mcppVersion": "2026.8.8.3" +} +``` + +这条路径**刻意是只读的**。人类用的 `mcpp self env` 会在 `$MCPP_HOME` 缺失时初始化它 +—— 在提示符下敲这条命令的人预期如此 —— 但一个客户端**询问东西在哪**,不该成为把东西 +放到那儿的原因。在从没跑过 mcpp 的机器上,你会拿到它**将会**使用的路径与 +`initialized: false`,而磁盘未被触碰。 + +这也是它存在的理由:没有它,客户端就得重新实现 mcpp 的 home 解析 —— 包括「PATH 上的 +`mcpp` 可能是 xlings shim 而不是真二进制」那一部分。 + +### `mcpp.xpkg` —— 解析后的描述符 + +``` +mcpp xpkg parse --format json +``` + +`data` 就是 `--json` 裸打印的那份文档。 + +### `mcpp.cache` —— 全局构建缓存 + +``` +mcpp cache list --format json +``` + +`data` 是 `{root, entries[]}`,与 `--json` 裸打印的一致。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 511a32c9..59547931 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -13,3 +13,4 @@ - [08 - 工具链机制内幕](08-toolchain-internals.md) - [09 - 发布 mcpp](09-release.md) - [10 - 发布一个库到 mcpp-index](10-publishing-a-library.md) +- [11 - 机器可读输出](11-machine-output.md) diff --git a/src/bmi_cache/maintenance.cppm b/src/bmi_cache/maintenance.cppm index 7cd91593..d96f03a2 100644 --- a/src/bmi_cache/maintenance.cppm +++ b/src/bmi_cache/maintenance.cppm @@ -309,31 +309,45 @@ export int cache_dir() { } // `mcpp cache list [--json]`. -export int cache_list(bool asJson) { +// The legacy `--json` payload, as a value. +// +// Split out so `--format json` can put the SAME object inside an envelope +// without the two drifting. The two spellings are not the same output -- +// `--json` has shipped with `{root, entries}` at the top level and this +// repository's e2e asserts those keys, so it keeps that shape for ever -- +// but they must always describe the same cache. +export nlohmann::json cache_list_json() { auto entries = walk_pkg_entries(); auto stds = walk_std_entries(); + nlohmann::json j; + j["root"] = mcpp::home::cache_root().string(); + j["entries"] = nlohmann::json::array(); + for (auto* set : {&entries, &stds}) { + for (auto const& e : *set) { + j["entries"].push_back({ + {"kind", e.kind}, + {"label", e.label}, + {"key", e.key}, + {"dir", e.dir.string()}, + {"bytes", e.size}, + {"files", e.fileCount}, + {"accessed", e.accessed}, + {"complete", e.complete}, + }); + } + } + return j; +} +export int cache_list(bool asJson) { if (asJson) { - nlohmann::json j; - j["root"] = mcpp::home::cache_root().string(); - j["entries"] = nlohmann::json::array(); - for (auto* set : {&entries, &stds}) { - for (auto& e : *set) { - j["entries"].push_back({ - {"kind", e.kind}, - {"label", e.label}, - {"key", e.key}, - {"dir", e.dir.string()}, - {"bytes", e.size}, - {"files", e.fileCount}, - {"accessed", e.accessed}, - {"complete", e.complete}, - }); - } - } - std::println("{}", j.dump(2)); + // The legacy shape, from the same producer as the enveloped one -- + // two spellings of one answer, never two answers. + std::println("{}", cache_list_json().dump(2)); return 0; } + auto entries = walk_pkg_entries(); + auto stds = walk_std_entries(); if (entries.empty() && stds.empty()) { std::println("(build cache is empty)"); diff --git a/src/cli.cppm b/src/cli.cppm index 630820fb..2290fb5c 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -28,6 +28,7 @@ import mcpp.cli.cmd_self; import mcpp.cli.cmd_toolchain; import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION +import mcpp.wire; import mcpp.platform.env; // --offline → MCPP_OFFLINE import mcpp.ui; import mcpp.log; @@ -214,6 +215,12 @@ int run(int argc, char** argv) { .option(cl::Option("offline") .help("Never touch the network (index refresh, downloads, toolchain install)") .global()) + // Answers "what do you speak" without spawning a command that might + // fail. An optimisation, NOT the client's detection rule: on any mcpp + // predating it this is itself an unknown option, so a client must + // still detect the protocol by parsing stdout for schemaVersion+kind. + .option(cl::Option("protocol-version") + .help("Print the machine-output protocol this build speaks (JSON)")) // ─── project commands ────────────────────────────────────────── .subcommand(cl::App("new") @@ -369,7 +376,10 @@ int run(int argc, char** argv) { .description("Inspect / validate xpkg descriptors") .subcommand(cl::App("parse") .description("Parse a descriptor's mcpp segment exactly as the resolver would (strict: unknown keys are errors)") - .option(cl::Option("json").help("Emit machine-readable JSON")) + .option(cl::Option("json") + .help("Emit machine-readable JSON (legacy payload, kept for ever)")) + .option(cl::Option("format").takes_value().value_name("json") + .help("Machine-readable output (enveloped; see docs/11-machine-output.md)")) .option(cl::Option("allow-unknown") .help("Downgrade unknown mcpp-segment keys from error to warning")) .option(cl::Option("all-os") @@ -420,7 +430,10 @@ int run(int argc, char** argv) { .description("Print the cache root (and any pre-v1 cache)")) .subcommand(cl::App("list") .description("List cache entries with size + last-use") - .option(cl::Option("json").help("Emit machine-readable JSON"))) + .option(cl::Option("json") + .help("Emit machine-readable JSON (legacy payload, kept for ever)")) + .option(cl::Option("format").takes_value().value_name("json") + .help("Machine-readable output (enveloped; see docs/11-machine-output.md)"))) .subcommand(cl::App("info") .description("Show details (incl. key inputs) for a cached package") .arg(cl::Arg("pkg").help("@").required())) @@ -499,7 +512,9 @@ int run(int argc, char** argv) { .subcommand(cl::App("doctor") .description("Diagnose mcpp environment health")) .subcommand(cl::App("env") - .description("Print mcpp paths and configuration")) + .description("Print mcpp paths and configuration") + .option(cl::Option("format").takes_value().value_name("json") + .help("Machine-readable output (enveloped; see docs/11-machine-output.md)"))) .subcommand(cl::App("config") .description("Show or modify mcpp's private xlings configuration") .option(cl::Option("mirror").takes_value().value_name("CN|GLOBAL") @@ -588,6 +603,37 @@ int run(int argc, char** argv) { return cmd_explain(argv[2]); } + // What each machine-output command does before it prints anything. + // + // Declared here, beside the commands themselves, rather than inside + // mcpp.wire: the effects of `self env` are a fact about `self env`. A + // protocol module that knew the command list would mean adding a command + // in one file and remembering to describe it in another. + // + // `self env` carries `init-mcpp-home` because on a fresh machine it does + // create $MCPP_HOME -- measured: six entries, where `xpkg parse` and + // `cache list` create none. Naming the effect rather than flagging a + // boolean lets an IDE ignore this one and still refuse `exec-build-script`. + auto protocol_commands = [] { + using mcpp::wire::Effect; + return std::vector{ + {"self env", {Effect::InitMcppHome}}, + {"xpkg parse", {}}, + {"cache list", {}}, + }; + }; + + // `--protocol-version` is answered before anything else parses, and + // before any command can decide it needs a project. A client asks this + // first, in a directory that may not be one. + for (int i = 1; i < argc; ++i) { + if (std::string_view(argv[i]) == "--protocol-version") { + std::println("{}", + mcpp::wire::protocol_document(protocol_commands()).dump(2)); + return 0; + } + } + // Unknown-command pre-check. cmdline doesn't error on an unknown // top-level word — it just treats it as a positional and returns // 0 silently (since the root App has no top-level action). The diff --git a/src/cli/cmd_cache.cppm b/src/cli/cmd_cache.cppm index f7a48eff..5a121200 100644 --- a/src/cli/cmd_cache.cppm +++ b/src/cli/cmd_cache.cppm @@ -11,6 +11,8 @@ import std; import mcpplibs.cmdline; import mcpp.bmi_cache.maintenance; import mcpp.ui; +import mcpp.wire; +import mcpp.libs.json; namespace mcpp::cli { @@ -19,6 +21,23 @@ export int cmd_cache_dir(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { } export int cmd_cache_list(const mcpplibs::cmdline::ParsedArgs& parsed) { + // `--format json` is enveloped; `--json` keeps the payload it shipped + // with (`{root, entries}` at the top level, which this repo's own e2e + // asserts). Spelling compatibility is not payload compatibility, and + // wrapping the old spelling would break every consumer that already + // reads it. + if (auto f = parsed.value("format")) { + auto fmt = mcpp::wire::parse_format(*f); + if (!fmt) { + std::println(stderr, "error: {}", mcpp::wire::unsupported_format(*f)); + return 2; + } + mcpp::wire::emit({ + .kind = "mcpp.cache", + .data = mcpp::bmi_cache::cache_list_json(), + }); + return 0; + } return mcpp::bmi_cache::cache_list(parsed.is_flag_set("json")); } diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index 7a57a6d5..2135dae1 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -11,10 +11,72 @@ import std; import mcpplibs.cmdline; import mcpp.doctor; import mcpp.toolchain.fingerprint; // MCPP_VERSION +import mcpp.home; +import mcpp.platform; +import mcpp.wire; +import mcpp.libs.json; namespace mcpp::cli { -export int cmd_env(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { +// `self env --format json`. +// +// Deliberately NOT `env_report()` plus a serializer. That path calls +// `config::load_or_init()`, which on a machine that has never run mcpp +// creates $MCPP_HOME -- measured: `config.toml`, `registry/`, `cache/`, +// `bin/`, `build-cache/`, `log/`, six entries, where `mcpp --version`, +// `xpkg parse` and `cache list` create none. Reporting where things ARE +// should not be what puts them there. +// +// So the machine path computes paths and reads what already exists. When +// nothing does, it says so (`initialized: false`) and reports the paths mcpp +// WOULD use, rather than creating them to be able to answer. A client asking +// "where is your home" on a fresh machine gets an answer and an untouched +// disk. +// +// The human path is unchanged: someone typing `mcpp self env` at a prompt +// expects mcpp to set itself up, and always has. +namespace { + +nlohmann::json env_data_readonly() { + namespace fs = std::filesystem; + const auto home = mcpp::home::root(); + std::error_code ec; + + const auto registry = home / "registry"; + const auto config = home / "config.toml"; + const bool initialized = fs::exists(config, ec); + + auto s = [](const fs::path& p) { return p.string(); }; + return nlohmann::json{ + {"initialized", initialized}, + {"mcppHome", s(home)}, + {"registry", s(registry)}, + {"xlingsHome", s(registry)}, // registryDir unless overridden + {"xlingsBinary", s(registry / "bin" / + ("xlings" + std::string(mcpp::platform::exe_suffix)))}, + {"config", s(config)}, + {"buildCache", s(mcpp::home::cache_root())}, + {"mcppVersion", std::string(mcpp::toolchain::MCPP_VERSION)}, + }; +} + +} // namespace + +export int cmd_env(const mcpplibs::cmdline::ParsedArgs& parsed) { + if (auto f = parsed.value("format")) { + auto fmt = mcpp::wire::parse_format(*f); + if (!fmt) { + std::println(stderr, "error: {}", + mcpp::wire::unsupported_format(*f)); + return 2; + } + mcpp::wire::emit({ + .kind = "mcpp.env", + .effects = {}, // this path creates nothing; see above + .data = env_data_readonly(), + }); + return 0; + } return mcpp::doctor::env_report(); } diff --git a/src/cli/cmd_xpkg.cppm b/src/cli/cmd_xpkg.cppm index f9ff13f4..3a2b4a1e 100644 --- a/src/cli/cmd_xpkg.cppm +++ b/src/cli/cmd_xpkg.cppm @@ -20,11 +20,34 @@ import mcpplibs.cmdline; import mcpp.manifest; import mcpp.platform.axis; import mcpp.ui; +import mcpp.wire; +import mcpp.libs.json; namespace mcpp::cli { namespace { +// One exit for both spellings. +// +// `--json` prints the payload exactly as it has shipped -- a bare object, no +// envelope -- because consumers already read that shape and this repository's +// own e2e asserts its keys. `--format json` wraps the SAME payload. Two +// spellings of one answer; the payload is produced once so they cannot drift. +// +// The payload is built as a string at three sites in this file, so it is +// parsed back here rather than duplicating three builders. A parse failure +// means mcpp emitted invalid JSON, which is a bug worth surfacing loudly +// rather than papering over. +void emit_xpkg(std::string_view payload, bool enveloped) { + if (!enveloped) { std::println("{}", payload); return; } + auto j = nlohmann::json::parse(payload, nullptr, /*allow_exceptions=*/false); + if (j.is_discarded()) { + mcpp::ui::error("internal: xpkg payload is not valid JSON"); + j = nlohmann::json::object(); + } + mcpp::wire::emit({.kind = "mcpp.xpkg", .data = std::move(j)}); +} + std::string json_escape(std::string_view s) { std::string out; out.reserve(s.size() + 8); @@ -69,7 +92,15 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { } std::string lua{std::istreambuf_iterator(is), {}}; - const bool asJson = parsed.is_flag_set("json"); + bool enveloped = false; + if (auto f = parsed.value("format")) { + if (!mcpp::wire::parse_format(*f)) { + std::println(stderr, "error: {}", mcpp::wire::unsupported_format(*f)); + return 2; + } + enveloped = true; + } + const bool asJson = enveloped || parsed.is_flag_set("json"); const bool allowUnknown = parsed.is_flag_set("allow-unknown"); const bool allOs = parsed.is_flag_set("all-os"); const bool allowSplitName = parsed.is_flag_set("allow-split-name"); @@ -105,10 +136,10 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { ? std::nullopt : mcpp::manifest::xpkg_name_form_violation_from_lua(lua)) { if (asJson) { - std::println("{{\"namespace\":\"{}\",\"name\":\"{}\"," - "\"error\":\"{}\"}}", - json_escape(id.ns), json_escape(id.name), - json_escape(*violation)); + emit_xpkg(std::format( + "{{\"namespace\":\"{}\",\"name\":\"{}\",\"error\":\"{}\"}}", + json_escape(id.ns), json_escape(id.name), + json_escape(*violation)), enveloped); } else { mcpp::ui::error(std::format("{}: {}", file, *violation)); } @@ -136,8 +167,9 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { auto field = mcpp::manifest::extract_mcpp_field(lua); if (field.kind != mcpp::manifest::McppField::TableBody) { if (asJson) { - std::println("{{\"namespace\":\"{}\",\"name\":\"{}\",\"form\":\"A\"}}", - json_escape(id.ns), json_escape(id.name)); + emit_xpkg(std::format( + "{{\"namespace\":\"{}\",\"name\":\"{}\",\"form\":\"A\"}}", + json_escape(id.ns), json_escape(id.name)), enveloped); } else { std::println("package {} (namespace '{}')", fqn, id.ns); std::println("form A — no mcpp segment (build info from the " @@ -243,7 +275,8 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { } } genContents += "}"; - std::println("{{\"namespace\":\"{}\",\"name\":\"{}\",\"versions\":{}," + emit_xpkg(std::format( + "{{\"namespace\":\"{}\",\"name\":\"{}\",\"versions\":{}," "\"standard\":\"{}\",\"import_std\":{},\"sources\":{}," "\"include_dirs\":{},\"generated_files\":{}," "\"generated_contents\":{},\"targets\":{}," @@ -259,7 +292,7 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { return dirs; }()), genFiles, genContents, targets, - json_array(m->xpkgUnknownKeys)); + json_array(m->xpkgUnknownKeys)), enveloped); return rc; } diff --git a/src/wire.cppm b/src/wire.cppm new file mode 100644 index 00000000..f363f6d2 --- /dev/null +++ b/src/wire.cppm @@ -0,0 +1,259 @@ +// mcpp.wire — the machine-readable output contract, in one place. +// +// Every JSON that leaves mcpp for a program rather than a person goes through +// here. That is the whole point: `xlings interface` declares 20 capabilities +// whose `outputSchema` is, for all 20, only `{"exitCode": integer}` — a schema +// that says nothing while looking like a contract. A client reads +// `schemaVersion` and assumes there is one. So this module owns the envelope +// AND the rules that keep it honest. +// +// Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md +// +// ── Two decisions worth knowing before reading the code ──────────────────── +// +// 1. THE ENVELOPE IS SELF-IDENTIFYING, and clients are told to detect it by +// PARSING, never by exit code. +// +// `mcpp --protocol-version` cannot solve the bootstrap problem it looks +// like it solves: on every mcpp that predates it, that command IS the +// "spawn something that might fail" case, and its failure is +// indistinguishable from success by channel. Measured, before the fix in +// this same series: it printed `Error: unknown option` to STDOUT with rc=1 +// and an empty stderr. Changing the spelling to `--json` changes nothing — +// both hit the same unknown-option path. +// +// So the only rule that works against versions that predate the protocol +// is positive detection: read stdout, try to parse it, and require +// `schemaVersion` + `kind` to be there. `--protocol-version` remains +// useful on versions that have it (one fewer spawn), but it is an +// optimisation, not the foundation. +// +// 2. EFFECTS, NOT A `destructive` BOOLEAN. +// +// A boolean cannot separate "creates mcpp's own home on first run" from +// "executes code out of the workspace", and an IDE's untrusted-workspace +// gate cares only about the second. Measured on a fresh MCPP_HOME: +// `xpkg parse --json` and `cache list --json` create NOTHING, while +// `self env` creates six entries — so the field does carry information, +// but only if the effect is NAMED. `init-mcpp-home` is something a client +// can reasonably ignore; `exec-build-script` is not. +// +// Effects are also reported in `--protocol-version` as a static table, +// because a gate has to decide BEFORE running — by the time an envelope +// arrives, whatever it describes has already happened. +module; + +#include + +export module mcpp.wire; + +import std; +import mcpp.version; +import mcpp.libs.json; + +export namespace mcpp::wire { + +// The envelope's own version. Bumped only when the envelope SHAPE changes — +// never for a change inside one command's `data`, which carries its own +// version in `kinds` below. A single global number would couple unrelated +// kinds: a field added to `mcpp.env` would push the version a client reads +// for `mcpp.xpkg`, and the client could not tell which one actually moved. +inline constexpr int kEnvelopeVersion = 1; + +// Each kind's `data` version, and the list of kinds this build knows. +// +// Reported by `--protocol-version` so a client can ask "do you speak +// mcpp.env, and at what version" without spawning the command and guessing +// from what comes back. +struct KindVersion { std::string_view kind; int version; }; + +inline constexpr std::array kKinds{{ + {"mcpp.env", 1}, + {"mcpp.xpkg", 1}, + {"mcpp.cache", 1}, +}}; + +// What running a command does, beyond writing to stdout. +// +// Named rather than boolean. The scope is deliberately part of the name: +// `write-project` and `init-mcpp-home` are both "writes files", and only one +// of them is a reason for an IDE to refuse. +enum class Effect { + InitMcppHome, // creates $MCPP_HOME on first use. Outside the project. + ReadProject, // reads the manifest / sources + WriteProject, // writes into the project tree (target/, CDB, …) + WriteGlobalCache, // writes the shared build cache + Network, // may fetch + ExecBuildScript, // runs code FROM THE WORKSPACE (build.mcpp). The one an + // untrusted-workspace gate exists for. +}; + +constexpr std::string_view effect_name(Effect e) { + switch (e) { + case Effect::InitMcppHome: return "init-mcpp-home"; + case Effect::ReadProject: return "read-project"; + case Effect::WriteProject: return "write-project"; + case Effect::WriteGlobalCache: return "write-global-cache"; + case Effect::Network: return "network"; + case Effect::ExecBuildScript: return "exec-build-script"; + } + return "unknown"; +} + +// ── Diagnostics ──────────────────────────────────────────────────────────── +// +// A position is 1-based, matching every compiler and every editor. `column` +// counts UTF-8 bytes, not code points: it is what a client needs to index the +// same file mcpp read. +struct Position { int line = 0; int column = 0; }; +struct Range { Position start; Position end; }; + +enum class Severity { Error, Warning, Note }; + +constexpr std::string_view severity_name(Severity s) { + switch (s) { + case Severity::Error: return "error"; + case Severity::Warning: return "warning"; + case Severity::Note: return "note"; + } + return "note"; +} + +// `code` is required, and deliberately so. `MCPP_IDE_CONFIGURE_FAILED` -- +// one code covering every failure, with the docs forbidding clients from +// parsing the human message -- is structured in name only. A new failure +// branch either gets its own code, or is declared indivisible on purpose. +struct Diagnostic { + std::string code; + Severity severity = Severity::Error; + std::string message; + std::string path; // optional + std::optional range; // optional +}; + +// ── The envelope ─────────────────────────────────────────────────────────── + +struct Envelope { + std::string_view kind; + std::vector effects; + nlohmann::json data = nlohmann::json::object(); + std::vector diagnostics; +}; + +inline int kind_version(std::string_view kind) { + for (auto const& k : kKinds) if (k.kind == kind) return k.version; + return 0; +} + +inline nlohmann::json to_json(const Diagnostic& d) { + nlohmann::json j{ + {"code", d.code}, + {"severity", std::string(severity_name(d.severity))}, + {"source", "mcpp"}, + {"message", d.message}, + }; + if (!d.path.empty()) j["path"] = d.path; + if (d.range) { + j["range"] = { + {"start", {{"line", d.range->start.line}, {"column", d.range->start.column}}}, + {"end", {{"line", d.range->end.line}, {"column", d.range->end.column}}}, + }; + } + return j; +} + +inline nlohmann::json to_json(const Envelope& e) { + nlohmann::json effects = nlohmann::json::array(); + for (auto f : e.effects) effects.push_back(std::string(effect_name(f))); + + nlohmann::json diags = nlohmann::json::array(); + for (auto const& d : e.diagnostics) diags.push_back(to_json(d)); + + return nlohmann::json{ + {"schemaVersion", kEnvelopeVersion}, + {"kind", std::string(e.kind)}, + {"kindVersion", kind_version(e.kind)}, + {"effects", std::move(effects)}, + {"mcpp", { + {"version", std::string(mcpp::MCPP_VERSION)}, + {"protocol", {{"min", kEnvelopeVersion}, {"max", kEnvelopeVersion}}}, + }}, + {"data", e.data}, + {"diagnostics", std::move(diags)}, + }; +} + +// Serialise and write to stdout. Two spaces, trailing newline: a client reads +// it with a JSON parser either way, and a person reading a bug report should +// not have to reformat it first. +inline void emit(const Envelope& e) { + std::println("{}", to_json(e).dump(2)); +} + +// ── Negotiation ──────────────────────────────────────────────────────────── +// +// `mcpp --protocol-version`. Answers three things a client would otherwise +// have to discover by spawning commands and interpreting failures: which +// envelope versions, which kinds at which versions, and what each command +// does before it prints anything. +// The caller supplies the table. This module deliberately does NOT know which +// commands exist: the effects of `self env` are a fact about `self env`, and +// belong next to where it is declared. Keeping the list here would mean adding +// a command in one file and remembering to describe it in another. +struct CommandEffects { std::string_view command; std::vector effects; }; + +inline nlohmann::json protocol_document( + const std::vector& commands) { + nlohmann::json kinds = nlohmann::json::object(); + for (auto const& k : kKinds) kinds[std::string(k.kind)] = k.version; + + nlohmann::json cmds = nlohmann::json::object(); + for (auto const& c : commands) { + nlohmann::json fx = nlohmann::json::array(); + for (auto f : c.effects) fx.push_back(std::string(effect_name(f))); + cmds[std::string(c.command)] = nlohmann::json{{"effects", std::move(fx)}}; + } + + return nlohmann::json{ + {"schemaVersion", kEnvelopeVersion}, + {"kind", "mcpp.protocol"}, + {"mcpp", {{"version", std::string(mcpp::MCPP_VERSION)}}}, + {"envelope", {{"min", kEnvelopeVersion}, {"max", kEnvelopeVersion}}}, + {"kinds", std::move(kinds)}, + {"commands", std::move(cmds)}, + }; +} + +// ── Output format selection ──────────────────────────────────────────────── +// +// One spelling in the core (`--format `), with `--json` accepted for +// ever at the entry points. Same shape as `toolchain/compat.cppm`: the only +// place that knows the old spelling is the boundary, and no deprecation +// warning is ever printed — clients already parse this output, and a warning +// would land in the middle of it. +// +// IMPORTANT, and the reason `--json` is not merely an alias: the two are not +// the same OUTPUT. `cache list --json` has shipped with `{root, entries}` at +// the top level and this repository's own e2e asserts those keys. Wrapping +// that in an envelope is a breaking change. So `--json` keeps the legacy +// payload for ever, and `--format json` is the enveloped one. Spelling +// compatibility is not payload compatibility, and treating them as the same +// thing is how a published contract gets broken quietly. +enum class Format { Human, LegacyJson, Json }; + +// Unknown values are NOT decided here — the caller reports them, because only +// the caller knows the command name for the message. See `unsupported_format`. +inline std::optional parse_format(std::string_view v) { + if (v == "json") return Format::Json; + return std::nullopt; +} + +// The message for an unsupported `--format`. Goes to stderr with exit 2 (the +// caller does that): a request that does not yet know what it will be given +// must not write into the channel the protocol owns, and a client that finds +// no JSON on stdout has its answer. +inline std::string unsupported_format(std::string_view got) { + return std::format("unsupported --format '{}'; expected: json", got); +} + +} // namespace mcpp::wire diff --git a/tests/e2e/202_machine_output_contract.sh b/tests/e2e/202_machine_output_contract.sh index d2341675..dc50563d 100755 --- a/tests/e2e/202_machine_output_contract.sh +++ b/tests/e2e/202_machine_output_contract.sh @@ -39,14 +39,27 @@ check() { # description expected_rc cmd... fi } -# An option this command does not have. -check "unknown option (cache list)" 2 "$MCPP" cache list --format json -check "unknown option (self env)" 2 "$MCPP" self env --format json +# An option that does not exist. This is the path a client hits when it probes +# an older mcpp for a capability -- the most common machine-facing failure, +# and the one that used to print to stdout. +check "unknown option (cache list)" 2 "$MCPP" cache list --no-such-option +check "unknown option (self env)" 2 "$MCPP" self env --no-such-option check "unknown option (top level)" 2 "$MCPP" --no-such-option -# A value this command does not accept. Already correct today; asserted so it -# stays that way while the option path is changed around it. -check "unknown value (pack)" 2 "$MCPP" pack --format bogus +# A value the command does not accept. `--format` exists on these now, so this +# is the branch that answers "I know the option, not that value". +check "unsupported value (self env)" 2 "$MCPP" self env --format yaml +check "unsupported value (cache list)" 2 "$MCPP" cache list --format yaml +check "unsupported value (pack)" 2 "$MCPP" pack --format bogus + +# And the supported ones must still be JSON on stdout. Asserted here because +# every check above is about what does NOT happen; without this, deleting the +# feature entirely would leave the file green. +for cmd in "self env" "cache list"; do + out=$($MCPP $cmd --format json 2>/dev/null) || { echo "FAIL: $cmd --format json exited non-zero"; fail=1; } + echo "$out" | grep -q '"schemaVersion"' || { echo "FAIL: $cmd --format json has no schemaVersion"; fail=1; } + echo "$out" | grep -q '"kind"' || { echo "FAIL: $cmd --format json has no kind"; fail=1; } +done [[ "$fail" -eq 0 ]] || exit 1 echo "PASS: usage errors stay off stdout and exit 2" diff --git a/tests/unit/test_wire.cpp b/tests/unit/test_wire.cpp new file mode 100644 index 00000000..234101d5 --- /dev/null +++ b/tests/unit/test_wire.cpp @@ -0,0 +1,161 @@ +// The envelope is a contract, so it is tested like one. +// +// The lesson these assertions encode is xlings': `interface --list` declares +// 20 capabilities whose `outputSchema` is, for all 20, only +// `{"exitCode": integer}`. A schema that says nothing while looking like a +// contract is worse than none — a client reads `schemaVersion` and assumes +// there is one. So the shape is pinned here, and changing a field name has to +// turn this file red. +// +// Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md + +#include + +import std; +import mcpp.wire; +import mcpp.libs.json; + +namespace w = mcpp::wire; + +namespace { + +// A client's ONLY reliable rule against an mcpp that predates the protocol is +// to parse stdout and require these two. `--protocol-version` cannot serve as +// the entry point: on older versions it is itself an unknown option. So the +// envelope must be self-identifying, and these two keys are what identifies +// it. +TEST(WireEnvelope, IsSelfIdentifying) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); + ASSERT_TRUE(j.contains("schemaVersion")) << "a client detects by parsing"; + ASSERT_TRUE(j.contains("kind")); + EXPECT_EQ(j["schemaVersion"], w::kEnvelopeVersion); + EXPECT_EQ(j["kind"], "mcpp.env"); +} + +TEST(WireEnvelope, CarriesTheKindsOwnVersionSeparately) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.xpkg"}); + // Separate from schemaVersion on purpose: one global number would make a + // field added to mcpp.env move the version a client reads for mcpp.xpkg, + // with no way to tell which actually changed. + ASSERT_TRUE(j.contains("kindVersion")); + EXPECT_EQ(j["kindVersion"], 1); +} + +TEST(WireEnvelope, AnUnknownKindHasNoVersion) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.not-a-kind"}); + EXPECT_EQ(j["kindVersion"], 0) << "silently claiming version 1 would be a lie"; +} + +// Effects are named, not boolean. `init-mcpp-home` is something a client may +// ignore; `exec-build-script` runs code out of the workspace and is the whole +// reason an untrusted-workspace gate exists. One bool cannot separate them. +TEST(WireEnvelope, EffectsAreNamed) { + auto j = w::to_json(w::Envelope{ + .kind = "mcpp.env", + .effects = {w::Effect::InitMcppHome, w::Effect::ExecBuildScript}}); + ASSERT_EQ(j["effects"].size(), 2u); + EXPECT_EQ(j["effects"][0], "init-mcpp-home"); + EXPECT_EQ(j["effects"][1], "exec-build-script"); +} + +TEST(WireEnvelope, NoEffectsIsAnEmptyArrayNotAbsent) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.xpkg"}); + ASSERT_TRUE(j.contains("effects")); + EXPECT_TRUE(j["effects"].is_array()); + EXPECT_TRUE(j["effects"].empty()) << "absent would mean 'unknown', not 'none'"; +} + +TEST(WireEnvelope, DiagnosticsCarryCodeAndPosition) { + w::Diagnostic d{ + .code = "MCPP_MANIFEST_UNKNOWN_KEY", + .severity = w::Severity::Warning, + .message = "unknown key 'standrad'", + .path = "mcpp.toml", + .range = w::Range{{3, 1}, {3, 9}}, + }; + auto j = w::to_json(w::Envelope{.kind = "mcpp.env", .diagnostics = {d}}); + ASSERT_EQ(j["diagnostics"].size(), 1u); + auto const& g = j["diagnostics"][0]; + EXPECT_EQ(g["code"], "MCPP_MANIFEST_UNKNOWN_KEY"); + EXPECT_EQ(g["severity"], "warning"); + EXPECT_EQ(g["source"], "mcpp"); + EXPECT_EQ(g["path"], "mcpp.toml"); + EXPECT_EQ(g["range"]["start"]["line"], 3); + EXPECT_EQ(g["range"]["end"]["column"], 9); +} + +// A diagnostic without a location must not invent one: absent keys, not +// zeros. `line: 0` would send a client to a position that does not exist. +TEST(WireEnvelope, ADiagnosticWithoutALocationOmitsIt) { + auto j = w::to_json(w::Envelope{ + .kind = "mcpp.env", + .diagnostics = {{.code = "MCPP_X", .message = "no place"}}}); + auto const& g = j["diagnostics"][0]; + EXPECT_FALSE(g.contains("path")); + EXPECT_FALSE(g.contains("range")); +} + +TEST(WireEnvelope, ReportsMcppVersionAndProtocolWindow) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); + EXPECT_FALSE(j["mcpp"]["version"].get().empty()); + EXPECT_EQ(j["mcpp"]["protocol"]["min"], w::kEnvelopeVersion); + EXPECT_EQ(j["mcpp"]["protocol"]["max"], w::kEnvelopeVersion); +} + +// ── Negotiation ──────────────────────────────────────────────────────────── + +TEST(WireProtocol, ListsKindsAndTheirVersions) { + auto j = w::protocol_document({ + {"self env", {w::Effect::InitMcppHome}}, + {"xpkg parse", {}}, + }); + ASSERT_TRUE(j.contains("kinds")); + // `{min,max}` alone cannot answer "do you speak mcpp.env, at what + // version" — which is the question a client actually has. + EXPECT_EQ(j["kinds"]["mcpp.env"], 1); + EXPECT_EQ(j["kinds"]["mcpp.xpkg"], 1); +} + +// The gate has to decide BEFORE running. By the time an envelope arrives, +// whatever it describes has already happened — so the effects have to be +// available statically too. +TEST(WireProtocol, DeclaresEffectsPerCommandStatically) { + auto j = w::protocol_document({ + {"self env", {w::Effect::InitMcppHome}}, + {"xpkg parse", {}}, + }); + ASSERT_TRUE(j["commands"].contains("self env")); + EXPECT_EQ(j["commands"]["self env"]["effects"][0], "init-mcpp-home"); + EXPECT_TRUE(j["commands"]["xpkg parse"]["effects"].empty()) + << "measured on a fresh MCPP_HOME: it creates nothing"; +} + +TEST(WireProtocol, IsItselfSelfIdentifying) { + auto j = w::protocol_document({}); + EXPECT_EQ(j["kind"], "mcpp.protocol"); + EXPECT_TRUE(j.contains("schemaVersion")); +} + +// ── Format selection ─────────────────────────────────────────────────────── + +TEST(WireFormat, KnowsJsonAndRefusesTheRest) { + EXPECT_EQ(w::parse_format("json"), w::Format::Json); + EXPECT_FALSE(w::parse_format("yaml").has_value()); + EXPECT_FALSE(w::parse_format("ndjson").has_value()) << "reserved, not supported"; + EXPECT_FALSE(w::parse_format("").has_value()); +} + +TEST(WireFormat, TheRefusalNamesWhatIsSupported) { + auto m = w::unsupported_format("yaml"); + EXPECT_NE(m.find("yaml"), std::string::npos); + EXPECT_NE(m.find("json"), std::string::npos) << "tell the client what to ask for"; +} + +// LegacyJson is a distinct format, not a synonym. `--json` has shipped with a +// bare payload (`cache list --json` is `{root, entries}` at the top level, and +// this repo's e2e asserts those keys); enveloping it would break consumers. +TEST(WireFormat, LegacyJsonIsNotTheSameFormatAsJson) { + EXPECT_NE(w::Format::LegacyJson, w::Format::Json); +} + +} // namespace From c10726687494f3164df949712857edceed3df165 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 18:17:48 +0800 Subject: [PATCH 7/8] =?UTF-8?q?test(wire):=20=E6=8A=8A=E3=80=8C=E5=8F=AA?= =?UTF-8?q?=E5=A2=9E=E4=B8=8D=E6=94=B9=E3=80=8D=E4=BB=8E=E5=8F=A3=E5=8F=B7?= =?UTF-8?q?=E5=8F=98=E6=88=90=E4=BC=9A=E5=8F=98=E7=BA=A2=E7=9A=84=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/11-machine-output.md 承诺:同一 kindVersion 内字段只增不删不改名。而本模块 存在的理由之一,正是 `xlings interface --list` 那 20 个 capability —— 声明了 outputSchema,内容全是 `{"exitCode": integer}`。**没人能弄坏的 schema 不是 schema。** 所以把承诺钉住:信封、诊断、协议文档的已发布键集,以及每个 effect / severity 的 字面名(客户端按这些字符串匹配 —— 改名会悄悄改变 untrusted 门放行什么)。 加字段**不会**变红,这是契约允许的唯一变更。 验证过它真的会红:把 `kindVersion` 改成 `kind_version`、`exec-build-script` 改成 `exec-script`,5 个测试立刻失败;复原后 20/20 绿。 --- tests/unit/test_wire.cpp | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/unit/test_wire.cpp b/tests/unit/test_wire.cpp index 234101d5..9d7765a3 100644 --- a/tests/unit/test_wire.cpp +++ b/tests/unit/test_wire.cpp @@ -158,4 +158,90 @@ TEST(WireFormat, LegacyJsonIsNotTheSameFormatAsJson) { EXPECT_NE(w::Format::LegacyJson, w::Format::Json); } +// ── Golden shapes, one per kind ──────────────────────────────────────────── +// +// docs/11-machine-output.md promises that within a kindVersion fields are +// added and never removed or renamed. A promise nobody can break is the thing +// this whole module was written against — `xlings interface --list` declares +// 20 capabilities whose outputSchema is, for all 20, only +// `{"exitCode": integer}`. So the promise is enforced here: these lists are +// the published key set, and removing or renaming one turns this file red. +// +// Adding a field does NOT turn it red, deliberately — that is the one change +// the contract allows. +namespace { + +void expect_has_keys(const nlohmann::json& obj, + std::initializer_list keys, + std::string_view what) { + for (auto k : keys) + EXPECT_TRUE(obj.contains(k)) + << what << " lost published key '" << k + << "'. Removing or renaming one is a breaking change: bump the " + "kind's version in mcpp.wire and say so in " + "docs/11-machine-output.md."; +} + +} // namespace + +TEST(WireGolden, EnvelopeKeySet) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); + expect_has_keys(j, {"schemaVersion", "kind", "kindVersion", "effects", + "mcpp", "data", "diagnostics"}, "the envelope"); + expect_has_keys(j["mcpp"], {"version", "protocol"}, "envelope.mcpp"); + expect_has_keys(j["mcpp"]["protocol"], {"min", "max"}, "envelope.mcpp.protocol"); +} + +TEST(WireGolden, DiagnosticKeySet) { + auto j = w::to_json(w::Envelope{ + .kind = "mcpp.env", + .diagnostics = {{.code = "C", .message = "m", .path = "p", + .range = w::Range{{1, 1}, {1, 2}}}}}); + expect_has_keys(j["diagnostics"][0], + {"code", "severity", "source", "message", "path", "range"}, + "a diagnostic"); + expect_has_keys(j["diagnostics"][0]["range"], {"start", "end"}, "a range"); + expect_has_keys(j["diagnostics"][0]["range"]["start"], {"line", "column"}, + "a position"); +} + +TEST(WireGolden, ProtocolDocumentKeySet) { + auto j = w::protocol_document({{"self env", {w::Effect::InitMcppHome}}}); + expect_has_keys(j, {"schemaVersion", "kind", "mcpp", "envelope", "kinds", + "commands"}, "the protocol document"); + expect_has_keys(j["envelope"], {"min", "max"}, "protocol.envelope"); + expect_has_keys(j["commands"]["self env"], {"effects"}, "a command entry"); +} + +// Every effect name is part of the contract: a client matches on these +// strings, so renaming one silently changes what a gate lets through. +TEST(WireGolden, EffectNamesAreStable) { + struct { w::Effect e; const char* name; } const expected[]{ + {w::Effect::InitMcppHome, "init-mcpp-home"}, + {w::Effect::ReadProject, "read-project"}, + {w::Effect::WriteProject, "write-project"}, + {w::Effect::WriteGlobalCache, "write-global-cache"}, + {w::Effect::Network, "network"}, + {w::Effect::ExecBuildScript, "exec-build-script"}, + }; + for (auto const& x : expected) + EXPECT_EQ(w::effect_name(x.e), x.name) + << "effect names are matched by clients; renaming one changes " + "what an untrusted-workspace gate admits"; +} + +TEST(WireGolden, SeverityNamesAreStable) { + EXPECT_EQ(w::severity_name(w::Severity::Error), "error"); + EXPECT_EQ(w::severity_name(w::Severity::Warning), "warning"); + EXPECT_EQ(w::severity_name(w::Severity::Note), "note"); +} + +// The kinds this build claims to speak. A client reads this list to decide +// whether to bother calling; dropping one silently is a breaking change. +TEST(WireGolden, DeclaredKinds) { + auto j = w::protocol_document({}); + expect_has_keys(j["kinds"], {"mcpp.env", "mcpp.xpkg", "mcpp.cache"}, + "the kind list"); +} + } // namespace From 500ee1f1ab94ab8b463d1253971190185b623a2f Mon Sep 17 00:00:00 2001 From: speak-agent Date: Sat, 8 Aug 2026 18:34:01 +0800 Subject: [PATCH 8/8] =?UTF-8?q?release:=202026.8.8.4=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E6=9C=BA=E5=99=A8=E5=8F=AF=E8=AF=BB=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增了公开契约面(信封 / --protocol-version / 效应集合 / --format json), 所以单独发一版。xlings pin 已是索引最新的 2026.8.8.1,不动。 68 单测(wire 20 条,含验证过会变红的 golden);e2e 202。 --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ mcpp.toml | 2 +- src/version.cppm | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af035aed..3143d1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,50 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.8.4] — 2026-08-08 + +机器可读输出有契约了。设计与实测见 +`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`,用户文档见 +`docs/11-machine-output.md`。 + +### 新增 + +- **`--format json`:统一的输出信封(`mcpp.wire`)。** 覆盖 `self env`、`xpkg parse`、 + `cache list`,含 `schemaVersion` / `kind` / `kindVersion` / `effects` / `data` / + `diagnostics`。信封与 kind 的版本**分开** —— 单一全局版本号会让给 `mcpp.env` 加一个 + 字段推高客户端为 `mcpp.xpkg` 读到的版本。 + +- **`mcpp --protocol-version`。** 回本 build 支持的信封版本、各 kind 版本,以及 + **命令 → 效应**的静态表。它是优化而非地基:在它出现之前的每个 mcpp 上,它自己就是 + 未知选项,失败与成功同走 stdout —— 所以客户端的判据只能是**正向识别**(stdout 解析 + 得出 `schemaVersion` + `kind`)。这条写进了文档的第一节。 + +- **效应集合,不是 `destructive` 布尔。** 实测:全新 `MCPP_HOME` 上 `xpkg parse` 与 + `cache list` 什么都不建,`self env` 建 6 项。布尔分不开「mcpp 给自己做初始化」和 + 「执行工作区里的代码」,而 IDE 的 untrusted 门只在乎后者。 + +- **`mcpp self env --format json` 走独立只读路径。** 不调 `load_or_init`:客户端问 + 「东西在哪」不该成为把东西放到那儿的原因。全新 home 上返回完整路径 + + `initialized: false`,创建项数 0(人类路径不变,仍会初始化)。 + +### 修复 + +- **CDB 的 `arguments` 带着 shell 引号。** 消费者(clangd)逐字 exec 它,不经 shell, + 于是带引号的 token 不是 flag 而是不存在的文件名。Windows 上每个带路径的 flag 都中招 + (`shell_quote_arg` 的触发集含反斜杠);带空格的路径上更糟 —— token 在引号**内部** + 被切断,一个参数变成两个,其中一个带着永不闭合的开引号。 + +- **usage 错误不再进 stdout。** 未知选项过去打在 stdout、rc=1、stderr 为空,于是 + `mcpp cache list --format json | jq` 拿到的是人类文本。现在未知选项与不支持的值 + 统一 stderr + rc=2,stdout 一字不写。 + +### 兼容 + +- **`--json` 永久保留它的 payload,不打 deprecation 警告。** 拼写兼容不等于 payload + 兼容:`cache list --json` 顶层是 `{root, entries}` 且本仓库 e2e 已断言。两种拼写由 + 同一来源产出,不会漂移。 +- `pack --format tar|dir` 是产物形态而非输出格式,**声明为本协议的例外**。 + ## [2026.8.8.3] — 2026-08-08 ### 修复 diff --git a/mcpp.toml b/mcpp.toml index 48d9247b..7555910c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.8.3" +version = "2026.8.8.4" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/version.cppm b/src/version.cppm index 160d73d5..28ea8cda 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.8.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.8.4"; } // namespace mcpp