首页
/ get-shit-done 3594:用对抗性测试夹具语料为 Frontmatter 与 ROADMAP 解析器钉住行为契约

get-shit-done 3594:用对抗性测试夹具语料为 Frontmatter 与 ROADMAP 解析器钉住行为契约

2026-09-04 18:30:38作者:卓艾滢Kingsley

本文基于变更集 .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 级别的补丁变更,核心交付有三块:

  1. 对抗性夹具语料目录:新增 tests/fixtures/adversarial/{frontmatter,roadmap}/,内容为"敌意但真实"(hostile-but-realistic)的输入——重复键、CRLF 行尾、未闭合块、Unicode、空字节、围栏代码内标题、小数阶段前缀碰撞、HTML 注释内标题;
  2. 行为测试tests/feat-3594-parser-adversarial-frontmatter.test.cjstests/feat-3594-parser-adversarial-roadmap.test.cjs 逐条消费这些夹具并断言不变式;
  3. 确定性种子属性测试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.cjssearchPhaseInContent / cmdRoadmapGetPhase / cmdRoadmapAnalyze(以及 SDK 侧解析器)。测试策略与 frontmatter 侧不同——不直接调内部函数,而是走公开 CLI 表面:每个测试用 tests/helpers.cjscreateTempProject 建临时项目,把夹具内容写入 .planning/ROADMAP.md,再执行 gsd-tools roadmap get-phase <N>,断言目标是 CLI 吐出的类型化 JSONfoundphase_numberphase_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.cjssearchPhaseInContent 的核心是:

const phasePattern = new RegExp(
  `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i'
);
const headerMatch = content.match(phasePattern);

两个事实直接解释了测试钉住的行为:

  • first-winscontent.match() 返回文档中首个匹配。repeated-phase-ids 夹具因此钉住"两次声明取第一次",测试注释写明"未来改成 last-wins 或去重都会触发该测试"。
  • 无上下文剥离:正则直接跑在原始内容上,不做围栏代码块剥离、不做 HTML 注释剥离——这正是 #2787(围栏内标题)与 HTML 注释假阳性两个"已知未决"的根因。而小数前缀碰撞(#3537)则靠阶段号的转义匹配语义保证:查 2.1 时正则要求 Phase 2.1: 后跟 [^\\n]+2.10: 的行在 2.1 查询下因后续字符不满足边界语义而不会误命中,故四条定向断言全绿。

cmdRoadmapGetPhaseroadmap.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.cjsrunCli 在临时项目目录内驱动真实 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 结构、配置文件),对抗性语料 + 确定性种子属性测试 + "钉住现状、修复即翻转"的三件套,能把解析器的每一个静默行为漂移都变成一次显式的测试失败,而不是线上数据事故。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
980
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384