本手册是项目的单一事实来源(single source of truth):定位、进度、架构、里程碑、工程约定都在这里。每完成一个里程碑提交时同步更新 §02 快照与 §04 状态。
一句话定位:纯 MoonBit 的 iCalendar(RFC 5545)解析 / 重复事件展开库,长成 mooncakes.io 生态第一个 CalDAV(RFC 4791)日历服务器——把竞品停在「中段计算层」的流水线,接到「真实日历客户端可直连」的服务端。
立项依据(申报书可直接引用,全部 2026-09-08 核实):
caldav / webdav / webcal / calendar-import 均返回 No modules found。| 包 | 内容 | 实现行数 | 测试 | 状态 |
|---|---|---|---|---|
| ical/text | 折行展开(§3.1)、内容行解析(引号参数)、文本反转义、带行号 ParseError | 309 | 20 用例 | 已落地 |
| ical/model | 组件树(BEGIN/END 嵌套、未知组件保留)、IcalDateTime(四态时间,Eq)、ZoneTable(固定偏移 + feed 内嵌 VTIMEZONE)、Event 类型化视图(S1:parse_events / Component::events,RRULE 原文、EXDATE、RECURRENCE-ID) | 846 | 53 用例 | 已落地 |
| demo | 可运行入口:内嵌真实形态 fixture(折叠/转义/TZID/RRULE/EXDATE/全天/改期覆盖),打印事件清单(moon run demo) | 71 | — | S1 起步 |
| ical/fetch | 取件层:fetch_ics(url, timeout_seconds?=30)(@http.get 薄层 + 状态检查,非 200 抛 FetchError::BadStatus;上游无任何超时,本包以 with_timeout 兜底,到点抛 TimedOut——S20);demo 支持 URL 参数端到端 | ~60 | 1 用例(本地挂起监听器的超时单测) | 已落地 |
| ical/rrule | 完整日历级 RRULE 展开(S2/S3/S8):四档 FREQ × INTERVAL/COUNT/UNTIL、序数 BYDAY、正负 BYMONTHDAY、BYMONTH、BYSETPOS、WKST;expand_series 合并 EXDATE、RECURRENCE-ID 改期/取消/THISANDFUTURE,ZoneTable 解析结果贯穿展开 | 约 1200 | 31 用例(graham 第一档 23 条 + 第二档 8 条 + 合并/时区) | 已落地 |
| ical/serialize | 组件树 → .ics 文本(S4):CRLF、75-octets 折行(多字节不劈开)、参数值按需重引号;@text.escape_value 与 unescape 对称;roundtrip 组件树相等且幂等 | 172 | 11 用例(roundtrip/折叠/引号/空值) | 已落地 |
| ical/store | vdir 存储(S5/S10):每事件一个 .ics 文件,每个日历集合一个子目录;FNV-1a 内容寻址 ETag(同字节同 tag);名字白名单(字母数字._- 且非点开头)防路径逃逸;get 缺失是 None 不是错;集合能力 = collections/create_collection/collection/remove_collection,list 只返回事件 | 312 | 6 用例(ETag/拒绝名/磁盘往返/集合生命周期) | 已落地 |
| ical/httpd | HTTP/1.1 请求层(S5):手解析请求行/头/Content-Length 体,方法是 token(WebDAV 方法天然可读);chunked 拒收(411);裸 socket 读写(ServerConnection 是帧化 reader 不适用);S6 在此之上加 WebDAV/CalDAV 语义 | 328 | 10 用例(MemoryReader 驱动) | 请求层已落地 |
| ical/caldav | WebDAV/CalDAV 语义(S6/S10):发现链、PROPFIND Depth 0/1、calendar-multiget、XML 安全转义、If-Match/If-None-Match;calendar-query 真正求值 CALDAV:filter(嵌套 comp-filter / is-not-defined / time-range 走 expand_series 判重叠 / prop-filter 的 text-match+param-filter / 两种 collation),filter 前置条件按 §7.8 回 403 valid-filter / supported-filter;最小 XML 形状解析器(xml.mbt);cmd/serve 接入 well-known、MKCALENDAR 集合与标准 201/204/207/403/405/409/412 状态 | 1374 | 26 用例 + 23 项 curl 活体验收 | 已落地 |
合计:0.3.0 开发分支严格全目标测试为 wasm 189、wasm-gc 174、JavaScript 189、native 197;S5 23 项与 S6 36 项真实 curl 活体验收全绿。Thunderbird 155.0.1(Windows)已完成发现、读取、创建、原位修改与删除的真实 CalDAV 联调。
请求自上而下流经六层,每层一个包、单向依赖;层与层之间只经公开接口(.mbti)通信:
@async 的 send_responseMilky2018/xmlical/text: unfold(String) -> Array[String]
parse_content_line(String, Int) -> ContentLine raise
unescape_value(String) -> String
suberror ParseError::BadLine(line_no~, line~, message~)
ical/model: parse_components(Array[String]) -> Array[Component] raise
parse_date_time(ContentLine, ZoneTable, line_no?) -> IcalDateTime raise
parse_single_date_time(String, ZoneTable, line_no?) -> IcalDateTime raise
parse_utc_offset(String, line_no~, raw~) -> Int raise
build_zone_table(Array[Component]) -> ZoneTable
Component::{property, properties_named, events}
IcalDateTime::{instant_seconds, is_utc, is_floating, compare} // derive Eq(S1 起)
ZoneTable::{empty, builtin_common, with_builtin, insert, lookup}
parse_events(String) -> Array[Event] raise
parse_date_time_value(String, ZoneTable, tzid?, value_is_date?, line_no?) raise
struct Event { uid, summary, location, description : String?
dtstart, dtend, recurrence_id : IcalDateTime?
rrule : String? exdates : Array[IcalDateTime] }
Event::all_day(Self) -> Bool // impl Show
哲学:每步一个最小目标 → 代码 + 测试 → 验收 → 一次提交。单步超过半天就拆。任何一步卡住,先砍范围再加班。
| # | 目标 | 规模 | 验收(过不了的验收不算完成) | 状态 |
|---|---|---|---|---|
| S0 | 清场:删模板/探针,README/moon.mod 就位,登记接缝 | 0 行 | moon check && moon test 全绿,工作区干净 | 完成 0ab8978 |
| S1 | 类型化事件层:Event 结构 + Component::events()(映射层,无新算法) | ~150 | demo 读本地 fixture .ics 打印事件清单 | 完成(S1) |
| S1.5 | 取件切片:ical/fetch(@http.get 薄层,非 200 抛 FetchError)+ demo 支持 URL 输入:URL → 事件清单 端到端。网络测试不进 CI(Google 源本网络不可达,演示用 calendarlabs) | ~130 | 给真实可达 .ics URL,打印解析后事件清单;CI 仍走本地 fixture | 完成(S1.5) |
| S2 | RRULE 解析(不展开):第一档子句 → Rule;不支持子句显式报错 | ~200 | graham 32 个用例的 rule 串全部按档位解析或报错 | 完成(S2) |
| S3 | 第一档展开:DAILY/WEEKLY/MONTHLY + INTERVAL/COUNT/UNTIL/BYDAY(无offset)/BYMONTHDAY(正) + DTSTART 回退特例 | ~400 | RFC 附录 A 第一档 ~20 用例对拍全绿(语料:graham/rrule 测试套件,见 THIRD-PARTY-NOTICES.md) | 完成(S3) |
| S4 | iCalendar 序列化写回:fold(75字符)/escape/组件树→ics(CalDAV 响应体就是它,故提前) | ~200 | parse → serialize → parse 往返不变 | 完成(S4) |
| S5 | HTTP/1.1 请求层 + vdir 存储:accept + 手解析请求行/头/Content-Length 体;OPTIONS/GET/PUT/DELETE | ~350 | curl 完成事件 PUT/GET/DELETE 往返,ETag 正确变化;chunked 请求回 411 | 完成(S5) |
| S6 | WebDAV/CalDAV 核心:PROPFIND(Depth 0/1)、REPORT(calendar-query/multiget)、MKCALENDAR、well-known 重定向、发现链、If-Match/If-None-Match | ~450 | curl 模拟 Thunderbird 发现链 + REPORT 序列全绿(录成集成测试) | 完成(S6) |
| S7 | 真实客户端联调:以 Thunderbird 完整 CRUD 为首版验收;DAVx5 / Apple 日历降为赛后互操作候选 | 0.5–1 天 | ≥1 个真实客户端完整走通;产出互操作矩阵(客户端 × 操作 × 结果) | 完成(Thunderbird 155.0.1 CRUD) |
| S8 | 库层深度:RECURRENCE-ID 覆盖合并(改期/取消/THISANDFUTURE)+ 第二档 BY*(BYSETPOS/WKST/序数 BYDAY/负 BYMONTHDAY)+ ZoneTable 接入展开链 | ~500–700 | 附录 A 全 32 用例按档位通过;RECURRENCE-ID 合并用例通过 | 完成(S8) |
| S9 | 发布打磨:CI(check/test --target all + fmt + info)、mooncakes 发布、申报书、根包 re-export 门面、互操作矩阵进 README | 0.5 天 | CI 绿、moon publish 成功、申报书一页 | 完成(0.1.0 已发布,scope frozen) |
| R1 | 系列合并性能:一次索引同 UID overrides/EXDATE,按序游标应用 THISANDFUTURE | ~100 | 2000 occurrences + 500 无关 overrides 的 native release 基准由 20.27 ms 降至 1.64 ms;语义回归与全目标测试通过 | 完成(约 12.4×) |
| R2 | CalDAV REPORT 热路径:StringView 零拷贝扫描 + href 哈希选择 | ~100 | 500 hrefs × 500 resources 的 native release 基准由 68.17 ms 降至 3.87 ms;命名空间前缀回归与全目标测试通过 | 完成(约 17.6×) |
| R3 | 0.2 根门面收口:新增整文档 parse_calendar,顶层只暴露完整 parse / recurrence / serialize 工作流 | ~50 | pkg.generated.mbti 明确移除协议与文本层拼装 API;子包高级入口保留;门面往返测试通过 | 完成(待发布) |
| R4 | 通用 ICS 解析链 StringView 化:unfold、内容行、参数和 EXDATE 列表仅在所有权边界分配 | ~120 | 1000 VEVENT 的 native release 基准由 25.83 ms 降至 15.10 ms;既有 Unicode 折行与全目标回归通过 | 完成(约 1.71×) |
| R5 | RRULE 预归一化:排序 BYMONTH/BYMONTHDAY,并缓存 28–31 天月份的实际候选日 | ~80 | 密集月度规则 10000 occurrences 的 native release 基准由 5.83 ms 降至约 5.19 ms;闰年、负月日、原 Rule 不变的回归通过 | 完成(约 11%) |
| R8–R11 | 赛后优化审计落地:测试盲区补齐(15 项)、AI 生成痕迹清理(净 -105 行)、StringView 残余迁移(R4 边界之后的全部值解析)、respond 缓冲 + ZoneTable Map 索引;HttpRequest.meth 经实测确认为避让保留字 method,不改名 | ~-160 | 四目标 150/137/150/155 全绿;基准保持 R 系列基线(parse 15.6 ms、multiget 3.7 ms、series 1.68 ms、monthly 5.5 ms);.mbti 仅 ZoneTable.entries 一处预期变更 | 完成 |
| S10 | 验收缺陷修复:①calendar-query 真正求值 CALDAV:filter(RFC 4791 §9.7 全语法;time-range 走 expand_series 展开判重叠,§7.4/§9.9),filter 前置条件按 §7.8 回 403 valid-filter / supported-filter;②MKCALENDAR 在 /cal/<cal>/ 真正建集合(已存在回 405,§5.3.1),集合可 PROPFIND / REPORT / 读写 / 删除,默认集合路径 /cal/<name>.ics 保持不变 | ~1100 | S6 活体验收由 11 项扩到 23 项并全绿(含区间内/外选择、无 filter 回 403、未知组件回 403、MKCALENDAR 201/405/发现/写入/集合内 REPORT 隔离/非空 409/删空 204);全目标 170/157/170/176;S5 21/21 无回归 | 完成 |
| S11–S12 | 复审修复(库层):负 BYMONTHDAY 在 DAILY/WEEKLY 路径按候选月长度归一化判定(原先静默零匹配);展开步进全 Int64 化、越出 year 9999 视为序列终结(替代 x/time 未类型化 Failure 泄漏与回绕出的「有效但错误」日期);越界日期分量收敛为 BadLine(T240000 按 RFC 5545 拒绝);parse_utc_offset 严格化(+hh/+hhmm/+hhmmss 三形态、分钟秒 ≤59,+05:30 不再半读丢 30 分钟) | ~+270 | 差分测试钉死:DAILY 单负/混合符号/WEEKLY 月末星期、MONTHLY;INTERVAL=100 的 957/958 边界、巨 INTERVAL 巨回绕、四形态坏偏移、五形态坏日期 | 完成 |
| S13–S14 | 复审修复(服务层):集合 URL 无斜杠改 301 规范形式(RFC 4918 §5.2,替代 Windows 500 / POSIX 静默 rmdir);XML 嵌套深度上限 64(替代栈溢出进程死亡,复核实测 5 万层 0xC00000FD);text-match 与 multiget href 解码 XML 实体;请求头改自带缓冲的有界读取(64KB/行、100 头);非数字 Content-Length 回 400;limit≤0 回空;参数值边缘空白序列化加引号;路由剥 query string | ~+300 | S6 活体 +4(301 差分对);S5 +1(query 同字节差分);httpd 单测 10→15(头行/头数封顶、400/413 分流) | 完成 |
| S15 | 审计缺口收口:VALARM time-range 拒绝的单测补齐(§11 矩阵「有单测」声明成真);S6 活体 +6(Depth 0/不可解析 Depth 差分、multiget 未知 href 空结果、PUT /cal/ 405、DELETE 陈旧 If-Match 412);§11 矩阵补 ical/fetch 行(已声明、仅人工验证、网络测试不进 CI 为 S1.5 决策) | ~+70 | S6 31/31、S5 22/22 全绿;全目标 185/170/185/192;README×2 与 §02 计数同步 | 完成 |
| S16 | 复审修复第二波(K1):WEEKLY 展开在 9999 年最后一周的天循环补上越界 catch——S12 只守住了「步进到周首」这一步,周内 base+i 越过 year 9999 仍泄漏 x/time 未类型化 Failure;越界日终结该周,与 DAILY/MONTHLY/YEARLY 的地平线语义一致 | ~+30 | 差分测试 ×2(DTSTART 9999-12-30 纯 WEEKLY 恰 1 实例不抛;BYDAY 全周走到 12-31 后自然终结);全目标 187/172/187/194 | 完成 |
| S17 | 复审修复第二波(K5b):413 拒绝分支补上与 411 相同的 drain——客户端还在上传超大 body 时,带未读数据关闭连接的 RST 会吞掉 413 响应(Windows 实测突发/并发两型 6/6 全丢,对照组 411 两型 2/2 可靠送达) | ~+40 | S5 活体 +1:原始 socket 声明 20MB 只发 256KB 的上传中途客户端必须收到 413(修复前该检查失败);S5 23/23、S6 31/31 复跑全绿 | 完成 |
| S18 | 复审修复第二波(K2):comp-filter 的每项测试作用于同一组件实例(RFC 4791 §9.7.1)——VEVENT 的 time-range 改按该组件自身 UID 的系列判重叠(master 与 override 同 UID,展开语义不变),不再用全资源事件判;此前另一事件的区间重叠可以「代通过」本事件的 time-range(双事件资源假阳性) | ~+45 | filter 差分测试(1 月区间 + SUMMARY=Beta 的双事件资源不选中,6 月区间选中 Beta);S6 活体 +2 同型差分对;S5 23/23、S6 34/34 全绿(S6 计数勘误:S15 实加 7 项到 32,文档一直记 31,本轮 +2 并按真实值纠正);全目标 188/173/188/195 | 完成 |
| S19 | 复审修复第二波(K5e,jev 裁决的决策落地):家目录裸形式 /cal 301 到规范 URL /cal/(RFC 4918 §5.2),与 S13 给嵌套集合立的规则统一——此前家目录单独双写法直接服务,且无任何决策记录(S5 以来的未审视继承行为);发现链发出的 href 全部带斜杠,存量客户端不受影响 | ~+25 | serve wbtest 断言更新(/cal 与 /cal?x 归 HomeBare);S6 活体 +2(裸形式 301+Location、带斜杠形式 200 差分对);S5 23/23、S6 36/36 全绿 | 完成 |
| S20 | 复审修复第二波(小项批 K3/K4/K5g):①ResourceView 携带 feed 时区表并贯穿 VTODO/VJOURNAL 区间与 prop-filter 的 time-range(此前只认内置表,feed 私有 TZID 假阴性);②parse_utc_offset 补小时 ≤23 上界(+9900 曾被静默接受为 99h);③fetch_ics 以 with_timeout 兜底超时(上游 async 客户端与 socket 层无任何超时,挂起服务器原先永久阻塞调用方),timeout_seconds?=30、到点抛 FetchError::TimedOut | ~+80 | K3 差分(feed 声明 TZID 的 VTODO 选中 / 撤销声明不选中);K4 差分(+9900 拒绝、+2359 仍合法);K5g 超时单测(本地「接受但永不响应」监听器 + 1s 界,断言恰为 TimedOut——纯本地、进 CI);S5 23/23、S6 36/36;全目标 189/174/189/197;fetch .mbti 预期两处 diff(新参数 + 新变体) | 完成 |
| S21 | 依赖升级(发布前窗口,排期经 jev 裁决):async 0.21.2→0.22.4、x 0.5.1→0.5.5(moon add --upgrade);K5g 的前提在新版 async 复核(HTTP 客户端与 socket 层仍无任何超时,§11 声明补注两版均核) | ~0(仅 moon.mod 与文档) | 升级后全门闸 + 活体复跑:check/test all deny-warn 189/174/189/197、fmt、info 零 diff(.mbti 不变)、S5 23/23、S6 36/36 | 完成 |
每个里程碑严格走同一循环,禁止「先写一大坨再补测试」:
assert_eq / assert_true(pattern is …) 级别的断言测试;边界(空输入、畸形输入、行号)必须覆盖。///| 分隔,每块带一句话文档注释说明「为什么」。moon fmt && moon check && moon test && moon info,然后检查 .mbti diff——接口变化必须是预期内的,意外的 diff 说明改坏了边界。git -c core.hooksPath=.githooks commit …——项目钩子会跑 moon check 并拒绝「改源码但不更新 PROGRESS.md」的提交。docs/PROGRESS.md(跨会话状态源:本地文件,不入库——§1 快照、§2 里程碑、§3 会话日志)与本手册 §02/§04/§07。钩子会校验它在上次提交后被更新过。新格式(rr_moon_mod + rr_moon_pkg)下的硬规则,违反即编译失败或静默 bug:
import { "moonbitlang/async/http" @http } 写在所在目录的 moon.pkg;.mbt 文件里写 import "..." 是硬错误(3001/3002)。测试专用导入用 import { … } for "test"。pub(all) suberror Name { Ctor(field~ : Type) };Error 是内建 opaque 类型不是 trait,impl Error for X 必失败。展示用 pub impl Show for Name with fn output(...)。Ctor(a=1, b=2),匹配 Ctor(a=x, b=y);位置式会报 requires 0 positional arguments。a..<b(右开)/ a..<=b(闭)。旧写法 a..=b 语义等价,但新版 formatter 会归一成 a..<=b;for 头写坏会在下游报假错误(unbound identifier / 类型不匹配)——先修 for 头。String::trim() 接 StringView::data():data() 返回整个原串不是切片,trim 会静默消失。用 String::from_array(s.to_array()[i:j]) 或手写字符数组裁剪(ical/text/content_line.mbt 里的 trim_ascii 就是这么写的)。moon ide doc "Type::method"(搜 core + 已注册依赖),不要凭记忆写 stdlib 名字。async fn main 需要根包 moonbitlang/async 一并导入;可执行包声明 pkgtype(kind: "executable")。Int 与 Int32 是同一个类型:新版 formatter 把源码里的 Int 归一成 Int32;moonc v0.10.15 及以前 .mbti 会归一回 Int,v0.10.17 起直接渲染 Int32(全仓 6 个接口文件 24 行纯渲染 diff,已随 S10.5 入库)——语义完全不变。S10 后仓库源码统一为 Int32,新代码跟随现状即可。name? : T = d 改写成 name~ : T = d。差别在调用方式——~ 形式只能按名字传(f(x, name=1)),不能再按位置传(实测 parse_date_time(line, zones, 7) 报「requires 2 positional arguments, but is given 3」)。仓库内所有调用点都用 name=value,故本次迁移零调用点改动。.mbti 分不清 ? 与 ~:两者在接口文件里都渲染成 name? : T。于是「.mbti 无 diff ⇒ 对外接口无变化」这条判据在新工具链下有盲区——S10 迁移 .mbti 零 diff,但按位置传可选参数的调用方会编译失败。判断接口兼容性时不能只看 .mbti。off 的检查(0073 unnecessary_annotation、0074 missing_doc、0038/0039 证明类等),--deny-warn 不会开启它们。用 moon check --warn-list "+74" 之类的开关按需排查(编号见 moon explain --diagnostic)。原则:业务代码永不 import 兜底实现,只 import trait;每个临时实现标注 UPSTREAM-GAP;探针转绿 = 上游已补齐 = 立即执行切换配方。完整表格在 docs/upstream-seams.md,此处摘要:
| # | 能力 | 现状(2026-09-08) | 本项目策略 |
|---|---|---|---|
| S1 | js 目标本地文件 | @async/fs native 可用,js 空壳 | CalDAV 服务主目标 native,js 支持延后 |
| S2 | IANA tzdb / DST 历史 | mooncakes 无 tzdb 包(x/time 仅 fixed_zone) | ZoneTable:固定偏移 + feed 内嵌 VTIMEZONE;边界写进 README |
| S3 | HTTP 抓取 | @async/http client 双目标可用(M0 实测) | 保留 Fetcher 抽象,S9 可选支线 |
| S4 | async 运行时稳定性 | README 自述 experimental | API 触点收敛在 httpd/store 两处 |
| S5 | 系统本地时区 | 需自行 FFI | 暂不需要(服务用 UTC + 显式偏移) |
| S6 | WebDAV 方法承载 | RequestMethod 封闭枚举(types.mbt:16),无 PROPFIND/REPORT/MKCALENDAR | S5 手解析请求行(方法只是 token);上游扩展后一行切回 read_request |
// 响应侧(直接用,无障碍):
conn.send_response(200, "OK", extra_headers={ "Content-Type": "text/calendar" })
conn <+ body // 字符串插值写入
conn.end_response()
// 请求侧(绕开 read_request 的方法枚举):
ServerConnection 实现了 @io.Reader(server.mbt:65)
// → 自己读字节流,解析请求行 "PROPFIND /cal/ HTTP/1.1"、头、Content-Length 体
// XML:Milky2018/xml 0.4.1(拉式解析)或 moonbit-community/XMLParser 0.2.5
// JSON:moonbitlang/core/json(REPORT/调试输出用)
411 Length Required(客户端 PUT 几乎不用 chunked)。DAV:prop 选择——S10 让 calendar-query 求值了 filter,但请求的属性集合仍被忽略,响应固定返回 resourcetype / getcontenttype / getetag / calendar-data;按 prop 裁剪与「未知属性回 404 propstat」留后续。MKCALENDAR 真正建集合,但 body 里的 displayname / calendar-color 等属性不落盘;集合名即 displayname。/cal/ 兼作家目录与默认集合——S10 刻意的兼容取舍:首版已从 /cal/ 直接服务事件且既有客户端按该路径配置,故新建集合并排放在其下(/cal/<cal>/),而不是把事件搬进 /cal/default/。403 supported-filter,不解析相对 trigger。| 风险 | 等级 | 对策 |
|---|---|---|
| 验收断言不判别——「预期的东西回来了」被当成证据,忽略输入的实现同样能通过,于是缺陷可以穿过全部闸门 | 高 | 已发生(S10 两条缺陷)。对策:§11 的 R1–R7 并入 DoD;改能力声明必须同步「能力 → 证据矩阵」;选择类操作强制「空结果」与「拒绝路径」用例 |
| 手解析 HTTP 边界情况(keep-alive、多值头、Expect: 100-continue) | 中 | 明确 411/501 拒绝并写进边界;S7 联调日志驱动补齐;Radicale 哲学「够用即止」 |
| 真实客户端差异不可控(发现链/PROPFIND 形状各家不同) | 中 | S7 独立成步预留整天;优先 Thunderbird(最宽容、文档全),DAVx5 次之;问题→修复→复测记进互操作矩阵 |
| XML 命名空间(客户端前缀任意:D:/d:/默认 ns) | 中 | 按 (namespace-uri, local-name) 匹配而非前缀;测试带前缀变体 |
| RRULE 序号算术(BYSETPOS/负值/序数 BYDAY)正确性 | 中 | 隔离在 S8 独立成步;附录 A 全量对拍;graham/rrule 上游源码(GitHub)随时对照 |
| 九月场规则未确认(本地技能只有八月场章程) | 低 | 以八月场为底线准备(一页 MD 申报书/CI/mooncakes/≥5 提交);提交前官方渠道确认一次 |
| 规模不足(当前 962 行 vs 4–10k 参考) | 低 | S1–S8 累计估 ~2.6k 实现 + ~2.5k 测试 ≈ 5k+,落在区间;宁加用例不堆无关功能 |
八月场验收硬标准 → 里程碑映射(九月场以官方章程为准):
| 验收项 | 满足于 |
|---|---|
| MoonBit 为主语言 | 全程 |
| 可运行示例 | S1 起有 fixture demo,S5 起服务器可 moon run,S7 起真实客户端 demo |
| 核心路径测试 | S1–S6 各自带测试;S3/S8 附录 A 对拍;S6 curl 序列集成测试 |
| CI | S9(moon check/test --target all + fmt + info,参考 moonbit-community/.github 的 check.yml) |
| 发布 mooncakes.io | S9 已完成:justinwongcn/moon-ical@0.1.0 可由 moon search 检索与安装 |
| 默认分支 ≥5 真实提交 | S0–S9 每步一提交,自然 ≥10 |
| 申报书(一页 Markdown) | S9 定稿:定位 + 竞品矩阵切分(引调研文档 §02)+ 互操作矩阵 + 边界 |
| AI 辅助合规 | AI 生成内容可解释、可测试、来源明确;申报书如实声明 AI 参与 |
背景:0.2.0 发布、进入验收后被指出两条缺陷——「calendar-query 忽略 filter,关键语义缺失」与「MKCALENDAR 不建集合」。两条都属实,却在此前所有闸门全绿的情况下漏过:全目标测试、S5/S6 活体验收、Thunderbird 真实联调、96% 覆盖率,没有一项报警。本节记录根因,并把它固化成规则,避免再犯。
| # | 根因 | 当时的证据 |
|---|---|---|
| 1 | 断言不判别(最直接原因):用例的请求体里根本不含被检验的输入 | S6 的 calendar-query 请求体没有 <C:filter>,断言是 code == 207 and "UID:s6@moon-ical" in body——只要「把已知存在的事件返回」就通过,全量返回同样满足。该用例在「正确实现」与「完全不实现」两种情况下都是绿的。 |
| 2 | 能力声明先于证据 | README Features 与 §02 把 MKCALENDAR 列为已支持,而全仓(单测 + S5 + S6)零个 MKCALENDAR 用例。声明来自「路由里有个分支回 201」,不是来自任何被验证过的行为。 |
| 3 | 验收按「协议动作清单」组织,而非「语义契约」 | S6 的 11 项按 OPTIONS/PROPFIND/REPORT/PUT/DELETE 排列,每项只问「有没有回预期状态码」。一个回 207 的桩实现能通过状态码清单。 |
| 4 | 全为正例,没有否定例与前置条件例 | 11 项里没有任何一项形如「这个输入必须不返回那个」;403 的 valid-filter / supported-filter 两条前置条件从未被触发过一次。 |
| 5 | 客户端互操作被当成协议完整性 | S7 的 Thunderbird 全绿,是因为它只用单一内建集合、也不依赖服务端时间范围过滤——恰好绕开了这两条缺陷。互操作只证明「该客户端走的那几条路」,不等于协议面完整。 |
| 6 | 边界清单只有「有意不做」,没有「已声明但未验证」 | §08 列的是刻意的省略(tzdb、scheduling、TLS…)。两条缺陷不在其中,于是被隐式当成「已支持」;没有任何机制强制「文档能力 ↔ 证据」的映射。 |
| 7 | 「规范机械对拍」只在库层做了,协议层没做 | RRULE 层从第一天就用 graham 语料机械抽取 40 条串对拍,这条纪律确实有效(第一/二档全部对拍通过);CalDAV 层没有等价的「从 RFC 例子机械抽取请求体」,请求体全部手写,于是继承了实现者的盲区。 |
| # | 规则 | 判定方式 |
|---|---|---|
| R1 | 判别性断言:任何「接收输入并据此决定输出」的操作,验收必须包含一对只差该输入的请求,并断言两者结果不同 | calendar-query:同一集合上两个只有 time-range 不同的 query 必须返回不同的资源集合。反例(禁止):只断言「已知事件出现在响应里」。 |
| R2 | 选择 / 过滤类操作必须有「空结果」用例 | 断言返回空 multistatus,而不是「返回了全部」。区间外、无匹配属性都必须有此例。 |
| R3 | 可拒绝的操作必须有拒绝用例,并断言具体状态码 + 具体错误元素 | 如 403 + <C:valid-filter/>;不能只断言「不是 200」。每条新增的 403/405/409 路径都要有落点。 |
| R4 | 每条对外声明的能力必须有至少一个「因它缺失而失败」的检查 | 做不到就把它从 Features 移进 §08 边界。落地载体 = 下文的「能力 → 证据矩阵」:改能力声明必须同步改矩阵。 |
| R5 | 协议请求体从 RFC 例子机械抽取,与 RRULE 层的 graham 语料同纪律 | 手写请求体只允许作为补充;抽取脚本或逐条抄录都要在用例注释里留下出处(RFC 章节号)。 |
| R6 | 互操作报告必须写明「该客户端未触及的协议面」 | 互操作通过不得用作协议完整性的证据;矩阵要有一列记「未覆盖」。 |
| R7 | 里程碑验收标准必须写成可判否形式 | 「REPORT 序列全绿」不合格;「同一集合上两个只有 time-range 不同的 query 返回不同集合」合格。 |
规则 R4 的载体:凡在 Features / §02 出现的对外能力,都必须能在此表定位到判别性证据;定位不到的一律移入 §08「明确不做」。
| 能力 | 单元测试 | 活体验收 | 判别性证据(关键一条) |
|---|---|---|---|
| 折行展开 / 内容行 / 转义 | text 20 例 | — | 带引号参数还原、多字节折行往返 |
| 组件树(未知组件保留) | model 用例 | — | 未知组件仍出现在序列化输出里 |
IcalDateTime 四态 / ZoneTable | model 用例 | — | 四态各一条;未知 TZID 报错而非静默回落 UTC |
Event 类型化视图 | model 用例 | demo 实跑 | 畸形属性报错 vs 属性缺失为 None |
| RRULE 解析(三档) | rrule 33 例 | — | graham 40 串机械抽取:界内 32 解析 / 界外 8 显式报错 |
| RRULE 展开(第一 / 二档) | expand/tier2/corpus | — | 与 graham 数字逐条对拍(第一档 23 + 第二档 8) |
expand_series 覆盖合并 | series 用例 | — | EXDATE 移除 / 改期 / 取消 / THISANDFUTURE 各一条 |
| 序列化折行与引号 | serialize 11 例 | — | roundtrip 相等 + 幂等 + 75 octets 字节边界 |
| HTTP/1.1 请求层 | httpd 15 例 | S5 | chunked → 411、畸形 → 400、Content-Length 按字节;头行 64KB / 头数 100 封顶 → 400;非数字 Content-Length → 400 而非 413(S14) |
取件层 fetch_ics | fetch 1 例(native-only) | demo 人工 URL 模式 | 判别性证据 = 超时单测(本地「接受但永不响应」监听器 + timeout_seconds=1,断言恰为 FetchError::TimedOut——上游 async(0.21.2 与 0.22.4 均核实)客户端与 socket 层无任何超时,本包的界是唯一防线,S20 补);demo 对真实 .ics URL 打印解析后事件、非 200 抛 FetchError::BadStatus;外网测试不进 CI 是 S1.5 的既定决策(超时单测纯本地、进 CI) |
| vdir 存储 / ETag | store 用例 | S5 | 同字节同 tag、改一字节即变、非法名拒绝 |
| 集合创建 / 枚举 S10 | store + serve 用例 | S6 | 建两次第二次为 false;嵌套集合的事件不计入父集合 |
| 发现链 / well-known | caldav 用例 | S6 | 301 且 Location: /cal/ |
| PROPFIND Depth 0/1 | caldav 用例 | S6 | depth 0 不含事件、depth 1 含;S15 起 S6 活体含 Depth 0 与不可解析 Depth 的差分(桩 hardcode depth=1 会挂) |
| calendar-query filter 求值 S10 | filter 19 例 | S6 | 区间内选中 vs 区间外空结果(R1+R2);i;octet 大小写敏感 vs 默认折叠 |
| calendar-multiget | caldav 用例 | S6 | 只返回被请求的 href;未知 href 返回空 multistatus(S15,R2) |
| If-Match / If-None-Match | caldav 用例 | S6 | 陈旧 tag → 412(PUT 与 DELETE 双路径,S15);If-None-Match: * 重复建 → 412 |
| MKCALENDAR 建集合 S10 | store + serve 用例 | S6 | 建后 Depth 1 能发现它;重复建 → 405 |
| Thunderbird CRUD | — | S7 人工会话 | 仅默认集合路径;未覆盖 MKCALENDAR 与时间范围过滤(见 R6) |
<D:prop> 属性选择:已知被忽略(见 §08),无任何证据;PROPFIND / REPORT 都返回固定属性集。sync-collection / CTag:未支持,返回 400——有单测钉住「不支持」这一事实本身,但没有实现。displayname、calendar-color):不落盘(见 §08)。time-range:显式 403 supported-filter(有单测)。