get-shit-done 3594:用对抗性测试夹具语料为 Frontmatter 与 ROADMAP 解析器钉住行为契约
本文基于变更集 .changeset/3594-parser-adversarial-fixtures.md 展开。get-shit-done 是一套面向 Claude Code 的轻量级元提示(meta-prompting)、上下文工程与规格驱动开发系统,其大量工作流依赖对 .planning/ 下 Markdown 规划文件(frontmatter 头、ROADMAP.md 阶段章节)的正则解析。#3594 引入的对抗性(adversarial)夹具语料与配套测试,把这些"用户用多种工具手工编辑、注定会写脏"的文件作为攻击面,系统性钉住了 extractFrontmatter() 与 roadmap get-phase 两条解析链的行为契约。读完后,你将掌握:如何为文本解析器构建可扩展的恶意输入语料、如何用确定性种子属性测试替代"拍脑袋"用例,以及如何用"断言翻转即修复"的策略管理已知未决回归(#2787、#3537、HTML 注释假阳性)。
一、变更集原文说了什么:#3594 的完整交付面
该变更集是一个 "get-shit-done-cc": patch 级别的补丁变更,核心交付有三块:
- 对抗性夹具语料目录:新增
tests/fixtures/adversarial/{frontmatter,roadmap}/,内容为"敌意但真实"(hostile-but-realistic)的输入——重复键、CRLF 行尾、未闭合块、Unicode、空字节、围栏代码内标题、小数阶段前缀碰撞、HTML 注释内标题; - 行为测试:
tests/feat-3594-parser-adversarial-frontmatter.test.cjs与tests/feat-3594-parser-adversarial-roadmap.test.cjs逐条消费这些夹具并断言不变式; - 确定性种子属性测试:
tests/feat-3594-parser-property-style.test.cjs,每条断言覆盖 500 个生成的输入。
变更集还点明了关键工程策略:对已知仍未修复的回归(#2787 的 CJS 解析器围栏代码标题误匹配、HTML 注释标题假阳性),测试故意钉住"当前(错误)行为",这样将来真正的修复落地时,只需把一条断言从 found: true 翻转为 found: false 即可,无需重写测试。这一策略是本文的重点之一。
二、为什么要做对抗性解析测试:规划文件是"脏输入"
get-shit-done 的规划产物(计划文件 frontmatter、ROADMAP.md)由 Agent 生成、由人类在多种编辑器中反复修改。夹具 README(tests/fixtures/adversarial/frontmatter/README.md)点出了动机:这些输入形状"在真实世界中必然出现,因为用户会用多种工具编辑规划文件"。对解析器而言,脏输入的典型危害有三类:
- 静默数据丢失(如 CRLF 的
\r渗入字段值、空字节截断后续解析); - 半解析垃圾(如未闭合 frontmatter 块只吐出部分字段,正文内容泄漏进"frontmatter");
- 崩溃与性能退化(如巨型 frontmatter 触发 OOM 或 O(n²) 行为)。
因此整个语料的验收底线被概括为三句话:"不抛异常、不返回半解析垃圾、不静默丢数据"。
三、Frontmatter 夹具语料:六类敌意输入与钉住的不变式
tests/fixtures/adversarial/frontmatter/ 目录包含六个夹具,文件名即滥用类别,测试按相对路径加载,新增夹具不需要改动测试框架代码(只需在测试矩阵中登记并决定要钉哪条不变式)。逐类说明:
| 夹具 | 敌意形状 | 钉住的不变式 |
|---|---|---|
| duplicate-keys.md | 同一键出现两次(title: First / title: Second) |
每个键必须产生单个确定性胜者;当前契约是 last-wins(后出现覆盖先出现),测试显式断言 fm.title === 'Second',使"悄悄改成 first-wins"成为测试失败 |
crlf-mixed.md |
frontmatter 块内全程 CRLF 行尾 | 每个解析值必须不含 \r——对 JSON.stringify(fm) 整体跑 /\r/ 断言;数组项同样无 \r 泄漏 |
unclosed-block.md |
只有开 --- 没有闭 --- |
必须返回空对象 {} 而非部分解析。部分解析(返回 {title: 'Unclosed Block'})会被视为正文向 frontmatter 的静默泄漏 |
unicode-keys-and-values.md |
非 ASCII 键与值 | Unicode 值必须原样往返('日本語のタイトル'、emoji 状态 '🚧 in-flight'、希腊字母内联数组 ['α','β','γ']);同时钉住当前键正则只识别 ASCII 的事实(相: 键返回 undefined),把 ASCII-only 契约从"隐式依赖"变成"显式断言" |
null-byte-value.md |
值内含 U+0000 空字节 | 不得崩溃,不得在空字节处截断后续行:空字节行之后的 phase: 05 必须仍被解析,空字节值本身仍是字符串且保留字节前的内容 |
huge-bounded.md |
故意放大但有界的 frontmatter(约 64KB、2000 个数组项) | 必须在 2 秒内完成并返回正确形状:fm.plans.length === 2000、首尾项内容精确匹配。时间上界是"宽裕"的——解析器退化为 O(n²) 时会在该夹具上轻松击穿 2s |
除逐夹具断言外,tests/feat-3594-parser-adversarial-frontmatter.test.cjs 还有一段跨语料扫描:readdirSync 列出目录下所有 .md(排除 README),对每个文件断言 extractFrontmatter 返回普通对象——永不抛异常、永不返回 null、永不返回数组。这条"地毯式"断言的价值在于兜底:未来有人往语料里加了新夹具却忘了写逐文件测试,地毯扫描会立刻抓住。
四、源码级印证:extractFrontmatter 为何呈现这些行为
上述不变式并非凭空设定,而是精确对应 get-shit-done/bin/lib/frontmatter.cjs 的实现细节。逐条印证:
1. 字节 0 锚定与 CRLF 宽容。入口正则(frontmatter.cjs#L48)为:
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
if (!match) return frontmatter;
\r?\n 让 LF 与 CRLF 混排都能命中;^--- 锚定字节 0,文档正文中的 ---(YAML 示例、水平分割线)永远不会被误判为 frontmatter。注意两个"安全默认":匹配失败时返回的是预创建的空对象 frontmatter——这正是 unclosed-block 夹具钉住"空对象而非部分解析"的来源;而 CRLF 无泄漏则来自 yaml.split(/\r?\n/)(L52)按行拆分时 \r 被吸收进行尾,不进值。
2. last-wins 重复键契约。键值匹配使用 /^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/(L74)。每遇到一行 key: value,直接执行 current.obj[key] = ... 覆盖写——没有任何"键已存在"检查,因此第二次出现天然胜出。测试注释把这层实现事实上升为契约:"把它钉住,使向 first-wins 的变更可见"。
3. ASCII-only 键的显式钉住。同一正则中 ([a-zA-Z0-9_-]+) 只允许 ASCII 标识符,故 相: 之类的非 ASCII 键不会被捕获——unicode 夹具对此断言 fm['相'] === undefined,注释明确这是"回归护栏":将来若有意放宽键字符集,这是一次可见的契约变更,而非静默行为漂移。
4. 空字节不截断。解析按行迭代、逐行正则匹配,行内空字节只是该行的一个普通字符,不会中断 for (const line of lines) 循环——因此空字节行之后的 phase 键照常解析,与 null-byte 夹具的三条断言一一对应。
5. 空对象自动升格为数组。L100-L114 处理"键后第一个子行是 - item"的情形:先把 key: 建成空对象占位,遇到数组项时回溯父层、把该键改写为数组。这是 2000 项数组夹具能完整落进 fm.plans 的机制,也是性能上界能成立的原因——整个解析是单趟线性扫描。
五、Roadmap 夹具语料:六个敌意 ROADMAP.md 与 get-phase 表面
tests/fixtures/adversarial/roadmap/ 目录面向另一个解析面:roadmap.cjs 的 searchPhaseInContent / cmdRoadmapGetPhase / cmdRoadmapAnalyze(以及 SDK 侧解析器)。测试策略与 frontmatter 侧不同——不直接调内部函数,而是走公开 CLI 表面:每个测试用 tests/helpers.cjs 的 createTempProject 建临时项目,把夹具内容写入 .planning/ROADMAP.md,再执行 gsd-tools roadmap get-phase <N>,断言目标是 CLI 吐出的类型化 JSON(found、phase_number、phase_name),而非 stderr 散文。这正是 tests/feat-3594-parser-adversarial-roadmap.test.cjs 头注释所引用的仓库测试标准(CONTRIBUTING.md "Parser and project-file inputs" 一节)。
逐夹具对照:
| 夹具 | 敌意形状 | 钉住的行为 |
|---|---|---|
| phase-heading-inside-fenced-code.md | ```md 围栏块内含 ## Phase 999: 假标题,围栏外有真实的 Phase 1/2 |
查 1 必须命中真实阶段;查 999 时当前 CJS 解析器仍然命中围栏内的假标题——这是历史回归 #2787 的未决部分,见下文第七节 |
nested-fenced-code.md |
外层围栏里再嵌一层围栏 | 任一层内部的标题都必须被忽略(SDK 侧解析器跟踪围栏状态) |
unicode-phase-titles.md |
日文标题、emoji + 弯引号(Émile)、希腊字母标题 |
phase_name 必须原样往返三种 Unicode 形态 |
repeated-phase-ids.md |
同一整数阶段号声明两次 | 行为必须确定性;当前契约是 first-wins(正则 content.match() 只返回首个匹配),测试用 /first declaration/ 钉死 |
| decimal-phase-mixed.md | 整数阶段 2、小数 2.1、2.10 与整数 21 共存 | 查 2 不得返回 2.1/2.10,查 2.1 不得返回 2,查 2.10 不得被 2.1 截胡,查 21 不得命中 2——历史回归 #3537 的前缀碰撞护栏,四条断言逐一定向 |
markdown-headings-inside-html-comment.md |
<!-- ## Phase 999 --> 注释内标题 |
查 1 命中真实正文;查 999 时当前 CJS 解析器仍会匹配注释内标题(未决:需注释剥离) |
跨语料扫描同样存在:对语料中每个夹具,用 ['1','2','99','999','0','2.1'] 六个 ID 各跑一次 get-phase,断言 hasStackTrace === false——任何 V8 栈帧都不允许出现在输出里,即解析器对任意语料输入不得崩溃(found/not-found 两种退出码都合法,崩溃不合法)。
六、源码级印证:searchPhaseInContent 的正则面
get-shit-done/bin/lib/roadmap.cjs 中 searchPhaseInContent 的核心是:
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i'
);
const headerMatch = content.match(phasePattern);
两个事实直接解释了测试钉住的行为:
- first-wins:
content.match()返回文档中首个匹配。repeated-phase-ids夹具因此钉住"两次声明取第一次",测试注释写明"未来改成 last-wins 或去重都会触发该测试"。 - 无上下文剥离:正则直接跑在原始内容上,不做围栏代码块剥离、不做 HTML 注释剥离——这正是 #2787(围栏内标题)与 HTML 注释假阳性两个"已知未决"的根因。而小数前缀碰撞(#3537)则靠阶段号的转义匹配语义保证:查
2.1时正则要求Phase 2.1:后跟[^\\n]+,2.10:的行在2.1查询下因后续字符不满足边界语义而不会误命中,故四条定向断言全绿。
cmdRoadmapGetPhase(roadmap.cjs#L130)在此之上还叠加了 #3599 的两段式匹配:项目码前缀 ID(如 PROJ-42)先走"精确前缀"尝试,未命中再回退到 #3537 的容错数字形态——这说明小数/前缀碰撞的护栏在调用链上是分层设防的,而对抗性语料在 get-phase 表面同时压测了这两层。
七、核心策略:钉住"当前错误行为",让修复变成一行断言翻转
这是 #3594 变更集最值得复用的工程决策。roadmap 测试头注释(feat-3594-parser-adversarial-roadmap.test.cjs#L19-L24)明确写定了范围边界:"本 PR 不修复这些夹具暴露出的既有解析器 bug——修复不属于'添加对抗性测试覆盖'的范围。当某个夹具暴露未决 bug 时,测试断言当前观察到的行为,并用注释标注未决 issue 编号。"
以 #2787 为例,测试写法(L93-L114):
test('phase 999 inside a fenced block: CJS parser currently STILL matches it (open: needs fence-stripping)', (t) => {
// ...
assert.equal(result.parsed.found, true, 'CJS parser currently matches inside fences (known open bug)');
assert.match(result.parsed.phase_name, /fenced code block/i,
'currently-matched heading must be the one inside the fence');
});
断言故意与"理想行为"相反(found: true),并额外匹配围栏内标题的特征子串 /fenced code block/i——这样将来有人接入 CJS 围栏剥离后,翻转 found: true → found: false 的一行改动同时成为回归护栏:任何"修 A 坏 B"的修复(比如误删围栏导致别的阶段失踪)都会让邻近的正常路径测试变红。测试注释还点明对比事实:SDK 侧的 roadmap 解析器已经跟踪围栏块(见 sdk/src/query/roadmap.ts 中 #2787 相关注释),是 CJS 路径尚未跟上——变更集因此把修复目标精确定位为"CJS 对齐 SDK"。
八、确定性种子属性测试:500 个输入、零外部依赖
tests/feat-3594-parser-property-style.test.cjs 实现了 TEST-EXAMPLES.md 中 "Deterministic Property-Style Parser Test" 一节规定的仓库标准:若解析器接受任意用户文本,就添加一个有界的确定性循环;失败时打印种子或夹具名。实现要点:
1. mulberry32 PRNG,可复现性零依赖。测试内联实现了一个小 PRNG(L30-L39),同一种子跨 Node 版本产生同序序列——失败用例可以用"种子 + case 索引"精确复现,不依赖任何外部库。
2. 敌意片段池随机装配。makeInput 从 11 个片段中随机抽取 1~7 个、随机排序,片段池本身就是浓缩的恶意形态:无效 UTF-8 字节(\xff\xfe\xfd)、CRLF 混入值内、重复键、未闭合内联数组、空字节、缩进键、稀疏空行。闭合 --- 以 50% 概率出现,使"良构"与"未闭合"两种形状都被压测。
3. 双测试、双种子、双不变式:
test('extractFrontmatter is total over 500 deterministic random inputs (seed=1234)', () => { ... });
test('extractFrontmatter completes 500 cases under 2 seconds (no quadratic regression guard)', () => { ... });
- 第一个(seed=1234,500 例)断言全函数性质:解析器要么返回普通对象,要么抛出"受控"异常——异常消息不得匹配
/Cannot read propert/i(V8 空引用 TypeErrore 的散文),且每次断言失败都携带seed=... case=...与完整输入 JSON; - 第二个(seed=5678,500 例)断言性能性质:500 次解析总耗时 < 2s,注释写明这是"O(n²) 输入长度退化的护栏",且上界宽裕到能容忍 Node 版本间抖动。
4. Fisher-Yates 替换不可传递洗牌。值得单独一提的是 L69-L87 的注释:早期版本用 arr.sort(() => rng() - 0.5) 洗牌,其比较器不可传递,排序结果还依赖 V8 内部排序实现——失败用例因此跨 Node 版本不可复现。PR #3633 的评审(Codex review)要求换成 Fisher-Yates:O(n)、无比较器、顺序只依赖 RNG 输出。种子与 case 数量由测试各自钉死,"改动二者是刻意的测试变更,不是 flake 源"。
九、如何扩展语料与运行验证
语料设计目标是可增长:frontmatter README(tests/fixtures/adversarial/frontmatter/README.md)给出三步流程——把新夹具文件放进目录、在测试矩阵中登记、决定要钉哪条不变式(通常就是"不抛异常、不返回半解析垃圾、不静默丢数据"三选或组合)。roadmap 侧同理(tests/fixtures/adversarial/roadmap/README.md)。由于跨语料扫描是 readdirSync 驱动,新文件自动进入"不崩溃"地毯断言,漏写逐文件断言也会被兜底抓住。
运行方式:测试全部基于 node:test 内置运行器(require('node:test') + node:assert/strict),仓库统一入口为 scripts/run-tests.cjs,也可直接以 Node 执行单个文件,例如:
node --test tests/feat-3594-parser-adversarial-frontmatter.test.cjs
node --test tests/feat-3594-parser-adversarial-roadmap.test.cjs
node --test tests/feat-3594-parser-property-style.test.cjs
roadmap 测试通过 tests/helpers/cli-negative.cjs 的 runCli 在临时项目目录内驱动真实 CLI,因此它同时压测了"可用 SDK 桥时走 SDK、否则走 CJS handler"的路由层(测试头注释说明),而不只是纯函数。
十、小结:这套语料钉住了什么
把变更集、夹具 README 与三份测试交叉对照,#3594 实际建立了一张"解析器行为契约表":
| 解析面 | 契约项 | 契约来源 |
|---|---|---|
extractFrontmatter |
重复键 last-wins;值无 \r 泄漏;未闭合块 → {};非 ASCII 键不捕获(ASCII-only);空字节不截断后续行;64KB/2000 项 < 2s;任意语料输入返回普通对象 |
夹具 + 逐夹具断言 + 地毯扫描 |
extractFrontmatter |
500 种子输入上全函数;不传播空引用 TypeError;500 例 < 2s(O(n²) 护栏) | 种子 1234 / 5678 双属性测试 |
roadmap get-phase |
首匹配胜出;小数/整数前缀不互相截胡(#3537);Unicode 标题原样往返;任意 ID 查询无栈帧崩溃 | 夹具 + CLI 表面 JSON 断言 |
| 未决回归(#2787、HTML 注释) | CJS 路径当前会命中围栏/注释内标题——反向断言钉死,修复日 = 一行断言翻转 | 带 open-bug 注释的反向断言 |
这套方法论的可迁移性在于:对任何"正则解析用户可编辑文本"的系统(frontmatter、Markdown 结构、配置文件),对抗性语料 + 确定性种子属性测试 + "钉住现状、修复即翻转"的三件套,能把解析器的每一个静默行为漂移都变成一次显式的测试失败,而不是线上数据事故。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00