# 开发历程：把「按节检索文档」做成一个可验收的 MoonBit 项目

- 周期：2026-11-07 → 2026-11-13
- 规模：43 个 `.mbt` 文件 / 6,759 行；42 个提交；102 个测试；50 条 ADR
- 发布：MoonCakes `PingGuoMiaoMiao/docmunch@0.2.0`

这篇不是功能说明书（那在 README 与 `docs/p1`–`p12` 里），而是记录**过程**：为什么这样设计、在哪里翻过车、哪些决定是事后看仍然成立的，以及哪些是我下次会做不一样的。

---

## 一、起点：三件被预研推翻的事

任务给的参考仓库 `PingGuoMiaoMiao/docmunch` 当时是空的（只有 LICENSE + 10 字节 README）。真正可参考的是 `jgravelle/jdocmunch-mcp`（Python，206★，非商业许可）。所以第一步不是写代码，而是**先做一轮预研**，结论直接推翻了三个原本的假设：

| 原本的假设 | 预研结论 |
| --- | --- |
| 参考仓库有代码可以移植 | 是空仓库，没有一行代码可参考 |
| Markdown 解析要绑 C 库（cmark） | `moonbit-community/cmark` 已是纯 MoonBit 实现，且保留源码位置 |
| MCP 服务器要复用 Node SDK | `colmugx/mcp` 已是纯 MoonBit，stdio + HTTP 都有 |

第三条尤其关键：如果当时选了 Node，今天的项目就不是「一个原生二进制」而是「要用户先装 Node」。**预研一小时，省掉后面两周的返工。**

预研报告里我还记了一条反向结论：参考实现的许可证是 NOASSERTION（free for non-commercial），所以只能学设计、不能抄代码——这决定了后面所有实现都必须自己写，也解释了为什么提交历史里几乎没有「移植」痕迹。

---

## 二、七个阶段，每个阶段都被测试咬过

按 `fetch → extract → markdown → section → index → mcp` 推进，每阶段一条硬规则：**`moon test` 全绿 + 外部校验脚本全绿才允许进入下一阶段**。这条规则抓住了几乎所有真问题。

### P1 抓取：10 个真实站点，2 个瞬时失败教会我错误分类

第一次冒烟 10 个站点，8 个成功、2 个失败：arXiv 报 `network_error: connection aborted`，MDN 报 `dns_error`，耗时 12 秒。重试后两个都成功。

这件事的价值不在于"修好了"，而在于证明**错误码必须先分类**：`DnsError` / `NetworkError` / `Blocked` / `Timeout` 分开之后，重试策略才能分级（DNS 失败重试没用，连接重置值得重试）。后来 D40 的重试逻辑就只对 `NetworkError` 生效。

### P2 抽取：差分测试抓出三个真 bug

抽取不是"看起来像就行"，我拉了 Mozilla Readability 作为 oracle（Node 端跑），对 4 个真实页面做差分：标题匹配 + 文本长度比 + 词集 Jaccard。

首轮结果惨不忍睹：长度比 `2.31 / 1.29 / 8.76`，Jaccard `0.50 / 0.83 / 0.35`。三个 bug：

1. **所有页面都抽不出正文** —— 判断"节点是否已被父节点删除"时，把 document 节点误判成已摘除，导致清洗阶段对全部节点 `continue`。
2. **MoonBit 页面抽成空** —— Docusaurus 给主容器加了 `__docusaurus_skiptocontent_fallback` 类，命中了我写的负向词 `skip`。
3. **GitHub 页面抽成文件列表** —— README 容器叫 `sharedmarkdowncontent__…`，命中负向词 `share`，被当成分享按钮删掉。

2 和 3 是同一个教训：**class/id 的负向信号必须整词匹配，不能子串匹配**。改成 token 化之后，`share-buttons` 照样删、`sharedmarkdowncontent` 保住。修完指标到 4/4、1.03 / 0.91。

### P3 Markdown：一个"不确定"的产物毁掉所有黄金文件

自研 HTML→CommonMark 渲染器，写完生成 4 份黄金文件，然后立刻校验——**三份全部漂移**。

我一开始以为是换行符，查了半天才发现根因：选"正文容器"时遍历的是 `Map`，分数并列时谁先被遍历到谁赢，而哈希遍历顺序不保证跨进程稳定。改成按文档顺序遍历、严格大于才替换，产物立刻逐字节可复现。

这条教训后来写进 D25：**只要产物要进黄金文件，任何"并列"都必须是确定性的**。

### P4 分节：字节偏移比字符偏移难，但只有它对

