Skip to content

Commit 55a39d9

Browse files
authored
feat: 机器可读输出协议 —— 信封 + 效应 + --format 归一 (2026.8.8.4) (#385)
回应 RFC #379,并修掉两个今天就在破坏客户端的缺陷。 ## 修复 **CDB 的 `arguments` 带着 shell 引号。** clangd 逐字 exec 它、不经 shell,于是带引号的 token 不是 flag 而是不存在的文件名。Windows 上每个带路径的 flag 都中招(`shell_quote_arg` 的触发集含反斜杠);带空格的路径更糟 —— token 在引号**内部**被切断,一个参数变成两个,其中一个带着永不闭合的开引号。 **usage 错误不再进 stdout。** 未知选项过去打在 stdout、rc=1、stderr 为空。现在与不支持的值统一 stderr + rc=2。 ## 新增 `src/wire.cppm`(独立模块,不认识任何命令)+ `--protocol-version` + `--format json`,覆盖 `self env` / `xpkg parse` / `cache list`。 三条设计都由实测支撑: 1. **信封自识别** —— `--protocol-version` 在它出现前的版本上自己就是未知选项,失败与成功同走 stdout。客户端判据只能是正向识别。 2. **效应集合而非布尔** —— 全新 home 上 `xpkg parse`/`cache list` 什么都不建、`self env` 建 6 项;布尔分不开「mcpp 自我初始化」和「执行工作区代码」。 3. **`--json` 永久保留 legacy payload** —— 拼写兼容不等于 payload 兼容,`cache list --json` 的 `{root, entries}` 本仓库 e2e 已断言。 `self env --format json` 走独立只读路径:全新 home 上创建项数 **0**(人类路径不变)。 ## 纪律 文档承诺「只增不改」,并用**验证过会变红**的断言钉住(改字段名 → 5 个测试失败)。没人能弄坏的 schema 不是 schema。 68 单测;e2e 202;CI 18/18。
1 parent 312ff52 commit 55a39d9

19 files changed

Lines changed: 1932 additions & 56 deletions

.agents/docs/2026-08-08-machine-readable-output-protocol-design.md

Lines changed: 411 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# 机器可读输出协议 —— 拆分实施计划
2+
3+
> 设计:`2026-08-08-machine-readable-output-protocol-design.md`(对 RFC #379 的核对与修正)
4+
> 已定:未知格式走 **stderr + rc=2**;`pack --format` **声明为例外**
5+
6+
## 顺序与理由
7+
8+
设计文档 §4.5 把 CDB 引号缺陷排在阶段 1 之前,理由是「协议做得再好,也救不了一个内容
9+
本身就坏掉的出口」。照此排:
10+
11+
```
12+
W0 CDB 引号(§4.5) —— 今天就在坏,且与协议正交,先修
13+
W1 stdout 归属(阶段 0) —— 未知选项/未知值统一 stderr + rc=2
14+
W2 mcpp.wire 模块(阶段 1) —— envelope + destructive + --protocol-version
15+
W3 --format 归一(阶段 2) —— --json 永久别名,入口一处归一
16+
W4 接入(阶段 3a) —— self env / xpkg parse / cache list
17+
```
18+
19+
W4 之后的 `metadata` / `--configure-only`(阶段 3b/3c)**不在本次范围** —— 它们是新增
20+
能力而非契约统一,且依赖 #372 的拆分结论。
21+
22+
## W0 —— CDB 的 `arguments` 带 shell 引号
23+
24+
**File:** `src/build/compile_commands.cppm`
25+
26+
- [ ] **Step 1** 红:带空格路径 + llvm 的单测,断言
27+
① 任何 token 不以引号开头/结尾;② 带空格的路径是**一个** token
28+
- [ ] **Step 2** 跑,确认 FAIL(今天两条都不满足)
29+
- [ ] **Step 3**`split_flags` 认引号。**顺序陷阱**:ninja`$ ` 反转义必须发生在
30+
引号****,否则被引号包住的空格先把 token 切断 —— 那正是现在的 bug
31+
- [ ] **Step 4** 绿
32+
- [ ] **Step 5** Commit
33+
34+
判据不能写成「clangd 能用了」——在不含空格的路径上恒真,正是它至今没被发现的原因。
35+
36+
## W1 —— stdout 归属
37+
38+
**Files:** `src/cli.cppm``src/main.cpp`(+ 依赖 `mcpplibs.cmdline` 的处置)
39+
40+
- [ ] **Step 1**:e2e 断言未知**选项**与未知****在通道(stderr)与退出码(2)上一致
41+
- [ ] **Step 2** 跑,FAIL(今天:未知选项 → stdout/rc=1;未知值 → stderr/rc=2)
42+
- [ ] **Step 3** 实现。`mcpplibs.cmdline:127``std::println` 写 stdout,在依赖里 ——
43+
**本次不改依赖**,改为 mcpp 侧接管 `ParseResult`:不走 `App::run()` 的内建错误
44+
打印,自己输出到 stderr 并返回 2
45+
- [ ] **Step 4** 绿
46+
- [ ] **Step 5** Commit
47+
48+
## W2 —— `mcpp.wire`(独立模块)
49+
50+
**New:** `src/wire.cppm`
51+
52+
- [ ] **Step 1** 单测:envelope 形状(`schemaVersion` / `kind` / `destructive` /
53+
`mcpp.version` / `mcpp.protocol{min,max}` / `data` / `diagnostics`)
54+
- [ ] **Step 2** 实现。`Diagnostic/Position/Range/Severity` 与 envelope 构造从 #372
55+
`src/ide/model.cppm``src/ide/snapshot.cppm` 提升(设计文档 §5-B)
56+
- [ ] **Step 3** `mcpp --protocol-version`:输出 `{min,max}` **加命令 → destructive 静态表**
57+
(设计 §2.3 —— untrusted 门要在执行前知道)
58+
- [ ] **Step 4** golden fixture,且**反向验证过**(改字段名要变红,设计 §4)
59+
- [ ] **Step 5** Commit
60+
61+
## W3 —— `--format` 归一
62+
63+
**Files:** `src/cli.cppm`(入口归一)、各 `cmd_*.cppm`
64+
65+
- [ ] **Step 1** 单测:`--json``--format json` 产出**逐字节相同**
66+
- [ ] **Step 2/3** 入口一处 `--json``--format json`;核心只见 `--format`
67+
- [ ] **Step 4** 绿;`--json` 永久保留、**不打 deprecation 警告**
68+
- [ ] **Step 5** Commit
69+
70+
`pack --format tar|dir` 不动(已定为例外),文档写明。
71+
72+
## W4 —— 接入 envelope
73+
74+
**Files:** `cmd_self.cppm`(新增 `self env --format json`)、`cmd_xpkg.cppm``cmd_cache.cppm`
75+
76+
- [ ] **Step 1** 每个 `kind` 一个 golden fixture(反向验证过)
77+
- [ ] **Step 2** `self env` 覆盖 mcpp-vscode#8 §2.4:MCPP_HOME / registry / xlings home /
78+
index repos / default toolchain
79+
- [ ] **Step 3** `xpkg parse` / `cache list` 包进 envelope(`schemaVersion` 随之而来)
80+
- [ ] **Step 4** 绿
81+
- [ ] **Step 5** Commit
82+
83+
## 平台特化的去处
84+
85+
按要求:平台差异进 `src/platform/`,协议本体独立 `.cppm`
86+
87+
- 协议本体 → **`src/wire.cppm`**(新模块,不依赖任何命令)
88+
- 路径/引号的平台差异 → 已在 `src/platform/`(`env::path_list_separator` 等);W0 的
89+
引号识别是**平台无关**的(引号本身两平台都要剥),不新增平台分支
90+
91+
## 交付
92+
93+
- 全部并入 **PR #385**(与 docs 一起)
94+
- 版本已是 2026.8.8.3(刚发布)⇒ 本次**跳过版本 bump**,除非 CI 要求
95+
- xlings pin 已是 2026.8.8.1(索引最新)⇒ 无需再抬

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,50 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [2026.8.8.4] — 2026-08-08
7+
8+
机器可读输出有契约了。设计与实测见
9+
`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`,用户文档见
10+
`docs/11-machine-output.md`
11+
12+
### 新增
13+
14+
- **`--format json`:统一的输出信封(`mcpp.wire`)。** 覆盖 `self env``xpkg parse`
15+
`cache list`,含 `schemaVersion` / `kind` / `kindVersion` / `effects` / `data` /
16+
`diagnostics`。信封与 kind 的版本**分开** —— 单一全局版本号会让给 `mcpp.env` 加一个
17+
字段推高客户端为 `mcpp.xpkg` 读到的版本。
18+
19+
- **`mcpp --protocol-version`** 回本 build 支持的信封版本、各 kind 版本,以及
20+
**命令 → 效应**的静态表。它是优化而非地基:在它出现之前的每个 mcpp 上,它自己就是
21+
未知选项,失败与成功同走 stdout —— 所以客户端的判据只能是**正向识别**(stdout 解析
22+
得出 `schemaVersion` + `kind`)。这条写进了文档的第一节。
23+
24+
- **效应集合,不是 `destructive` 布尔。** 实测:全新 `MCPP_HOME``xpkg parse`
25+
`cache list` 什么都不建,`self env` 建 6 项。布尔分不开「mcpp 给自己做初始化」和
26+
「执行工作区里的代码」,而 IDE 的 untrusted 门只在乎后者。
27+
28+
- **`mcpp self env --format json` 走独立只读路径。** 不调 `load_or_init`:客户端问
29+
「东西在哪」不该成为把东西放到那儿的原因。全新 home 上返回完整路径 +
30+
`initialized: false`,创建项数 0(人类路径不变,仍会初始化)。
31+
32+
### 修复
33+
34+
- **CDB 的 `arguments` 带着 shell 引号。** 消费者(clangd)逐字 exec 它,不经 shell,
35+
于是带引号的 token 不是 flag 而是不存在的文件名。Windows 上每个带路径的 flag 都中招
36+
(`shell_quote_arg` 的触发集含反斜杠);带空格的路径上更糟 —— token 在引号**内部**
37+
被切断,一个参数变成两个,其中一个带着永不闭合的开引号。
38+
39+
- **usage 错误不再进 stdout。** 未知选项过去打在 stdout、rc=1、stderr 为空,于是
40+
`mcpp cache list --format json | jq` 拿到的是人类文本。现在未知选项与不支持的值
41+
统一 stderr + rc=2,stdout 一字不写。
42+
43+
### 兼容
44+
45+
- **`--json` 永久保留它的 payload,不打 deprecation 警告。** 拼写兼容不等于 payload
46+
兼容:`cache list --json` 顶层是 `{root, entries}` 且本仓库 e2e 已断言。两种拼写由
47+
同一来源产出,不会漂移。
48+
- `pack --format tar|dir` 是产物形态而非输出格式,**声明为本协议的例外**
49+
650
## [2026.8.8.3] — 2026-08-08
751

852
### 修复

docs/11-machine-output.md

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# 11 — Machine-Readable Output
2+
3+
mcpp writes for two audiences. This chapter is the contract for the second one:
4+
programs. If you are building an editor extension, a CI script, or anything
5+
that parses mcpp's output, this is what you may rely on.
6+
7+
Design and the measurements behind it:
8+
`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`.
9+
10+
## 1. The rule that matters most
11+
12+
> **Detect the protocol by parsing stdout. Never by exit code, and never by
13+
> "the command did not fail".**
14+
15+
Read stdout, try to parse it as JSON, and require `schemaVersion` and `kind`
16+
to be present. If either is missing, this mcpp does not speak the protocol you
17+
asked for.
18+
19+
This is not a stylistic preference. `mcpp --protocol-version` looks like it
20+
should be the entry point, and on a version that has it, it is a useful
21+
shortcut. But on **every mcpp released before it existed**, that command is
22+
itself an unknown option — and an unknown option used to print human text to
23+
*stdout* with exit code 1 and an empty stderr. Success and failure arrived on
24+
the same channel. Spelling it `--json` instead changes nothing; both hit the
25+
same path.
26+
27+
So positive detection is the only rule that works across versions. Everything
28+
below is designed around it.
29+
30+
## 2. The envelope
31+
32+
Every enveloped response has this shape:
33+
34+
```jsonc
35+
{
36+
"schemaVersion": 1, // the ENVELOPE's version
37+
"kind": "mcpp.env", // which document this is
38+
"kindVersion": 1, // this kind's own data version
39+
"effects": [], // what running the command did — see §4
40+
"mcpp": {
41+
"version": "2026.8.8.3",
42+
"protocol": { "min": 1, "max": 1 }
43+
},
44+
"data": { /* specific to `kind` */ },
45+
"diagnostics": []
46+
}
47+
```
48+
49+
`schemaVersion` and `kindVersion` are separate on purpose. One global number
50+
would mean that adding a field to `mcpp.env` moves the version a client reads
51+
for `mcpp.xpkg`, with no way to tell which actually changed.
52+
53+
`effects` is always present. An empty array means "nothing"; an absent array
54+
would mean "unknown", which is a different claim.
55+
56+
### Diagnostics
57+
58+
```jsonc
59+
{
60+
"code": "MCPP_MANIFEST_UNKNOWN_KEY",
61+
"severity": "error" | "warning" | "note",
62+
"source": "mcpp",
63+
"message": "unknown key 'standrad'",
64+
"path": "mcpp.toml", // omitted when there is none
65+
"range": { "start": {"line": 3, "column": 1},
66+
"end": {"line": 3, "column": 9} } // omitted when there is none
67+
}
68+
```
69+
70+
Positions are 1-based. `column` counts UTF-8 **bytes**, so it indexes the same
71+
file mcpp read.
72+
73+
A diagnostic without a location omits `path` and `range` rather than sending
74+
zeros — `line: 0` would point at a position that does not exist.
75+
76+
`code` is always present. Parse `code`; never parse `message`.
77+
78+
## 3. Asking for machine output
79+
80+
```
81+
mcpp <command> --format json
82+
```
83+
84+
`json` is the only supported value today. `ndjson` is reserved for a future
85+
streaming case and is **not** accepted — asking for it is an error, not a
86+
silent fallback.
87+
88+
### Unsupported values and unknown options
89+
90+
Both go to **stderr** with **exit code 2**, and write nothing to stdout:
91+
92+
```
93+
$ mcpp self env --format yaml
94+
error: unsupported --format 'yaml'; expected: json # stderr
95+
$ echo $?
96+
2
97+
```
98+
99+
A request that does not yet know what it will be given must not write into the
100+
channel the protocol owns. Combined with §1, a client's rule is complete: no
101+
JSON on stdout means "not supported", whatever the reason.
102+
103+
Exit codes:
104+
105+
| code | meaning |
106+
|---|---|
107+
| 0 | success |
108+
| 2 | usage error — unknown option, unsupported value |
109+
| 70 | internal error (uncaught exception) |
110+
| 127 | unknown command |
111+
112+
## 4. Effects — what a command does before it prints
113+
114+
An IDE with an untrusted-workspace gate has to decide **before** running.
115+
By the time an envelope arrives, whatever it describes has already happened.
116+
So the same information is available statically:
117+
118+
```
119+
mcpp --protocol-version
120+
```
121+
122+
```jsonc
123+
{
124+
"schemaVersion": 1,
125+
"kind": "mcpp.protocol",
126+
"envelope": { "min": 1, "max": 1 },
127+
"kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 },
128+
"commands": {
129+
"self env": { "effects": ["init-mcpp-home"] },
130+
"xpkg parse": { "effects": [] },
131+
"cache list": { "effects": [] }
132+
}
133+
}
134+
```
135+
136+
Effects are named rather than a `destructive: true|false`, because a boolean
137+
cannot separate the harmless from the thing a gate exists for:
138+
139+
| effect | meaning |
140+
|---|---|
141+
| `init-mcpp-home` | may create `$MCPP_HOME` on first use. **Outside your project.** |
142+
| `read-project` | reads the manifest and sources |
143+
| `write-project` | writes into the project tree (`target/`, the compile DB) |
144+
| `write-global-cache` | writes the shared build cache |
145+
| `network` | may fetch |
146+
| `exec-build-script` | **runs code from the workspace** (`build.mcpp`) |
147+
148+
Most gates care about `exec-build-script` and `write-project`, and can ignore
149+
`init-mcpp-home` — mcpp setting itself up is not the workspace acting.
150+
151+
## 5. `--json` is not `--format json`
152+
153+
Two commands shipped a `--json` flag before this protocol existed:
154+
155+
```
156+
mcpp xpkg parse <file> --json -> {"namespace": …, "name": …, …}
157+
mcpp cache list --json -> {"root": …, "entries": [ … ]}
158+
```
159+
160+
Those payloads are **bare** — no envelope — and consumers already read them.
161+
So:
162+
163+
> **`--json` keeps its payload for ever. `--format json` is the enveloped one.**
164+
165+
`--json` is not deprecated, and using it prints no warning: clients parse this
166+
output, and a warning would land in the middle of it.
167+
168+
Both spellings are produced from the same source, so they always describe the
169+
same thing — one answer, two shapes.
170+
171+
## 6. What you may rely on, and what changes
172+
173+
For each `kind`, within a `kindVersion`:
174+
175+
- fields are **added**, never removed
176+
- the meaning of a field never changes
177+
- a breaking change bumps the version and, where a window is needed,
178+
`protocol.min`/`max` overlap so both are readable
179+
180+
That promise is only worth something if it is enforced, so each kind has a
181+
test that fails when a field name changes. A schema nobody can break is not a
182+
schema — `xlings interface --list` declares 20 capabilities whose
183+
`outputSchema` is, for all 20, only `{"exitCode": integer}`, and a client that
184+
sees a version number assumes there is a contract behind it.
185+
186+
## 7. Kinds
187+
188+
### `mcpp.env` — where mcpp keeps things
189+
190+
```
191+
mcpp self env --format json
192+
```
193+
194+
```jsonc
195+
{
196+
"initialized": false, // is there a config.toml yet?
197+
"mcppHome": "/home/u/.mcpp",
198+
"registry": "/home/u/.mcpp/registry",
199+
"xlingsHome": "/home/u/.mcpp/registry",
200+
"xlingsBinary":"/home/u/.mcpp/registry/bin/xlings",
201+
"config": "/home/u/.mcpp/config.toml",
202+
"buildCache": "/home/u/.mcpp/build-cache/v1",
203+
"mcppVersion": "2026.8.8.3"
204+
}
205+
```
206+
207+
This path is read-only, deliberately. The human `mcpp self env` initialises
208+
`$MCPP_HOME` if it is missing — someone typing it at a prompt expects that —
209+
but a client asking *where things are* should not be what puts them there. On
210+
a machine that has never run mcpp you get the paths it **would** use and
211+
`initialized: false`, and the disk is untouched.
212+
213+
That is why this exists at all: without it a client has to reimplement mcpp's
214+
home resolution, including the part where the `mcpp` on `PATH` may be an
215+
xlings shim rather than the real binary.
216+
217+
### `mcpp.xpkg` — a parsed descriptor
218+
219+
```
220+
mcpp xpkg parse <file.lua> --format json
221+
```
222+
223+
`data` is the same document `--json` prints bare.
224+
225+
### `mcpp.cache` — the global build cache
226+
227+
```
228+
mcpp cache list --format json
229+
```
230+
231+
`data` is `{root, entries[]}`, the same document `--json` prints bare.

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [08 - Toolchain Internals](08-toolchain-internals.md)
1414
- [09 - Releasing mcpp](09-release.md)
1515
- [10 - Publishing a Library to mcpp-index](10-publishing-a-library.md)
16+
- [11 - Machine-Readable Output](11-machine-output.md)
1617

1718
## Specifications
1819

0 commit comments

Comments
 (0)