moon-ical · 内部开发手册

开发手册:iCal 解析库 + CalDAV 服务器

本手册是项目的单一事实来源(single source of truth):定位、进度、架构、里程碑、工程约定都在这里。每完成一个里程碑提交时同步更新 §02 快照与 §04 状态。

更新日期 2026-10-13 进度 S0–S10 与 R1–R12 全部完成(0.2.0 已发布,0.3.0 开发中) 工具链 moon 0.1.20261023 / moonc v0.10.17(rr_moon_mod + rr_moon_pkg;S10 后源码已按新 formatter 迁移 Int→Int32,S10.5 起 .mbti 也直接渲染 Int32) 依赖 moonbitlang/async@0.22.4 · moonbitlang/x@0.5.5 状态 moon check ✓ · moon test 73/73 ✓

01定位与证据链

一句话定位:纯 MoonBit 的 iCalendar(RFC 5545)解析 / 重复事件展开库,长成 mooncakes.io 生态第一个 CalDAV(RFC 4791)日历服务器——把竞品停在「中段计算层」的流水线,接到「真实日历客户端可直连」的服务端。

立项依据(申报书可直接引用,全部 2026-09-08 核实):

差异化旗帜(相对五竞品):① 端到端——从网络取件到客户端可连,而非纯计算库;② 真实数据——RFC 5545 附录 A 对拍 + 真实客户端联调矩阵,五家全部只用自造 fixture;③ RECURRENCE-ID 单实例改期合并——五家共同空白(调研矩阵 ④b 行)。

02进度快照

包内容实现行数测试状态
ical/text折行展开(§3.1)、内容行解析(引号参数)、文本反转义、带行号 ParseError30920 用例已落地
ical/model组件树(BEGIN/END 嵌套、未知组件保留)、IcalDateTime(四态时间,Eq)、ZoneTable(固定偏移 + feed 内嵌 VTIMEZONE)、Event 类型化视图(S1:parse_events / Component::events,RRULE 原文、EXDATE、RECURRENCE-ID)84653 用例已落地
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 参数端到端~601 用例(本地挂起监听器的超时单测)已落地
ical/rrule完整日历级 RRULE 展开(S2/S3/S8):四档 FREQ × INTERVAL/COUNT/UNTIL、序数 BYDAY、正负 BYMONTHDAY、BYMONTH、BYSETPOS、WKST;expand_series 合并 EXDATE、RECURRENCE-ID 改期/取消/THISANDFUTURE,ZoneTable 解析结果贯穿展开约 120031 用例(graham 第一档 23 条 + 第二档 8 条 + 合并/时区)已落地
ical/serialize组件树 → .ics 文本(S4):CRLF、75-octets 折行(多字节不劈开)、参数值按需重引号;@text.escape_value 与 unescape 对称;roundtrip 组件树相等且幂等17211 用例(roundtrip/折叠/引号/空值)已落地
ical/storevdir 存储(S5/S10):每事件一个 .ics 文件,每个日历集合一个子目录;FNV-1a 内容寻址 ETag(同字节同 tag);名字白名单(字母数字._- 且非点开头)防路径逃逸;get 缺失是 None 不是错;集合能力 = collections/create_collection/collection/remove_collection,list 只返回事件3126 用例(ETag/拒绝名/磁盘往返/集合生命周期)已落地
ical/httpdHTTP/1.1 请求层(S5):手解析请求行/头/Content-Length 体,方法是 token(WebDAV 方法天然可读);chunked 拒收(411);裸 socket 读写(ServerConnection 是帧化 reader 不适用);S6 在此之上加 WebDAV/CalDAV 语义32810 用例(MemoryReader 驱动)请求层已落地
ical/caldavWebDAV/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 状态137426 用例 + 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 联调。

03架构与数据流

请求自上而下流经六层,每层一个包、单向依赖;层与层之间只经公开接口(.mbti)通信:

cal clientThunderbird / DAVx5 / Apple 日历 / curl
→
ical/httpdHTTP/1.1 请求层:accept + 手解析请求行/头/体;响应复用 @async 的 send_response
S5–S6
→
WebDAV/CalDAVPROPFIND / REPORT / MKCALENDAR / ETag;XML 用 Milky2018/xml
S6
→
ical/storevdir:每事件一个 .ics 文件,目录即日历集合
S5
ical/serialize · serialize组件树 → 折行 .ics 文本
S4
←
ical/model · Component / IcalDateTime / ZoneTable组件树 + 四态时间 + 偏移表
已落地
←
ical/text · unfold / parse_content_line物理行 → 逻辑行 → 内容行
已落地

已落地包的公开接口(摘自 pkg.generated.mbti)

ical/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
设计立场:保真优先——未知组件/属性一律保留(竞品 mooncal 是固定字段清单);时间四态显式建模;解析不了的输入带行号报错而不是静默吞掉。这三条是后续所有包必须继承的基线。

04里程碑阶梯 S0–S10

哲学:每步一个最小目标 → 代码 + 测试 → 验收 → 一次提交。单步超过半天就拆。任何一步卡住,先砍范围再加班。

