## [ERR-20260510-004] claude CLI 自由文本 JSON 不可救：用 `--json-schema` 替代启发式 fixer

**日期**: 2026-05-10
**类别**: ai-review / claude CLI / JSON 解析
**严重度**: 中（dry-run 期间是误报红 CI；转正后会变成"AI review 偶发硬阻断合并"）

### 现象

[run 1949](http://43.130.59.228/FFAIWorkspace/workspace/actions/runs/1949) 上 `ai-review` job 失败：claude CLI 返回的 JSON 在 col 283 处 `Expecting ',' delimiter`，启发式自动转义器（`ai-review-recover-json.py`）也救不回来。同 sha 在另一个 PR 上正常——纯偶发。

### 根因（两层）

**外层：模型在中文 summary 里夹未转义 ASCII 双引号**

```text
…相当于"远程任意命令执行"。建议…
       ↑↑                ↑↑   都是 ASCII U+0022，把 JSON 字符串提前关掉
```

prompt 里已经写了"必须转义""优先用反引号或「」"，模型 ~95% 时候配合，但 long generation 偶发掉链子，CI 就红一次。

**内层：启发式 fixer 有原理性盲区**

`ai-review-recover-json.py` 的算法："看到 `"` → peek 下一个非空白字符；如果是 `,]}:` 或 EOF 就当做"字符串真结束"，否则当做"内嵌引号"插入 `\"`"。

盲区：`…做结构化",作为 demo 可接受…` —— 内嵌引号紧接 `,` 又紧接 CJK 字符。算法只看一个字符，把它当字符串真结束，吐出 `…做结构化",` 给 jq；jq 看到 `,` 后面是 `作` 不是 `"`，报 `Expecting property name enclosed in double quotes`。

**这个盲区不可纯启发式修复**——只看下一个字符就是会撞上"中文逗号继续句子"的歧义；多看几个字符、判断是不是合法 JSON key 模式，复杂度爆炸而且仍可被构造样本干掉。

### 解法：让模型物理上拿不到"输出非法 JSON"的选项

claude CLI 原生有 `--json-schema <schema>` flag（配 `--output-format json`），把 schema 推到 API 层做硬校验。模型不再"输出 JSON 文本"，而是走 structured output channel；JSON 序列化由 CLI 在 envelope 阶段做，引号被正确转义不可能错。

```bash
RESPONSE=$(echo "$PROMPT" | claude --print \
  --output-format json \
  --json-schema "$SCHEMA" 2>&1)

# .structured_output 永远是合法 JSON，模型物理上无法跳过 schema
JSON=$(echo "$RESPONSE" | jq -c '.structured_output // empty')
[ -z "$JSON" ] && exit_or_soft_fail   # schema 不通过 / CLI 异常的兜底
```

**验证**：本地刻意构造"summary 里夹 `"远程任意命令执行"` 和 `"x-api-key 是上游凭据"`"的 prompt，jq 能直接取出合法 JSON。PR #292 真 diff（498k 字节）跑全流程，8 条 findings 全部结构化。

### 经验教训

1. **LLM 自由文本 JSON 的引号转义是赌博，不要写启发式 fixer 维护**——修复一个 case 引入两个新 case，本质是穷举。该用 schema 就用 schema。
2. **claude CLI 现有认证就支持 `--json-schema`**（OAuth / keychain / API key 都行），不需要切到直调 Anthropic SDK。最初考虑过 MCP route 自定义 `submit_review` 工具，3 个文件的工程量；`--json-schema` 是同等保证、1 处改动。
3. **CI 工具的"我在 dry-run，失败别真红"语义要显式编码**——原 runner 的 dry-run 只对 `verdict=block` 生效，对 JSON 解析失败仍然 hard exit 2。新版补了"structured_output 缺失 + dry-run = exit 0"。

### 反向参考

- 这是 [run 1938](http://43.130.59.228/FFAIWorkspace/workspace/actions/runs/1938)（meeting-attendance flaky）和 [run 1949](http://43.130.59.228/FFAIWorkspace/workspace/actions/runs/1949)（本条）两个红 CI 的后者；前者是测试本身的并发清理问题，跟本条无关。
- 删除：`scripts/ops/ai-review-recover-json.py`（启发式 fixer，不再需要）。
- 改造：`scripts/ops/ai-review-runner.sh`（JSON 提取 + dry-run 软失败）。
