Skip to content

Commit 3a4ddb3

Browse files
committed
fix(cli): usage 错误不进 stdout;并把第二轮 review 写进方案
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 通过
1 parent 1b21b00 commit 3a4ddb3

3 files changed

Lines changed: 217 additions & 2 deletions

File tree

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,144 @@ CDB 是 mcpp 已经在发的**机器可读输出**,只是没走 envelope。它
261261
请求不往协议通道写东西。
262262
- **`pack --format tar|dir` 声明为例外**,不加 `--layout` 别名。它没有 stdout 机器
263263
输出,不参与本协议;为一个不存在的一致性付迁移成本不值。
264+
265+
---
266+
267+
# 第二轮 review(2026-08-08,综合 @wellwei / @Ximiaw 反馈)
268+
269+
## R0. 先承认一件事:第一轮**没有做需求侧分析**
270+
271+
第一轮全部是供给侧 —— 核对 mcpp 现在的行为,找实现层缺陷。**没有**
272+
mcpp-vscode#8 / #5 的实际需求,也没有验证「提议的接口是否闭合了它们」。
273+
274+
wellwei 指出的四条里,**两条正是需求侧分析才会抓到、而第一轮漏掉的**(R1、R3)。
275+
这一节存在的意义不是自责,而是记下判据:**契约设计里,「我方能提供什么」和
276+
「对方需要什么」是两次独立的核对,做了前者不等于做了后者。**
277+
278+
## R1. `--json` 的 payload 兼容 —— 第一轮把它当纯拼写别名,错了
279+
280+
实测:
281+
282+
```
283+
mcpp cache list --json 顶层键 = ["entries", "root"] ← 没有 envelope
284+
mcpp xpkg parse --json 顶层 = {"namespace","name","form"} ← 没有 envelope
285+
```
286+
287+
第一轮跟着 RFC 写「入口一处归一化,核心只见 `--format`」,默认了**拼写兼容 ⇒ payload
288+
兼容**。不成立:如果核心随后统一输出 `{schemaVersion, kind, data, diagnostics}`,任何
289+
依赖旧顶层结构的消费者都会断,**包括本仓库的 e2e**
290+
291+
**修正 —— 三选一必须先定,否则 wire v1 不能冻结:**
292+
293+
| 方案 | 含义 | 代价 |
294+
|---|---|---|
295+
| **(A) `--json` 永久 legacy payload,`--format json` 才是 envelope** | 两条出口,语义不同 | 每个命令两份序列化;但**没有任何现存消费者会断** |
296+
| (B) 老命令保留旧顶层,只做增量字段;envelope 只用于新命令 | 老命令永远拿不到 `schemaVersion` | mcpp-vscode#8 §7.1 的请求落空 |
297+
| (C) 有意 breaking,给迁移窗口 | 干净 | 与「`--json` 永久保留、不打警告」的既定纪律冲突 |
298+
299+
**倾向 (A)**,理由是它与本仓库既有范式一致(`compat.cppm`:旧拼写永久接受,核心只见
300+
规范形式),而这里要永久接受的是**旧 payload**,不是旧拼写。代价是明确且有界的。
301+
302+
## R2. `destructive` —— 与第一轮结论一致,但 wellwei 补了一刀
303+
304+
第一轮已指出它做不了 preflight 门(§2.3),wellwei 独立确认,并补充:
305+
**单个 bool 也不够** —— 「只写 CDB」「写全局缓存」「可能触网」「执行工作区代码」是四种
306+
不同的边界,VSCode 的门需要区分。
307+
308+
**修正**:`--protocol-version` 的静态表不是 `destructive: bool`,而是**效应集合**:
309+
310+
```jsonc
311+
"commands": {
312+
"self env": { "effects": [] },
313+
"xpkg parse": { "effects": [] },
314+
"metadata": { "effects": ["read-project"] },
315+
"metadata --resolved": { "effects": ["read-project","write-cache","network"] },
316+
"build --configure-only": { "effects": ["read-project","write-project","write-cache","exec-build-script"] }
317+
}
318+
```
319+
320+
`exec-build-script` 单独成项,因为 `build.mcpp` 会执行工作区里的代码 —— 那是 untrusted
321+
门唯一真正在乎的一条。
322+
323+
## R3. `self env` 首次运行会初始化 —— 不是「每次都写」,但对契约是同一件事
324+
325+
wellwei 指出 `doctor.cppm::env_report()``config::load_or_init()`,不是只读。核实。
326+
327+
**第一次测法不足以定责**:我只在全新 `MCPP_HOME` 上跑了一次 `self env`,看到 6 个文件
328+
就下了结论 —— 但那没有排除「任何 mcpp 命令的首次初始化」。分离之后:
329+
330+
| 测法 | 结果 |
331+
|---|---|
332+
| 全新 home + `mcpp --version` | **目录都没创建** |
333+
| 全新 home + `mcpp self env` | 创建 `config.toml` `registry` `cache` `bin` `build-cache` `log` |
334+
| **已初始化** home + `mcpp self env` | **无变化,只读** |
335+
336+
所以准确的表述是:**`self env` 不是每次都写,而是首次运行时触发一次性初始化**;
337+
`--version` 不走那条路径,所以「所有命令都这样」不成立。
338+
339+
**这改变修法,不改变结论:**
340+
341+
-`destructive: false` 仍然是错的 —— 在一台新机器上它是谎,而 untrusted-workspace
342+
门恰恰在新机器上最需要生效。契约按**最坏情况**写。
343+
- 但要拆的不是「读 vs 写」——`env_report()` 在已初始化的机器上本来就只读。
344+
- 要拆的是**「读 vs 首次初始化」**:JSON 路径用一个只计算路径、只读已存在配置的
345+
resolver,未初始化时返回推导值 + `initialized: false`,**不建目录、不 bootstrap、
346+
不触网**。人类输出保持现状(用户对 `mcpp self env` 顺带初始化是有预期的)。
347+
348+
顺带记下这次的方法教训:**「跑一次,看有没有文件」不足以给副作用定责** —— 必须有一个
349+
不走同一路径的对照命令。这条不是这一处的技巧,是所有「某命令有无副作用」的判定通法。
350+
351+
## R4. 阶段 0 的边界比第一轮画的更宽
352+
353+
实测退出码:
354+
355+
```
356+
未知子命令 rc=127 (cli.cppm:610,stderr,正确)
357+
未知选项 rc=2 ← 本轮 W1 已修(原 rc=1 + stdout)
358+
未知值(pack) rc=2 (stderr,原本就对)
359+
未捕获异常 rc=70 (main.cpp,stderr)
360+
```
361+
362+
W1 修掉了最坏的一条。但 wellwei 说得对:**光接管 parse error 不够**,还要把
363+
usage / runtime / internal 的 rc 映射写成契约,并覆盖异常边界 —— 否则客户端仍然要靠
364+
猜。这条现在是 `docs/spec/` 的内容,不是代码。
365+
366+
## R5. 全局 `schemaVersion` 把不相关的 kind 耦合在一起
367+
368+
wellwei 的观察成立。一个全局版本号意味着 `mcpp.env` 的字段变更会推高 `mcpp.xpkg`
369+
版本,客户端无从判断哪个 kind 真的变了。
370+
371+
**修正**:envelope 版本与 kind 数据版本分开;并且 `--protocol-version` 不能只回
372+
`{min,max}`,要回**它支持哪些 kind 及各自版本**:
373+
374+
```jsonc
375+
{ "envelope": { "min": 1, "max": 1 },
376+
"kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 } }
377+
```
378+
379+
## R6. Ximiaw 的实测修正:supported-key 词汇表只覆盖部分段
380+
381+
他实测 24 段 + 源码核对,结论是「parser 已掌握每段 supported keys」**只对少数段成立**:
382+
383+
- 有白名单的:`[build]``[resources]` 等 6 处常量(`src/manifest/toml.cppm`)
384+
- 开放词汇段(dependencies / indices / toolchain …):无键清单,只校验值形态
385+
- **其余固定段(package / profile / runtime / xlings / workspace / pack):未知键被静默
386+
吞掉**,合法键散在解析逻辑里
387+
388+
所以「顺带导出词汇表」不是现成的序列化。**但第三档的收敛有独立价值**:把「打错键名
389+
毫无提示」变成警告,对 mcpp 自身就是健壮性收益 —— 这一条应当独立于本协议推进,不该
390+
被 wire v1 阻塞。
391+
392+
## R7. 修正后的范围
393+
394+
| | 第一轮 | 本轮修正 |
395+
|---|---|---|
396+
| W0 CDB 引号 || **不变**(已完成) |
397+
| W1 stdout 归属 || **已完成**,但 rc 映射契约要补进 spec(R4) |
398+
| W2 `mcpp.wire` | envelope + `destructive: bool` | envelope + **效应集合**;`--protocol-version`**kinds 及各自版本**(R2/R5) |
399+
| W3 `--format` 归一 | 纯拼写别名 | **先定 payload 兼容策略(R1),否则不能冻结 v1** |
400+
| W4 接入 | self env ~15 行 | **先拆只读 resolver**(R3);`cache list` / `xpkg parse` 按 R1 的结论决定走哪条 |
401+
| 新增 || manifest 固定段的 supported-key 收敛(R6),**独立推进** |
402+
403+
**结论:W2/W3/W4 在 R1 定案前不应动手** —— 那是唯一会决定 wire v1 形状的分叉。W0 与
404+
W1 与它正交,已完成。

