Skip to content
Merged
411 changes: 411 additions & 0 deletions .agents/docs/2026-08-08-machine-readable-output-protocol-design.md

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions .agents/docs/2026-08-08-wire-protocol-implementation-plan.md
Original file line number Diff line number Diff line change
@@ -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(索引最新)⇒ 无需再抬
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### 修复
Expand Down
231 changes: 231 additions & 0 deletions docs/11-machine-output.md
Original file line number Diff line number Diff line change
@@ -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 <command> --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 <file> --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 <file.lua> --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.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading