文档与代码同步演进:get-shit-done 的 `lint:docs` 文档强制校验机制(PR 3213)深度解析
本技术指南围绕 get-shit-done(GSD)仓库在 PR #3213 中引入的 lint:docs 文档强制校验机制展开,系统讲解它如何在 CI 层强制任何新增、变更、废弃或移除用户可见行为的 PR 必须同步修改 docs/ 下的文档。读者将掌握该 lint 的判定规则、两类带审计痕迹的豁免通道(no-docs 标签与 docs-exempt 片段标记)、坏 frontmatter 的 fail-closed 兜底策略,以及如何在本仓库中本地运行与验证该机制。
一、机制背景:为什么一个 meta 提示工具需要"文档强制"门禁
get-shit-done 是一个面向 Claude Code 等 AI 编程助手的轻量级 meta-prompting 与规格驱动开发(spec-driven development)系统。这类项目以大量文档(docs/ 下的用户指南、命令参考、配置说明、ADR 架构决策记录)作为运行时知识的核心载体——Agent 通过读取这些文档来理解命令契约、工作流与配置项。因此,文档一旦与代码脱节,下游所有消费方(开发者、Agent、LLM)都会被误导。
项目采用 changeset 机制管理变更记录:每个有用户可见影响的 PR 在 .changeset/ 下放置一个 <随机词>.md 碎片,发版时再聚合进 CHANGELOG.md。该机制解决了"多个 PR 同时编辑同一段 CHANGELOG 导致合并冲突"的痛点(详见 .changeset/README.md)。但它在设计上只约束变更是否被记录,不约束文档是否被同步更新——于是代码悄悄改、文档停留在旧描述的现象就会反复出现。
PR #3213 正是为此而生:新增独立的 lint:docs 门禁,与既有的 changeset 门禁互为补充。前者(scripts/changeset/lint.cjs)只检查 PR 是否携带碎片,后者则接管"带了哪些类型碎片就必须同步改文档"的职责。这一设计也写入了 CONTRIBUTING.md 供贡献者查阅。
二、核心判定规则:哪些碎片类型会触发文档要求
lint 的规则相当克制且经过精心裁剪。核心常量定义在 scripts/lint-docs-required.cjs:
const LINT_REASON = Object.freeze({
OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments',
OK_DOCS_UPDATED: 'ok_docs_updated',
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
OK_FRAGMENTS_EXEMPT: 'ok_fragments_exempt',
FAIL_DOCS_MISSING: 'fail_docs_missing',
FAIL_MALFORMED_FRAGMENT: 'fail_malformed_fragment',
});
// Fragment types that require a docs update.
const TRIGGERING_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed']);
其判定逻辑可归纳为以下要点:
- 被门禁的类型:
Added、Changed、Deprecated、Removed。这四类都意味着用户可见行为的增减或改变,理应伴随文档更新。 - 不被门禁的类型:
Fixed与Security。两者的语义是"修复回归"或"修复漏洞"——它们恢复的是文档已承诺的行为,而非引入需要新写文档的行为(安全修复若因保密需要不便透露细节,也无需强制留档)。CONTRIBUTING 同时提醒:若修复本身纠正了文档中的错误,仍建议顺手改文档。 - 文档路径判定:
docs/前缀下的任意文件(包括docs/adr/、docs/agents/等嵌套子目录)都算数,由isDocsFile辅助函数通过file.startsWith('docs/')判断(见 tests/lint-docs-required.test.cjs)。注意:根目录的README.md、CONTRIBUTING.md等不算docs/文档。 - 碎片路径判定:
isFragmentPath只匹配.changeset/<slug>.md,且显式排除.changeset/README.md与嵌套子目录。
lint 的入口是一个纯函数 evaluateLint,签名与职责在源码注释中有完整说明(scripts/lint-docs-required.cjs):
function evaluateLint({ changedFiles, fragments, labels, malformed = [] }) {
if (malformed.length > 0) {
return { ok: false, reason: LINT_REASON.FAIL_MALFORMED_FRAGMENT, ... };
}
const triggering = fragments.filter((f) => TRIGGERING_TYPES.has(f.type));
if (triggering.length === 0) {
return { ok: true, reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS, ... };
}
// 1. 所有触发碎片都带 docs-exempt 标记 → OK_FRAGMENTS_EXEMPT(部分豁免失败)
if (triggering.every(isExemptFragment)) {
return { ok: true, reason: LINT_REASON.OK_FRAGMENTS_EXEMPT, ... };
}
// 2. PR 带 no-docs 标签 → OK_OPT_OUT_LABEL
if (labels.includes(OPT_OUT_LABEL)) {
return { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL, ... };
}
// 3. PR diff 中确实出现了 docs/ 文件 → OK_DOCS_UPDATED
if (changedFiles.some(isDocsFile)) {
return { ok: true, reason: LINT_REASON.OK_DOCS_UPDATED, ... };
}
// 4. 兜底:以上全不满足 → 失败
return { ok: false, reason: LINT_REASON.FAIL_DOCS_MISSING, ... };
}
evaluateLint 不做任何文件系统或 git 操作,因此可以像测试那样针对 changedFiles、fragments、labels、malformed 四类输入穷举判定路径。这种"纯函数核心 + 薄 CLI 外壳"的结构(与 lint:changeset 保持一致)让测试能够断言结构化 verdict 而非自由文本。
三、本地运行与 CLI 行为
仓库在 package.json 中注册了脚本命令:
npm run lint:docs
# 等价于 node scripts/lint-docs-required.cjs
其运行流程如下:
- 收集 PR 标签:读取
GITHUB_EVENT_PATH环境变量指向的事件 JSON,提取pull_request.labels中的标签名。 - 计算变更文件集:通过
git diff --name-only origin/${base}...HEAD获取本分支相对基础分支(默认main,可由GITHUB_BASE_REF覆盖)的改动文件。CLI 使用execFileSync并以参数数组传值、不经 shell,且 git 自身的引用名校验会拦截元字符,杜绝了恶意GITHUB_BASE_REF注入 shell 语法的可能(源码注释对此有明确说明,scripts/lint-docs-required.cjs)。 - 解析碎片:对 diff 中每个
.changeset/*.md调用parseFragment,产出{ fragments, malformed }两组数据(已被删除的碎片自动跳过)。 - 输出 verdict:
- 通过时打印
ok docs-lint: <reason>并exit 0; - 文档缺失时在 stderr 列出所有触发碎片及其
type,并提示三种出路(改docs/、加no-docs标签、或写docs-exempt标记)后exit 1; - 存在解析失败的碎片时,打印具体失败原因与 detail,
exit 1。
- 通过时打印
CI 亦支持 --json 参数输出结构化诊断(verdict、changedFiles、fragments、malformed、labels),便于调试或接入其他工具。
四、CI 工作流接线
该 lint 以独立 workflow 运行,配置文件位于 .github/workflows/docs-required.yml。工作流在 PR 的 opened / synchronize / reopened / labeled / unlabeled 事件时触发——监听 labeled/unlabeled 的用意很明确:提交者可以在 CI 失败后补打 no-docs 标签,从而无需重新 push 即可重新触发校验。
name: Docs Required
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
jobs:
docs-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Run docs-required lint
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: node scripts/lint-docs-required.cjs
两个值得注意的工程细节:fetch-depth: 0 保证能拿到完整历史以执行 origin/${base}...HEAD 三点比较;GITHUB_BASE_REF 显式注入目标分支名,避免硬编码 main 在分支命名不同的 PR 场景下失效。
五、两条带"审计痕迹"的豁免通道
规则只约束"确有必要"的场景。对于确实没有用户可见文档影响的改动(基础设施重写、纯内部重构、仅测试变更、CI 调整),CONTRIBUTING 提供了两条通道——二者的共同底线是必须留下纸面审计痕迹(paper trail):
5.1 全局标签:no-docs
给 PR 打上 no-docs 标签即可整体放行(对应 verdict OK_OPT_OUT_LABEL)。实现上读取 GitHub event JSON 中的标签列表,命中即生效(scripts/lint-docs-required.cjs)。规范同时要求提交者在 PR 里留言解释为何无需文档。标签是 PR 级别的全局豁免,适合整份 PR 都不涉及用户可见行为的场景。
5.2 每碎片标记:<!-- docs-exempt: <reason> -->
当一份 PR 混合了"需文档的改动"与"无需文档的内部改动"时,逐碎片豁免比全局标签更精准。语法是在触发碎片正文末尾单独一行放置:
<!-- docs-exempt: 一句话说明为何该碎片不需要文档 -->
标记的解析在 scripts/changeset/parse.cjs 的 extractDocsExempt seam 完成,使用如下正则:
const DOCS_EXEMPT_RE = /^[ \t]*<!--[ \t]*docs-exempt[ \t]*:[ \t]*(\S[^\r\n>]*?)[ \t]*-->[ \t]*\r?$/im;
解析后的结果以结构化字段 fragment.docsExempt(reason 字符串或 null)暴露给 lint(isExemptFragment 据此判断),同时把标记行从正文中剥除、规整空白(CRLF 感知),从而保证标记永远不会泄漏进 CHANGELOG.md 或 GitHub release notes 的渲染输出——发版序列化器会向正文末行追加 (#PR号) 后缀,若标记残留就会污染发布文案。相应的防护测试在 tests/lint-docs-required.test.cjs 中验证了"标记进入类型化字段、并从 body 中消失"。
该正则与语义有几个刻意设计的安全边界,值得逐条理解:
- 必须是独立成行:正则锚定
^...$(多行模式),行内/段落内(例如反引号包裹的语法示例)提及该标记不会被误判为豁免; - reason 必填且非空:
\S锚点强制 reason 首字符为非空白。裸写<!-- docs-exempt -->或<!-- docs-exempt: -->(空 reason)会被拒绝——没有 reason 就没有审计价值; - CRLF 兼容:末尾
\r?吞掉 CRLF 换行的\r,避免 Windows 作者的碎片留下残差导致(#NNNN)后缀被挤到空行; - 线性时间安全:reason 字符类
[^\r\n>]有界,不会在对抗性输入上发生灾难性回溯。
5.3 两种豁免的裁决顺序与"部分豁免失败"原则
从 evaluateLint 源码可见裁决顺序是:先查每碎片标记(全部命中才放行),再查 no-docs 标签,最后才看是否真的改了 docs/。其中蕴含一条严格原则——部分豁免失败:只要存在一个未带标记的触发碎片,即使其他碎片都标记了 docs-exempt,整份 PR 仍判失败(对应测试 tests/lint-docs-required.test.cjs)。混有 Fixed(不触发)+ Added(触发)的 PR 同理,只有 Added 会被列入触发清单要求文档。
六、fail-closed:坏 frontmatter 无法绕过文档门禁
这是 #3213 在 Codex 协助开发过程中补上的重要安全语义。lint:changeset 只检查碎片的存在性而非有效性,因此若一个 type 应为 Added 的碎片因 frontmatter 错误(缺 pr、缺 type、type 非法)而无法被解析,它可能被静默跳过——恶意或粗心者借此即可绕过文档要求。
docs lint 主动接管了这一责任:任何出现在 diff 中却无法通过 parseFragment 的碎片都会进入 malformed 列表,而 evaluateLint 对 malformed.length > 0 优先且无条件地判失败,产出 verdict FAIL_MALFORMED_FRAGMENT。这一检查甚至先于 no-docs 标签的判定——测试明确断言"no-docs 标签无法绕过 FAIL_MALFORMED_FRAGMENT"(tests/lint-docs-required.test.cjs),即标签只能豁免"文档缺失",不能豁免"碎片本身写坏了"。此时 CLI 会逐条打印每个失败碎片及其原因(missing_frontmatter、invalid_type、missing_pr、invalid_pr、empty_body 等,枚举见 scripts/changeset/parse.cjs)。
tests 目录中还专门收录了一条回归测试:Added 碎片只写 type 不写 pr 时,从磁盘解析即被判为 missing_pr,再送入 evaluateLint 得到 fail-closed 结果(tests/lint-docs-required.test.cjs)。
七、"该改哪份文档"矩阵与英文规范语言策略
为了让"改文档"落得更实而非敷衍了事,CONTRIBUTING 新增了 Documentation Updates 一节,用一张矩阵明确改动类型 → 应更新的文档:
| 改动类型 | 必须更新的文档 |
|---|---|
| 新命令或新 flag | docs/COMMANDS.md、docs/FEATURES.md |
| 命令行为或输出变更 | docs/USER-GUIDE.md、docs/COMMANDS.md |
| 配置 / schema 变更 | docs/CONFIGURATION.md |
| 架构性变更 | docs/ARCHITECTURE.md、docs/adr/ |
| Agent 或 skill 变更 | docs/AGENTS.md |
| 移除命令 / flag / 工作流 | 所有引用过它的文档 |
配套还有英文规范语言策略:docs/ 与根 README.md 的内容必须使用英文书写,英文版本是唯一规范来源(canonical source);而 README.pt-BR.md、README.zh-CN.md、README.ja-JP.md、README.ko-KR.md 属于社区维护的翻译版本,不要求每份 PR 同步更新。
这份矩阵同时被固化进了 PR 模板:feature.md 与 enhancement.md 都新增了 Documentation 章节(含 Checklist 与到 CONTRIBUTING 对应章节的链接),其中 enhancement.md 明确给出"行为/输出变更 → docs/USER-GUIDE.md 与/或 docs/COMMANDS.md;配置/schema 变更 → docs/CONFIGURATION.md……"的映射指引(.github/PULL_REQUEST_TEMPLATE/enhancement.md)。由于 PR 模板通过 query 参数按类型分发(fix / enhancement / feature),模板正文中点名了 CI 强制逻辑,贡献者在提交流程中就完成了一次自我检查。
八、测试矩阵:verdict 的穷举验证
.changeset/steady-zebras-click.md 中记录的本碎片本身还是"豁免机制自举"的活例子:它 type 为 Added、属于触发类型,却因本质是"文档门禁自身的引导(bootstrap)"而在末尾携带了 docs-exempt 注释——文档层责任落在 CONTRIBUTING.md 与 PR 模板,而 docs/ 保留给面向最终用户的文档。
整套机制的可靠性由 tests/lint-docs-required.test.cjs 提供穷举级保障,测试全部基于 Node 内置的 node:test 运行器(符合项目 Testing Standards,见 CONTRIBUTING.md),大致覆盖五组场景:
- LINT_REASON 枚举与常量完整性:断言六个 verdict 码与四个触发类型与文档一致;
- 通过路径:无碎片、仅
Fixed/Security碎片、Added碎片伴随docs/变更、docs/adr/等嵌套文档路径同样有效; - 失败路径:四类触发类型各自无文档变更即失败;混合
Fixed + Added时只有Added触发; - 豁免语义:非空 reason 才豁免、空串/纯空白 reason 不豁免(防御 CodeRabbit 发现的
docsExempt: ''构造漏洞)、部分豁免失败、标签放行; - fail-closed:畸形碎片优先于一切判失败、
no-docs标签无法绕过、缺省malformed参数向后兼容。
测试统一断言结构化 verdict({ ok, reason, triggering, malformed })而非错误文案,这使得判例即使将来改写提示语也不会破坏测试。
九、在工程实践中运行与落地
如果你是 get-shit-done 仓库的维护者或希望借鉴此模式的贡献者,可以这样使用该机制:
- 作为提交者:给改动配齐 changeset 碎片(
npm run changeset -- --type <Added|Changed|Deprecated|Removed> --pr <PR号> --body "...",详见 .changeset/README.md),同时按第七节的矩阵更新对应docs/文档;若确属内部改动,二选一打no-docs标签或写docs-exempt标记并附理由。 - 本地预检:push 前运行
npm run lint:docs(在未设置GITHUB_EVENT_PATH时标签为空数组),用--json查看结构化 verdict 辅助排错。 - 接入自有 CI:直接复用 .github/workflows/docs-required.yml 的接线方式——checkout 需带
fetch-depth: 0,并将GITHUB_BASE_REF注入为${{ github.base_ref }};别忘了监听labeled/unlabeled事件让标签豁免即时生效。 - 作为设计参考:其"纯函数 verdict + 薄 CLI"的架构、三通道兜底(逐碎片标记 → 标签 → 改文档)、以及"lint 只检查存在性时自行为消费的碎片兜底有效性"的 fail-closed 思路,均可平移到任意基于碎片化变更记录(changeset/conventional commits)的仓库。
总之,lint:docs 与既有的 Changeset Required 门禁构成了一道"变更必记录、记录必落档"的双层防线,让 get-shit-done 这类以文档驱动 Agent 行为的项目始终能保证:文档不是滞后于代码的备忘录,而是与每次用户可见变更同步交付的交付物。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00