# 开发历程

> 这份文档记录 MoonHive v2 的关键决策与踩坑过程。
> 记录这些是因为：**能讲清楚为什么这么做、为什么改**，比列功能清单更有用。

## 一、为什么推翻 v1

v1 是一个"每日 GitHub 项目投喂"工具：拉取 GitHub 项目、按多因子打分排序、生成推荐卡片、支持收藏与导出。功能上它是完整的——8 个命令、59 个单元测试全绿、CI 通过。

但它被初审明确驳回，理由是：**「选题仍偏薄封装和一次性脚本，暂不满足生产级生态项目的规模与边界要求。」**

复盘后我认为这条评语是准确的，而且指向两个具体的技术事实：

**事实一：真正的引擎在仓库的工程边界之外。**

`daily.ps1` 用 `curl.exe` 打 GitHub Search API、处理限流、精简字段；MoonBit 侧只接收 base64 编码后的 JSON 做格式化输出。也就是说，**网络与数据获取——一个工具的命脉——全部在一个 159 行的 PowerShell 脚本里**，而这个脚本连 CI 都覆盖不到，无法测试。

**事实二：一个完整的业务动作被 shell 边界切成了三段。**

`daily.ps1` 的实际流程是：先 `moon run daily filter` 拿到文本 → `ConvertFrom-Json` 解析 → 重新 base64 编码 → 再 `moon run daily digest`。这是"脚本调工具调脚本"的三次往返，正是"一次性脚本"的典型形态。

**旁证**：`daily_plugin.mbt` 单文件 1157 行、43 个函数、无分节；`Plugin::execute` 返回 `String`，**没有错误契约**；没有文件 I/O、没有配置、没有缓存。8 个命令全是"字符串进 / 字符串出"——所以它本质上是**一个字符串处理器，而不是一个工具**。

**结论：改 README 或补充申报说明都无法解决，必须动结构。**

## 二、为什么选择"生态验证引擎"这个新内核

换内核时我用一条判据筛选方向：

> **如果这件事用大模型直接对话就能解决，那它不值得做成项目。**

按这条判据回看 v1：「读一批 GitHub 项目、排序、生成推荐」——大模型能做得更好，GitHub 原生搜索也基本能覆盖。**v1 的场景本身就不成立。**

而"**这个包能不能真的用**"这件事：
- 大模型做不到——它不会去 `git clone` 一个仓库再跑 `moon check`
- GitHub 搜索做不到——它不告诉你包在你这套工具链上跑不跑得起来
- **只有握着工具链的人才做得到**——这就是别人抄不走的地方

而且它恰好是 MoonBit 擅长的事：需要起进程、读编译器诊断、管理真实工作区、做类型化的错误分类。

## 三、技术选型与取舍

### 3.1 为什么自建 C FFI，而不是引入第三方包

`moonbitlang/core` 不提供文件系统与进程模块。查证后确认它们只存在于第三方的 `moonbitlang/async`。

选择自建，理由是核心能力（起进程、读诊断、管工作区）必须完全可控，且零第三方依赖能让评审中的"第三方依赖合规"一项没有争议。代价是必须自己处理跨平台差异与 FFI 边界上的类型转换。

实现过程中验证了以下能力确实可行：
- C stub 编入 native 产物（`-lws2_32` 链接成功）
- C 侧分配 MoonBit 字符串跨边界传递
- FFI 执行外部进程并分离捕获 stdout / stderr / 退出码
- FFI 递归遍历与删除目录

### 3.2 为什么放弃了"自写 HTTP 客户端"

最初的设想是连网络层也自己实现。实测时发现一个硬事实：

```
$ 用自研 socket 客户端请求 api.github.com
HTTP/1.1 301 Moved Permanently
Location: https://api.github.com/search/repositories?...
```

**GitHub 强制 HTTPS。** 纯 socket 只能做明文 HTTP，没有退路。而"能访问 HTTPS"意味着要实现 TLS——握手、密钥交换、AES-GCM、证书链验证。

这是一个明确的取舍点：**TLS 与"验证包能不能用"这个目标毫无关系**。继续做下去就是典型的"堆技术"——增加大量复杂度，却不产生用户价值。

于是改为：**让 git 与 moon 工具链承担网络部分**（`moon search` 实测可以直接访问 mooncakes 注册表），MoonHive 专注于"拿到代码之后怎么验证"这一层真正的价值。

### 3.3 为什么 CLI 只支持 native 后端（以及库核心为什么不受限）

C FFI 不支持 wasm 后端，这是硬约束。接受它，是因为 **CLI 的定位是本地命令行工具**：它需要起子进程、读写真实文件系统，这些在 wasm 运行时里本就不成立。

这个选择也影响了 CI：构建矩阵改为 `native` + Linux/Windows 双平台，而不是默认的 wasm。

**后续修正（2026-09-30）**：上面这条取舍最初被推导成了"整个项目只能 native"，这是过度推广。真正受 C FFI 限制的只有 CLI 链路；**归因分类器、报告渲染、错误契约都是纯计算，本来就与后端无关**。原始取舍的代价因此被低估了：它把一个可移植的库核心连带锁死在 native 上，任何人想在 wasm/js 环境里复用归因能力都不可能。

现在的做法是把边界显式声明出来，交给构建器强制：三个 FFI 包声明 `supported_targets = "native"`，库核心七个包声明四个后端全支持。实测 `moon test --target wasm` 从"整模块无法编译"变为 **87 个用例全绿**。详见 [ARCHITECTURE.md 的分层与边界](ARCHITECTURE.md#后端可移植性是构建期强制的不是文档承诺)。

## 四、被实测揪出的真实缺陷

这一节是本文档最有价值的部分——这些缺陷**都不是设计时想到的，而是端到端真跑之后才暴露的**。

### 4.1 进程错误码语义冲突（严重）

**现象**：一个明确编译失败的模块，报告结论是"编译失败"，但证据栏写的是"无法创建子进程（命令不可执行或系统资源不足）"——一个真实存在的诊断行被替换成了一句假的错误原因。

**定位过程**：先用 PowerShell 手工复现 C 侧构造的完整命令，结果是正确的（退出码 -1，stderr 文件 420 字节）。这说明命令构造没问题，问题在进程层。

**根因**：Windows 上 `_pclose` 在**命令运行失败时也返回 -1**，而我把"popen 失败"的错误码也定义为 `-1`：

```c
#define PROC_ERR_POPEN (-1)   // 与真实退出码 -1 冲突
```

于是"命令正常失败"被误判成"无法创建进程"。

**修复**：把内部错误码统一移到 `-1000` 以下，与真实退出码彻底分离；同时新增 `proc_last_os_error` 上报 `GetLastError()` / `errno`，便于区分"权限不足"与"资源耗尽"。

**修复后**：同一用例从错误结论变为正确的 `DoesNotCompile`，且证据指向具体诊断行。

**这次暴露的问题**：**"进程启动失败"与"进程运行后失败"必须用互不重叠的表示**。这是一个很容易被忽略、但会让整个工具失去可信度的坑。

### 4.2 C 侧 UTF-8 解码缺失

**现象**：报告里的证据栏显示 `âââ[ ... ]` 这样的乱码。

**根因**：MoonBit 的 `String` 是 UTF-16，而我在 C 侧读取文件后**把每个字节直接当作一个 UTF-16 码元**：

```c
out[i] = (uint16_t)(unsigned char)src[i];   // 多字节 UTF-8 序列被撕裂
```

ASCII 内容看不出问题，但编译器诊断输出里有框线字符（`╭─│╰`）与中文，全部变成乱码。

**修复**：实现正规的 UTF-8 → UTF-16 解码，处理以下情况：
- 合法的 1/2/3/4 字节序列 → 对应码元（含 UTF-16 代理对）
- BOM（`EF BB BF`）→ 跳过
- 非法起始字节、截断序列 → 替换为 `?`，绝不产生乱码

写入方向同样处理：把 UTF-16 编码回 UTF-8，含代理对合并。

**这次暴露的问题**：**跨 FFI 边界的字符串必须显式处理编码**。字节到码元的直接映射只在纯 ASCII 下正确，而编译器输出、中文注释、终端框线符号都不是 ASCII。

### 4.3 符号重复定义（架构不一致的代价）

**现象**：加入 `survey` 后链接失败：

```
fs_stub.c:(.text+0x970): multiple definition of `fs_read_text'
proc_stub.obj:proc_stub.c:(.text+0x530): first defined here
```

**根因**：项目早期我把文件 I/O 放在了 `platform/proc` 里（因为进程输出捕获需要读临时文件），而后来 `platform/fs` 也提供了文本读写。两个 C stub 最终链接进同一个可执行文件，同名符号冲突。

**这暴露的不是一个笔误，而是边界没划清**：`proc` 应该只管进程，文件系统归 `fs`。

**修复方式与一个额外约束**：把通用文本读写统一到 `fs`，`proc` 只保留进程捕获所需的读回，符号名加 `proc_` 前缀。这里有一个不能靠"让 proc 依赖 fs"来解决的原因——**fs 依赖 proc**（用 `run_ok` 做可写性探测），反向依赖会形成环。因此选择用符号前缀隔离，而不是用包依赖。

**这次的教训**：**两个模块提供同名能力时，要么合并、要么改名**；跨语言链接期的符号是全局的，包边界在链接期不提供任何保护。

### 4.4 删除代码段时的连带损伤

修 4.3 时，我用脚本删除了 `proc_stub.c` 中的一段文件 I/O 代码，结果连带删掉了旁边的一个辅助函数 `temp_name`，导致 `undefined reference to temp_name`。

**教训**：在大段删除时，删除边界应该由**明确的结构标记**界定（例如某个完整注释块到下一个注释块），而不是靠字符串匹配猜。这类错误编译期能发现，代价可控，但如果发生在运行时逻辑里就会很难查。

### 4.5 单线程 HTTP 服务被"半开连接"永久卡死（严重）

`serve`（浏览器仪表盘）的 C 实现是**单线程串行**处理：`for(;;) { accept(c); recv(c, req, ...) }`。`recv` 是阻塞调用且没有设置超时——任何"连上但不发送数据"的客户端（浏览器预连接、TCP 半开、异常断开）都会让 `recv` 永久阻塞，**整个服务被卡死**，后续所有请求排队等待，浏览器表现为"无限加载"。

**这是端到端实测才暴露的**：三个路由（`/`、`/api/reports`、`/api/report/<n>`）单独测都正常返回 200，但浏览器打开页面后一直转圈——直到用 `curl --noproxy` 直连也挂起，才意识到服务被某个连接卡死了。

**修复**：`accept` 后对每个连接设置 `SO_RCVTIMEO`（5 秒）。超时后 `recv` 返回错误，已有的错误处理路径会关闭连接并回到循环，服务自动恢复。实测：建立一个"连上不发数据"的连接挂起 7 秒，之后服务仍能正常响应。

**教训**：网络服务的第一条工程法则——**任何阻塞 I/O 都必须有超时**。单线程模型本身没错（每个请求毫秒级），但"连上不发数据"是一个真实存在、随时可能发生的状态，不处理它就是故障。这也解释了为什么服务刚启动时是好的，跑一会儿就"变慢"了——其实是积累的半开连接把服务卡死了。

**额外教训（链接期）**：本项目 winsock 符号全部通过 `LoadLibrary("ws2_32.dll")` 运行时动态加载（moon 的 `cc-link-flags` 不进入可执行链接）。新增调用 `setsockopt` 时直接写会报 `undefined reference to __imp_setsockopt`——必须照既有模式补 `typedef` / 全局函数指针 / `GetProcAddress` / 完整性检查 / `#define` 映射五处。跨 FFI 边界的"新增系统调用"不是改一行的事。

## 五、被环境挡住的部分（如实记录）

开发环境的沙箱限制了两条路径，**这是环境问题而非代码问题**，但值得记录，因为它们影响了验证方式：

1. **`git clone` 被禁止**：报 `couldn't create signal pipe, Win32 error 5`。原因是 Git for Windows 依赖命名管道。`git ls-remote` 却能正常工作。
2. **`Invoke-WebRequest` 被 TLS 凭据限制拦截**：报 `schannel: AcquireCredentialsHandle failed`。

**应对**：新增 `fs.copy_tree` 与本地目录候选支持。这本身也是一个真实需求——用户手上往往已经有 checkout，能直接验证它比"必须先联网"更有用，也便于离线复现结论。

**因此**：端到端验证在本机是用本地目录完成的（真实工具链、真实编译、真实测试），远端克隆路径的代码已完成但未能在本机跑通。这一点在项目文档中如实说明。

## 六、端到端跑通样例

```
$ moonhive survey --out report.md <4 个候选>

MoonHive 生态验证引擎
git TLS 后端: 系统默认（未显式指定后端）
工具链: moon 0.1.20260904 (94521db 2026-09-04)
目标后端: native
待验证: 4 个

[1/4] ✅ mh-fixture-ok      Verified          173ms
[2/4] ❌ compile-fail       DoesNotCompile    146ms
[3/4] 📄 not-a-module       NoManifest        0ms
[4/4] 🔒 has-c-stub         Unsafe            0ms

结论: Verified 1 · DoesNotCompile 1 · NoManifest 1 · Unsafe 1
报告已写入: report.md
```

报告头强制记录验证环境（时间戳、目标后端、工具链版本）——**没有这一节，"这个包能不能用"的结论就无法复现，也就无法被信任**。

## 七、当前验证结果与剩余工作（如实说明）

**已完成的验证（真实工具链，非模拟）**：

- 构建：0 警告；测试：**140 个用例全绿**（native）
- **库核心跨后端**：`wasm` / `wasm-gc` / `js` 各 **87 个用例全绿**，边界由 `supported_targets` 构建期强制
- `survey` 四种结论分类端到端跑通（Verified / DoesNotCompile / NoManifest / Unsafe）
- `serve` 仪表盘：三个路由实测 200，半开连接防护已修复并实测
- **CI 双矩阵（Linux / Windows）已实跑且全绿**——其中 `join_path` 按平台选分隔符的修复（2c9968b）就是在 CI 的 Linux 环境下暴露的：GitHub runner 设置了 TEMP，测试目录被拼成含 `\` 的路径，Windows 上没问题、Linux 上目录遍历测试全挂
- 测试用例数：140（此前 61 → 115 → 124 → 132）

**剩余工作**：

| 项 | 状态 |
|---|---|
| 远端 `git clone` 路径在本机（含中文路径 + 沙箱限制环境）实测 | 🚧 CI 已覆盖，本机未单独实跑 |
| GitHub Pages 部署静态站 | 🚧 未开启（需在仓库设置里启用） |
| 更多真实生态包的验证样本 | 🚧 按需补充 |