#目标规模验收(过不了的验收不算完成)状态
S0清场:删模板/探针,README/moon.mod 就位,登记接缝0 行moon check && moon test 全绿,工作区干净完成 0ab8978
S1类型化事件层:Event 结构 + Component::events()(映射层,无新算法)~150demo 读本地 fixture .ics 打印事件清单完成(S1)
S1.5取件切片:ical/fetch(@http.get 薄层,非 200 抛 FetchError)+ demo 支持 URL 输入:URL → 事件清单 端到端。网络测试不进 CI(Google 源本网络不可达,演示用 calendarlabs)~130给真实可达 .ics URL,打印解析后事件清单;CI 仍走本地 fixture完成(S1.5)
S2RRULE 解析(不展开):第一档子句 → Rule;不支持子句显式报错~200graham 32 个用例的 rule 串全部按档位解析或报错完成(S2)
S3第一档展开:DAILY/WEEKLY/MONTHLY + INTERVAL/COUNT/UNTIL/BYDAY(无offset)/BYMONTHDAY(正) + DTSTART 回退特例~400RFC 附录 A 第一档 ~20 用例对拍全绿(语料:graham/rrule 测试套件,见 THIRD-PARTY-NOTICES.md)完成(S3)
S4iCalendar 序列化写回:fold(75字符)/escape/组件树→ics(CalDAV 响应体就是它,故提前)~200parse → serialize → parse 往返不变完成(S4)
S5HTTP/1.1 请求层 + vdir 存储:accept + 手解析请求行/头/Content-Length 体;OPTIONS/GET/PUT/DELETE~350curl 完成事件 PUT/GET/DELETE 往返,ETag 正确变化;chunked 请求回 411完成(S5)
S6WebDAV/CalDAV 核心:PROPFIND(Depth 0/1)、REPORT(calendar-query/multiget)、MKCALENDAR、well-known 重定向、发现链、If-Match/If-None-Match~450curl 模拟 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 门面、互操作矩阵进 README0.5 天CI 绿、moon publish 成功、申报书一页完成(0.1.0 已发布,scope frozen)
R1系列合并性能:一次索引同 UID overrides/EXDATE,按序游标应用 THISANDFUTURE~1002000 occurrences + 500 无关 overrides 的 native release 基准由 20.27 ms 降至 1.64 ms;语义回归与全目标测试通过完成(约 12.4×)
R2CalDAV REPORT 热路径:StringView 零拷贝扫描 + href 哈希选择~100500 hrefs × 500 resources 的 native release 基准由 68.17 ms 降至 3.87 ms;命名空间前缀回归与全目标测试通过完成(约 17.6×)
R30.2 根门面收口:新增整文档 parse_calendar,顶层只暴露完整 parse / recurrence / serialize 工作流~50pkg.generated.mbti 明确移除协议与文本层拼装 API;子包高级入口保留;门面往返测试通过完成(待发布)
R4通用 ICS 解析链 StringView 化:unfold、内容行、参数和 EXDATE 列表仅在所有权边界分配~1201000 VEVENT 的 native release 基准由 25.83 ms 降至 15.10 ms;既有 Unicode 折行与全目标回归通过完成(约 1.71×)
R5RRULE 预归一化:排序 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 保持不变~1100S6 活体验收由 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~+300S6 活体 +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 决策)~+70S6 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 可靠送达)~+40S5 活体 +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(双事件资源假阳性)~+45filter 差分测试(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 全部带斜杠,存量客户端不受影响~+25serve 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~+80K3 差分(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完成

05每步工作流(Definition of Done)

每个里程碑严格走同一循环,禁止「先写一大坨再补测试」:

  1. 写测试先行或同步:每块新行为至少一个 assert_eq / assert_true(pattern is …) 级别的断言测试;边界(空输入、畸形输入、行号)必须覆盖。
  2. 实现:块式组织,///| 分隔,每块带一句话文档注释说明「为什么」。
  3. 验证链:moon fmt && moon check && moon test && moon info,然后检查 .mbti diff——接口变化必须是预期内的,意外的 diff 说明改坏了边界。
  4. 提交:一次提交一个里程碑,消息格式 S<n>: <一句话>,正文列改动点 + 验收证据(测试数、对拍结果)。命令必须是 git -c core.hooksPath=.githooks commit …——项目钩子会跑 moon check 并拒绝「改源码但不更新 PROGRESS.md」的提交。
  5. 更新跟进文档:docs/PROGRESS.md(跨会话状态源:本地文件,不入库——§1 快照、§2 里程碑、§3 会话日志)与本手册 §02/§04/§07。钩子会校验它在上次提交后被更新过。
规模红线:单步新增代码超过计划规模 1.5 倍(如 S1 超过 ~220 行)即视为范围蔓延——停下来检查是不是把下一步的活提前做了,砍回去。
验收有效性红线(S10 复盘,见 §11):本循环第 1 条「至少一个断言测试」不足以构成验收。凡「接收输入并据此决定输出」的操作,必须额外满足 §11 的 R1–R7——尤其 R1(一对只差输入的请求必须给出不同结果) 与 R2(选择类操作必须有空结果用例)。只断言「预期的东西回来了」的用例不算证据:全量返回同样能通过它,S10 的两条缺陷就是这么漏过全部闸门的。

06MoonBit 工程约定与踩坑清单

新格式(rr_moon_mod + rr_moon_pkg)下的硬规则,违反即编译失败或静默 bug:

07上游接缝登记(活文档)

原则:业务代码永不 import 兜底实现,只 import trait;每个临时实现标注 UPSTREAM-GAP;探针转绿 = 上游已补齐 = 立即执行切换配方。完整表格在 docs/upstream-seams.md,此处摘要:

#能力现状(2026-09-08)本项目策略
S1js 目标本地文件@async/fs native 可用,js 空壳CalDAV 服务主目标 native,js 支持延后
S2IANA tzdb / DST 历史mooncakes 无 tzdb 包(x/time 仅 fixed_zone)ZoneTable:固定偏移 + feed 内嵌 VTIMEZONE;边界写进 README
S3HTTP 抓取@async/http client 双目标可用(M0 实测)保留 Fetcher 抽象,S9 可选支线
S4async 运行时稳定性README 自述 experimentalAPI 触点收敛在 httpd/store 两处
S5系统本地时区需自行 FFI暂不需要(服务用 UTC + 显式偏移)
S6WebDAV 方法承载RequestMethod 封闭枚举(types.mbt:16),无 PROPFIND/REPORT/MKCALENDARS5 手解析请求行(方法只是 token);上游扩展后一行切回 read_request

S5/S6 用到的上游 API(已核实)

// 响应侧(直接用,无障碍):
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/调试输出用)

08明确不做(边界)

09风险与对策

风险等级对策
验收断言不判别——「预期的东西回来了」被当成证据,忽略输入的实现同样能通过,于是缺陷可以穿过全部闸门高已发生(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+,落在区间;宁加用例不堆无关功能

10提交与验收映射

八月场验收硬标准 → 里程碑映射(九月场以官方章程为准):

验收项满足于
MoonBit 为主语言全程
可运行示例S1 起有 fixture demo,S5 起服务器可 moon run,S7 起真实客户端 demo
核心路径测试S1–S6 各自带测试;S3/S8 附录 A 对拍;S6 curl 序列集成测试
CIS9(moon check/test --target all + fmt + info,参考 moonbit-community/.github 的 check.yml)
发布 mooncakes.ioS9 已完成:justinwongcn/moon-ical@0.1.0 可由 moon search 检索与安装
默认分支 ≥5 真实提交S0–S9 每步一提交,自然 ≥10
申报书(一页 Markdown)S9 定稿:定位 + 竞品矩阵切分(引调研文档 §02)+ 互操作矩阵 + 边界
AI 辅助合规AI 生成内容可解释、可测试、来源明确;申报书如实声明 AI 参与

11验收有效性:为什么缺陷能过验收(S10 复盘)

背景: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 例子机械抽取请求体」,请求体全部手写,于是继承了实现者的盲区。

硬性规则(并入 §05 的 Definition of Done)

#规则判定方式
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 四态 / ZoneTablemodel 用例—四态各一条;未知 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 例S5chunked → 411、畸形 → 400、Content-Length 按字节;头行 64KB / 头数 100 封顶 → 400;非数字 Content-Length → 400 而非 413(S14)
取件层 fetch_icsfetch 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 存储 / ETagstore 用例S5同字节同 tag、改一字节即变、非法名拒绝
集合创建 / 枚举 S10store + serve 用例S6建两次第二次为 false;嵌套集合的事件不计入父集合
发现链 / well-knowncaldav 用例S6301 且 Location: /cal/
PROPFIND Depth 0/1caldav 用例S6depth 0 不含事件、depth 1 含;S15 起 S6 活体含 Depth 0 与不可解析 Depth 的差分(桩 hardcode depth=1 会挂)
calendar-query filter 求值 S10filter 19 例S6区间内选中 vs 区间外空结果(R1+R2);i;octet 大小写敏感 vs 默认折叠
calendar-multigetcaldav 用例S6只返回被请求的 href;未知 href 返回空 multistatus(S15,R2)
If-Match / If-None-Matchcaldav 用例S6陈旧 tag → 412(PUT 与 DELETE 双路径,S15);If-None-Match: * 重复建 → 412
MKCALENDAR 建集合 S10store + serve 用例S6建后 Depth 1 能发现它;重复建 → 405
Thunderbird CRUD—S7 人工会话仅默认集合路径;未覆盖 MKCALENDAR 与时间范围过滤(见 R6)

当前已知的未验证 / 未支持面(诚实清单)

每次改能力声明时问三句:①这条能力在「能力 → 证据矩阵」里定位得到判别性证据吗?②证据里有没有一对只差输入的请求?③新增的拒绝路径(4xx)有没有对应的拒绝用例?三句有任何一句答不上来,就先补证据再改声明。