文档守卫(doc_guard)
一句话
守同一类 bug:文档对事实做了强声明,而事实不是那样,且人眼一次都没抓到。
这类 bug 的特征是「看着挺合理」——所以靠审查审不出来,只能靠机器。工具包 fengshuiqutan
在 2026-09-30 收尾时连续抓到 12 处同类问题(见 references/案例库.md),
本工具就是从那 12 个案例反向提炼出来的。
快速开始
# 零配置(推荐先跑这个):5 项检查,不执行任何文档里的命令
python scripts/doc_guard.py "C:/Users/你/.workbuddy/skills/你的skill"
# 加声明式断言(把「文档声称的事实」写成 JSON)
python scripts/doc_guard.py "<skill>" --config examples/fengshuiqutan.docguard.json
# 额外实跑文档里 `$ ` 开头的命令块,逐行核对样本 + 检查有没有污染源码目录
python scripts/doc_guard.py "<skill>" --run-samples
# 只跑某项 / 机器可读 / 落盘(Windows 经管道传中文会乱码)
python scripts/doc_guard.py "<skill>" --only version,attribution
python scripts/doc_guard.py "<skill>" --json
python scripts/doc_guard.py "<skill>" -o report.txt
真实输出(2026-09-30 对 fengshuiqutan 跑,逐字取自实跑;路径里的用户目录已替换为 <user>,是全文唯一动过的地方):
doc_guard C:\Users\<user>\.workbuddy\skills\fengshuiqutan
技能 fengshuiqutan | 文件 149 个 | 配置 fengshuiqutan.docguard.json | 实跑样本 否(加 --run-samples 才跑)
[✔] version 版本号 5.0.0 在 5 处版本源一致
· SKILL.md frontmatter = 5.0.0
· fst.py (VERSION) = 5.0.0
· tests/run_eval.py (VERSION) = 5.0.0
· tests/test_docsync.py (EXPECTED_VERSION) = 5.0.0
· README.md 标题 = 5.0.0
[✔] artifacts 无未声明的运行态/开发期产物;文档提到的文件实物都在
· .trial_activated —— 试用激活标记:带进包会让下载者一解压就是『已激活』;必须由 .skillhubignore 声明排除(历史事故:文件真被平台拒收过)(已由 .skillhubignore 声明排除)
· 工具/__pycache__/ —— 发布器全局默认排除(无需声明)
[✔] attribution 署名与许可声明自洽
· ATTRIBUTION.md:5887 字节,含 MIT、CC BY;与 frontmatter license=MIT 一致
· LICENSE.txt:与 frontmatter license=MIT 一致
[✔] claims 4 条声明式断言全部成立
[✔] deadlinks 156 处相对链接全部有效
[i] samples 文档里有 30 条命令块(**未执行**;加 --run-samples 才实跑)
=== 全部通过 ===
--run-samples 时的失败样例(同一包,修复前):
[✗] samples 23 条样本对不上,7 条通过,0 条跳过
· README.md:253 $ python fst.py bazi 2000 1 1 8 0 男 --city 北京 —— 样本有 14 行在实跑输出里找不到(疑似手写/手排换行/抓自旧版本):━━━ 八字排盘 v5.6 ━━━ …
[✗] pollution 跑文档命令后技能目录发生了变化——新增 5 个、减少 0 个
· 新增:sample_户型标注.svg
· 新增:飞星.svg
· 说明:命令把产物写进了源码目录(把样本换成只读命令,或在 sample_cwd 指到临时目录),否则这些产物会随发布包发出去
多项同时红灯长什么样(2026-09-30 用一个故意做坏的 4 文件演示包跑,逐字取自实跑)——
演示包里一次埋了 4 个坑:SKILL 写 1.2.0 / 代码写 1.0.0;frontmatter 写 MIT / LICENSE.txt 是 Apache 正文;
文档链到不存在的 docs/usage.md;样本写 demo-broken 9.9.9 / 实跑输出 demo-broken 1.0.0:
doc_guard C:\Users\<user>\AppData\Local\Temp\dgdemo
技能 dgdemo | 文件 4 个 | 配置 无 | 实跑样本 是
[✗] version 版本号有 1 处不一致(唯一真源:SKILL.md frontmatter = 1.2.0)
· scripts/main.py 里 VERSION = 1.0.0,与 SKILL.md 的 1.2.0 不一致
· 全部版本源:SKILL.md frontmatter=1.2.0;scripts/main.py (VERSION)=1.0.0
[✔] artifacts 无未声明的运行态/开发期产物;文档提到的文件实物都在
[✗] attribution 署名/许可有 1 处问题
· SKILL.md 声明 license: MIT,但 LICENSE.txt 里没有该许可正文/名称
· 没有 ATTRIBUTION 文件(若包里不含第三方内容,这是正常的)
[i] claims docguard.json 未声明额外断言(可选)
[i] deadlinks 1 处相对链接指向不存在的文件(共检查 1 处)
· SKILL.md → docs/usage.md
· 若为有意保留(如收录的第三方正文指向其原仓库结构),把路径加入 docguard.json 的 ignore_deadlink;若本包不收录第三方正文,建议设 "deadlinks": "fail" 让断链真正拦人
[✗] samples 1 条样本对不上,0 条通过,0 条跳过
· SKILL.md:17 $ python scripts/main.py --version —— 样本有 1 行在实跑输出里找不到(疑似手写/手排换行/抓自旧版本):demo-broken 9.9.9
[✔] pollution 跑完 1 条文档命令,技能目录文件清单未变
=== 3 项失败 ===
注意这份报告里两个「没报」的地方,都是设计如此、不是漏报:
- artifacts 放过了残留的 _meta.json —— 发布器已硬编码排除它,留在包里无害(见已知坑 2)。
- artifacts 放过了「文档说逻辑在 scripts/helper.py,而该文件不存在」—— 因为 files_claimed_in_docs
默认空,「提到」不等于「声称存在」,要查必须显式声明(见配置字段)。
退出码:0 = 全部通过(可能有 INFO);1 = 有 FAIL;2 = 路径/配置出错。
6 项检查各守什么
| 检查 | 守的 bug | 触发条件 |
|---|---|---|
version |
版本号漂移(代码 4.7.0 vs 文档 4.7.2,两边都「看着没错」) | SKILL.md frontmatter 的 version 与 代码里的 VERSION 常量、包根文档标题里的 vX.Y.Z 不一致 |
artifacts |
① 本机运行态文件随包发出去(.trial_activated 曾真被平台 400 拒收)② 文档说「已新增 X 文件」而包里没有 X |
发现未声明的产物后缀/文件名;或文档提到某文件而实物缺失 |
attribution |
把别人的 CC BY 正文标成自己的 MIT(错误署名) | ATTRIBUTION 缺许可名/许可链接/署名三要素之一;或 frontmatter license 与 ATTRIBUTION/LICENSE 对不上 |
claims |
「文档声称的事实」没有机器可查的对应物 | 配置里声明的断言不成立(含 file_matches_upstream 字节比对) |
deadlinks |
文档链接指向不存在的文件 | 相对链接目标不存在(默认只报 INFO,见下文) |
samples / pollution |
① 文档里的「真实输出示例」其实被手改过 / 抓自旧版本 ② 跑文档命令把产物写进源码目录 | 样本行在实跑输出里找不到;或跑完目录文件清单变了 |
配置字段(docguard.json,放技能根或 --config 指定)
| 字段 | 作用 |
|---|---|
aliases |
文档里的裸命令映射成可执行命令。如 {"fst": "{python} fst.py"} —— 文档写 $ fst life,实跑时替换为「当前解释器 + fst.py」 |
sample_env |
跑样本时的额外环境变量。如 {"FST_NO_TRIAL": "1"} |
sample_timeout |
单条命令超时秒数(默认 120) |
samples_severity |
fail(默认)/ warn。文档样本是历史留档时设 warn,只提醒不拦人 |
samples_skip_files |
跳过某些 md 的命令块(glob) |
ignore_deadlink |
断链豁免 glob。收录第三方正文时必设——正文指向上游仓库结构的链接在本包内必然断,且是有意保留的 |
deadlinks |
断链的严重级别。默认 info(只提示、不进失败数);设 "fail" 则断链计入失败与退出码。⚠️ 默认宽松是因为「收录第三方正文」是常见场景(那种包里断链是有意保留的,见 ignore_deadlink)。若你的包不收录第三方正文,建议显式设为 "fail" —— 否则一条指向不存在文件的链接会被静默放过 |
files_claimed_in_docs |
「文档提到就必须存在」的文件名清单。默认空(不启用)——「提到」不等于「声称存在」,只有包作者知道某次提及是不是在声称存在,所以必须显式声明。风水趣谈的配置里填了 [".skillhubignore"] |
forbidden_files / forbidden_suffix |
追加自定义的禁止产物 |
version_scan_exclude |
版本常量扫描的排除 glob(默认排除 skills/**、references/** —— 嵌套子技能有自己独立的版本号,不该拿来比) |
claims |
声明式断言列表,见下 |
claims 断言类型
[
{"type": "file_exists", "path": "ATTRIBUTION.md", "why": "包内含第三方正文,必须有分区署名"},
{"type": "file_absent", "path": "some/tmp.txt", "why": "..."},
{"type": "doc_contains", "file": "ATTRIBUTION.md", "text": "creativecommons.org/licenses/by/4.0", "why": "CC BY 要求附许可链接"},
{"type": "doc_not_contains", "file": "SKILL.md", "text": "【免责】以上内容由 AI 生成", "why": "脚注全文真源已收敛到 简介.md,这里不该再抄一遍"},
{"type": "file_matches_upstream", "path": "references/x.md", "upstream": "D:/upstream/docs/x.md", "expect": "same", "why": "确认它是上游逐字节搬来的 → ATTRIBUTION 必须归 CC BY"}
]
file_matches_upstream 是本工具里唯一能识破「归属写错」的检查(用 SHA-256 比字节)。
expect 取 same(声称照搬,就必须真一样)或 diff(声称原创,就不能跟上游一样)。
已知坑 / 什么时候它会误报
这些都是实测踩出来的,遇到同款先看这里,别急着改包:
.trial_activated报 FAIL 是误报吗? 不一定。规则是:文件存在 + 未被.skillhubignore声明 → FAIL;
已被声明 → 只作为说明列出。所以报 FAIL 时真正要修的是.skillhubignore,或确认这文件根本不该在包里。__pycache__/.pyc不报错。发布器已全局默认排除,存在是对的——写进.skillhubignore反而变成第二处真源。- 断链默认不判失败。收录第三方正文时断链是「有意保留」(改了就不满足「逐字节一致」),所以默认 INFO;
要它拦人就把配置里deadlinks设成"fail"。 - 行内代码里的链接不算真链接。文档写「正文原有链接(如
[← 回总目录](../README.md))在本包内是断链」——
这是描述一个断链,不是真断链。工具会先剥掉围栏块和反引号内容再找链接。 - 许可名有等价写法。
CC-BY-4.0(SPDX 风格)与CC BY 4.0(散文)是同一个许可,比较前会归一化。 - 样本比对已跳过占位行:
…、...、(此处略、其余 N 条等排版省略标记不参与比对。 --run-samples会真的执行文档里的命令。默认不开;开了也有危险命令跳过滤名单
(pip/curl/rm/git/>/|/;等一律 SKIP 不执行)。别对来源不明的包开这个开关。.skillhubignore是「声称」不是「保证」。本工具只校验它的存在与声明,不校验发布器是否真的读了它。
发布前仍需--dry-run看待上传文件清单(那条路径由发布器负责)。- 自己造反例包验证本工具时,别在文件里写出你期望被检测的那个关键词。实测踩到:为了造
「frontmatter 声明 MIT、LICENSE.txt却是 Apache 正文」这一冲突,我在LICENSE.txt里加了一句中文注释解释
这个冲突 —— 注释里正好含MIT三个字母,而许可比对是子串包含判断(为了容忍CC-BY-4.0/CC BY 4.0
这类等价写法),于是attribution认为文件里「找到了 MIT」,冲突被放过了(表现为该反例只报 2 项失败而非 3 项)。
→ 注解写在包外的说明里,别写进被检测的文件;反例没报红时,先怀疑反例本身不纯,再怀疑工具。
怎么确认它真的能抓到问题
工具本身也需要被验证。别手搓坏包(手搓过一次,直接踩了已知坑 9),用自带脚本:
python scripts/make_broken_demo.py "%TEMP%/dgdemo" # 一次埋 4 个坑,并打印核对清单
python scripts/doc_guard.py "%TEMP%/dgdemo" --run-samples
应当稳定得到 3 项 FAIL(version / attribution / samples)+ 1 项 INFO(deadlinks),
即上面那份红色报告。验完把 %TEMP%/dgdemo 删掉即可,别留在工作目录里被后续扫描误伤。
什么时候别用它
- 包里没有 SKILL.md → 直接退出码 2,不是技能包就不用跑。
- 纯代码库、没有「对事实的强声明」 → 价值有限,
version+artifacts两项够用。 - 想替代发布器预检 → 不能。它管「文档有没有撒谎」,发布器管「平台收不收」,两件事。
与本包其他校验的关系
doc_guard 是跨包通用的外挂检查器;被它校验的技能包通常还有自己包内专用的守卫
(如 fengshuiqutan/tests/test_docsync.py 守着「README 输出样本逐行核对」「许可分区」「脚注档位单一真源」)。
两者不重复:包内守卫知道本包的语义(哪块样本必须逐字一致、哪个文件属于第三方),
doc_guard 只知道通用形态、换个包就能跑。新起一个包时先用 doc_guard 扫一遍,再按需补包内专用守卫。
12 个真实案例(每个都附根因、为什么人眼抓不到、现在由谁守)+ 3 条「看着像 bug 其实不该改」的
反向教训,见 references/案例库.md。