分节的核心是给每节记 `[byte_start, byte_end)`，取回时 `seek + read`。坑在于 MoonBit 的 `String` 按字符索引，而偏移必须是 UTF-8 字节——所以每处累加都要用 `utf8_len_of_char`，测试里专门断言 `# 中文\n\n内容\n\n## 下一节` 的第二节从**第 18 字节**开始（字符数只有 14）。

另外两个真实站点的教训：
- 中文标题 slug 全空（`slugify` 只留 ASCII），早先退化成 `sec0/sec1`，前面插一节就全乱 → 改成内容哈希 slug（`h45937b7f`），插入不影响（D27）。
- Sphinx 页面的标题带 `¶`、Docusaurus 带零宽空格，slug 变空、标题变脏 → 分节前统一剥离（D28）。

### P5 索引：元数据 + 偏移，正文不进索引

决定索引里**只存元数据**（id/标题/层级/偏移/哈希/关键词），正文放 raw 文件。效果是单篇文档索引 502 字节、原文 5,292 字节——差一个数量级。

同期踩的坑是"身份"问题：早期 repo 名直接当目录名，`"a b"` 和 `"a_b"` 落到同一目录；`delete` 时又没走同一套 sanitize，导致删不掉。后来统一成 `repo_key`（单射映射 + `repo.json` 存原始名），并把 Windows 的保留名（`CON`）、末尾点（`foo.`）、大小写不敏感也一并处理（D37）。

### P6 MCP：协议版本让我改了整个测试思路

我以为要写 `initialize` 握手，结果服务器回 `-32601 Method not found`。查 SDK 文档才发现 `colmugx/mcp` 实现的是 **MCP 2026-07-28，无状态协议**：没有 `initialize`，第一件事是 `server/discover`，而且每个请求的 `params._meta` 必须带 `protocolVersion` / `clientCapabilities` / `clientInfo`。

另一个决定是**测试不用同一套 SDK**：`tools/mcp_e2e.py` 用标准库手写换行分隔的 JSON-RPC。理由很实际——用 SDK 客户端验证 SDK 服务端，两边犯同样的错会互相掩盖。这次就靠它抓到了 `_meta` 与 `Mcp-Name` 头这两处（D32/D36）。

### P7–P8：把"能跑"推到"敢给别人用"

- 链接密度裁剪：文本 ≥120 字符且链接占比 ≥65% 的块整体丢弃，Sphinx 页面长度比 1.33→1.27（D34）。
- 正文关键词进索引，让"词只在正文里"的查询也能命中（D35）。
- MCP 补 Streamable HTTP，但必须带 `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` 三个头，漏一个就 400（D36）。

---

## 三、审计阶段：12 类真实缺陷，一半是"看起来能用"的地方

功能跑通后我做了一轮自我审计，逐条构造复现用例。结果比预想的糟——列几个最有代表性的：

| 缺陷 | 现象 | 根因 |
| --- | --- | --- |
| 极简 HTML 死循环 | `<body><article><p>…</p></article></body>` 让 CPU 100% | 候选容器直接挂 body 时，折叠循环走到 document 节点、删光 body 子节点，unwrap 循环对 document 空转 |
| 并发索引丢文档 | HTTP 并发调两次 `index_local`，3/3 丢一篇 | `build_index` 是读-改-写，没有互斥 |
| GBK 页面乱码 | 中文站点入库是乱码 | 一律 `decode_lossy` 当 UTF-8 |
| `README.md` 变 `README.md.md` | URL 映射重复加扩展名 | 恒追加 `.md` |
| 缓存命中身份错 | 重定向过的页面从缓存读时 `final_url` 是请求 URL | meta 里有真实值但没回填 |

修的时候坚持两件事：**每条都补回归测试**（极简 HTML 那两条现在还在 `extract_wbtest.mbt` 里），以及**并发那条必须能"证伪"**——我临时去掉锁跑了 3 次 e2e，3/3 失败；加回锁后通过。如果测试在去掉修复后仍然通过，那它测的不是这个 bug。

---

## 四、可维护性：先量化，再决定要不要重写

审计之后我做了一次可维护性评估，结论是**不重写，做 4 处定向改造**。判断依据是量化的：

| 指标 | 改造前 | 改造后 |
| --- | --- | --- |
| 最长文件 | 640 行 | 486 行 |
| 最长函数 | 264 行（`build_server`） | 149 行（算法本体） |
| 跨包重复的纯文本函数 | 3 处 | 0 |
| `mcp` 包单测 | 0 | 8 |
| 测试用例 | 79 | 102 |

关键的一步是**找到重复的根因**：`md` 和 `extract` 各自实现了一遍 `find_sub`，不是因为懒，而是因为那个函数住在 `http` 包里，而 `http` 拖着 async/url/sha256/encoding 一整套传输栈——为了一字符串函数引入这些不划算。所以解法不是"提醒自己别再重复"，而是**建一个零依赖的 `core/text` 包**（D48）。