src/cli.cppm

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,8 +612,30 @@ int run(int argc, char** argv) {
612612
}
613613
}
614614

615-
auto app_rc = app.run(trimmed_argc, trimmed_argp);
616-
if (app_rc != 0) return app_rc;
615+
// Parse and dispatch separately, rather than `app.run(argc, argv)`.
616+
//
617+
// `App::run` prints its parse errors with `std::println` -- to STDOUT --
618+
// and returns 1. stdout is the channel a machine-readable request owns, so
619+
// a client doing `mcpp cache list --format json | jq` got
620+
// `Error: unknown option: --format` fed to its parser, with nothing to
621+
// distinguish "this mcpp is too old" from "the command failed". stderr was
622+
// empty.
623+
//
624+
// That print lives in mcpplibs.cmdline, a published dependency. Taking the
625+
// ParseResult here fixes it without a cross-package release, and keeps one
626+
// rule for the whole CLI: anything mcpp says ABOUT ITSELF goes to stderr.
627+
//
628+
// Exit 2 for a usage error, matching `pack --format bogus` -- which was
629+
// already right, and was the only one of the two that was.
630+
auto parsed = app.parse(trimmed_argc, trimmed_argp);
631+
if (!parsed) {
632+
// `--help` / `--version` come back as a non-error "failure": the
633+
// parser handled them and printed. Nothing to add, nothing to report.
634+
if (!parsed.error().is_error()) return 0;
635+
mcpp::ui::error(parsed.error().message);
636+
return 2;
637+
}
638+
app.run(*parsed);
617639
return action_rc;
618640
}
619641

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env bash
2+
# 202_machine_output_contract.sh — stdout belongs to the protocol.
3+
#
4+
# A client that asks for machine-readable output has exactly one channel for
5+
# it. Anything mcpp says about ITSELF -- a usage error, an unsupported format
6+
# -- has to go somewhere else, or the client's parser eats it.
7+
#
8+
# Measured before this test existed:
9+
#
10+
# mcpp cache list --format json -> stdout: "Error: unknown option: --format"
11+
# stderr: (empty) rc=1
12+
# mcpp pack --format bogus -> stderr: "invalid --format ..." rc=2
13+
#
14+
# Two shapes for the same class of mistake, and the first one writes human text
15+
# into the channel a protocol owns. `mcpp cache list --format json | jq` gets a
16+
# parse error with nothing to distinguish "this mcpp is too old" from "the
17+
# command failed".
18+
#
19+
# Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md §2
20+
set -uo pipefail
21+
22+
fail=0
23+
check() { # description expected_rc cmd...
24+
local what=$1 want=$2; shift 2
25+
local out err rc
26+
out=$("$@" 2>/dev/null); rc=$?
27+
err=$("$@" 2>&1 >/dev/null)
28+
if [[ -n "$out" ]]; then
29+
echo "FAIL: $what wrote to stdout: $(echo "$out" | head -1 | cut -c1-70)"
30+
fail=1
31+
fi
32+
if [[ -z "$err" ]]; then
33+
echo "FAIL: $what said nothing on stderr"
34+
fail=1
35+
fi
36+
if [[ "$rc" != "$want" ]]; then
37+
echo "FAIL: $what exited $rc, expected $want"
38+
fail=1
39+
fi
40+
}
41+
42+
# An option this command does not have.
43+
check "unknown option (cache list)" 2 "$MCPP" cache list --format json
44+
check "unknown option (self env)" 2 "$MCPP" self env --format json
45+
check "unknown option (top level)" 2 "$MCPP" --no-such-option
46+
47+
# A value this command does not accept. Already correct today; asserted so it
48+
# stays that way while the option path is changed around it.
49+
check "unknown value (pack)" 2 "$MCPP" pack --format bogus
50+
51+
[[ "$fail" -eq 0 ]] || exit 1
52+
echo "PASS: usage errors stay off stdout and exit 2"

0 commit comments

Comments
 (0)