同理，`build_server` 264 行改成"一个工具一个注册函数"（D49），加第 13 个工具时就只需要一个函数 + 一行注册。

---

## 五、检索：从手写加分到 BM25

原本的检索是整数加分（标题 +20/+10/+5、路径 +2、正文词 +1 封顶 5）。它的问题是结构性的：没有词频、没有逆文档频率、没有长度归一化。实测同一查询，前两名经常并列（6/6/1/1），等于没排序。

换成 BM25（`k1=1.2`、`b=0.75`，字段权重 title 3 / path 2 / body 1）之后，同一查询得分是 89/87/63/63——有梯度了。

有一个实现细节值得记：词频**不能**在检索时算（那就要读原文，O(1) 取回的意义就没了），所以在分节时就存好 `terms`（按频次重复展开的数组）+ `tokens`（长度）。选 `Array[String]` 而不是 `Map`，是因为它能直接 JSON 派生，不需要自定义编解码（D50）。

索引格式一变，`index_version` 就从 2 升到 3——旧索引加载即视为"未索引"，自动重建。这条 fail-closed 规则从 P5 定下来，后面每次改 schema 都省了迁移代码。

---

## 六、发布：一次 403 教我的事

`moon publish` 第一次 dry-run 就失败：

```
403 Forbidden: User mismatch: The username in the module config file
(pingguomiaomiao) does not match the authenticated user's username (PingGuoMiaoMiao)
```

`moon.mod` 里的模块名是小写的 `pingguomiaomiao`（早期照 GitHub 仓库 owner 抄的），而 MoonCakes 账号是 `PingGuoMiaoMiao`。改名不只是改一行——**7 个 `moon.pkg` 的 import 路径都是大小写敏感的**，全部同步后重新跑测试才敢发。

发出去之前我做了三件事：`--dry-run` 拿到 `202 Accepted`、全量 9 条校验重跑一遍、把版本从 0.1.0 升到 0.2.0（这轮加了 BM25 与 4 个工具，属于功能升级而非修补）。

---

## 七、AI 在这个项目里到底是什么角色

全程使用 AI 编码代理（Claude Code / Codex 系列）参与：读依赖的 `.mbti` 确认 API（而不是凭记忆写）、生成代码与测试、写文档、做审计。

但**质量闸门全部是自动化、可复现的**，不依赖模型自评：

- 每个阶段 `moon test` + 5 套外部校验必须全绿；
- 抽取质量对 Mozilla Readability 做差分，不是"看起来差不多"；
- 分节正确性用独立脚本**读磁盘字节**验证（连续覆盖 / UTF-8 合法 / sha256 全等 / id 唯一），不信任进程内字符串；
- 每处取舍写进 ADR，共 **50 条**，每条都带"被否方案"。

这套做法的价值在审计阶段体现得最清楚：**AI 生成的代码并非一次正确**，12 类缺陷里有 5 类是我自己构造用例才发现的。能发现它们，靠的不是模型更聪明，而是"有差分、有契约、有能证伪的测试"。

---

## 八、如果重来，我会做不一样的四件事

1. **第一天就建 `core/text`**：零依赖的文本工具包应该在 P0 就存在，而不是等审计发现三处重复。
2. **更早引入差分 oracle**：抽取层第一天就拉 Readability 对比，能省掉后来三轮返工。
3. **把"确定性"写成硬约束**：P3 那次黄金文件漂移本可以在 P0 用一句话避免——"任何影响产物的并列都必须有确定顺序"。
4. **发布流程早跑一遍**：模块名大小写问题如果第一次 `moon pack`/`publish --dry-run` 就暴露，改名的成本远低于项目末期。

---

## 九、现在的位置

- **代码**：43 个 `.mbt` / 6,759 行，7 个包分层（`core` / `core-text` / `http` / `extract` / `md` / `section` / `index` / `mcp` / `cmd`），依赖图无环。
- **测试**：102 个用例 + 5 套外部校验（抽取差分、黄金文件、字节契约、两套 MCP 端到端）+ 验收自检脚本。
- **文档**：16 份，含预研、50 条 ADR、P1–P12 每阶段记录、可维护性评估、参考实现对照。
- **可用性**：CLI 12 个子命令、MCP 12 个工具（stdio + Streamable HTTP）、MoonCakes 已发布。

还没做完的，我都写在 `docs/p7-delivery.md` 的「已知差距」里：中文分词、RST/ipynb、增量跳过、MCP 认证、arXiv/Sphinx 的抽取比例调优。**把它们写下来，比假装它们不存在更有用。**
