文档里出现一个路径,不等于那里就存在一个必须被检查的文件。这个区别看起来很小,却决定了一套引用审计到底是在帮人找问题,还是在制造一堆红灯。
这次我遇到的场景很具体:一个技能体系在结构治理之后,已经手工抽查过一轮断链,但手工检查既容易漏掉真正的问题,也容易把代码示例、示例路径和跨目录引用误判成错误。于是问题从“有没有几处链接断了”,变成了“怎样把文档引用完整性固化成代码审计”。
我一开始很容易想到最直接的办法:把文档里的路径字符串全部找出来,再逐个判断目标是否存在。后来发现,这条路虽然看起来彻底,实际会把很多不应该检查的东西一起拖进来。审计的第一步不是扫描得更广,而是先定义什么才算审计对象。
手工抽查为什么总是不稳定
手工抽查有一个天然的问题:检查者会根据当时看到的上下文作判断,而判断很难稳定地留下来。同一段文档,今天的人可能知道某个路径只是示例,过几天换一个人,就可能把它当成真实依赖;一个跨目录引用,如果只看末尾的相对部分,也可能被误认为当前目录下缺少文件。
这次任务的背景正是如此。结构治理完成后,文档里已经存在一批引用,需要确认它们是否仍然指向真实内容。手工看几篇文件,能够解决眼前的疑问,却不能保证下一次结构调整后仍然有人记得重查,更不能把判断规则交给测试自动执行。
如果不处理,后果不只是偶尔出现一条过期链接。真正的风险有两种:该报错的断链被漏掉,读者点进文档时才发现入口失效;不该报错的示例被当成失败,维护者为了让测试变绿去修改没有问题的文档。前者让引用完整性失去意义,后者会让人逐渐不再相信红灯。
所以我选择把它做成代码审计,是因为代码可以把“哪些内容应该查、哪些情况应该跳过、什么结果算失败”固定下来。它不能替人理解所有文档,但至少可以让同一条规则在每次变更后重复执行。
先收窄审计对象,再谈断链
最容易误判的地方,是把所有“看起来像路径”的字符串都视为引用。文档中可能有命令示例、目录结构示意、配置模板,也可能只是为了说明格式而写出的示例参数。它们的共同点是:字符串像目标,却没有承担“读者可以点击或据此找到真实文件”的引用职责。
因此,审计对象应该先收窄到明确的 Markdown 链接。这个选择牺牲了一点表面上的覆盖率,却换来了更低的误报,因为它检查的是文档已经明确表达为链接的内容,而不是猜测作者意图。
这件事让我重新意识到,自动化并不天然等于全面。扫描范围越大,未必得到越多有效信息;当规则开始猜测自然语言和示例代码的意图时,审计就从验证变成了推测。低误报的关键,不是让程序认识文档里的每一种路径,而是让它只对有明确语义的引用负责。
这也解释了为什么不能把“路径字符串扫描”当成最终方案。它适合做探索,帮助发现可能值得看的位置;但如果直接把探索结果当成质量门,示例里的尖括号、花括号和通配符都会被当成真实目标。一个写成 <某个文件> 的位置,本来就在告诉读者“这里需要替换”,并不意味着当前工作区缺了一个名叫“某个文件”的文件。
跨目录引用必须先判断归属
确定了审计对象之后,第二个难点是路径解析顺序。文档里的引用不一定只指向当前技能目录,也可能指向另一个技能的资料。比如,一个链接的前缀先表明它属于另一个目录,后面又出现了 references/xxx 这样的相对片段。
这里最容易犯的错误,是先按本地相对路径处理,再去尝试解释跨目录含义。这样一来,程序会把跨技能路径的后半截剥出来,误以为当前目录缺少一个 references/xxx 文件。错误信息看起来很像真的,维护者却会被带到错误的地方。
我判断解析顺序必须是“先跨目录,后本地相对路径”,是因为路径前缀承担的是归属信息。只有先确定它属于哪个技能,后面的相对路径才有正确的基准目录。这个顺序不是实现上的小偏好,而是语义上的先后关系:先回答“目标属于谁”,再回答“目标在哪里”。
如果目标技能存在,审计器就按照跨目录规则定位真实文件;如果引用只是在当前技能内相对展开,才使用本地目录作为基准。这样每一类引用都有自己的解析入口,避免所有字符串最后都落入同一个本地检查分支。
路径解析的安全顺序是先判断引用的归属,再在正确的目录语境里解析位置。
示例参数与缺席依赖不能混成失败
路径解析并不意味着所有解析出来的内容都要报错。至少有两类情况需要在结果里保留区别。
第一类是示例参数。包含尖括号、花括号或通配符的路径,通常是在展示写法,而不是指向一个固定文件。它们应该跳过,不应该因为目标不存在就让审计失败。这里的跳过不是放弃检查,而是承认这段文本并没有提供足够证据,证明它是一个真实引用。
第二类是整体缺席的外部依赖。一个技能可能引用另一个技能的资料,但对方目录在当前环境中根本不存在。这和“对方目录存在,但其中某个被引用文件缺失”不是一回事。前者可能只是可选依赖没有安装,后者才更接近当前引用链的断裂。
所以我把整体缺席依赖输出为 skip 警告,而不是 fail。这样结果仍然留下了可见线索,提醒维护者这条引用没有被实际解析;但它不会把一个当前范围之外的问题伪装成当前文档的硬错误。如果不做这个区分,测试会把环境差异当成代码缺陷,最后只能通过删引用或放宽全部规则来消除噪声。
我更愿意把这看成一种保守的边界管理:能确认错误的地方才亮红灯,无法确认但值得留意的地方留下黄灯。红灯应该对应可修复的断链,缺席的可选依赖则应该保留为可解释的跳过。
先让错误真的红起来
审计器的价值不只在于“最终通过”。如果一个测试从来没有被故意打失败过,就很难确认它是否真的抓住了目标问题。这次方法里有一个重要动作:先用临时断链验证红灯,确认输出能够说清楚哪个文件、哪一行、原始引用是什么,以及目标为什么不存在,再把它接入正式测试。
这一步看起来像额外工作,实际上是在验证错误信息本身。断链审计不是只给出一个总数;维护者拿到失败结果后,必须能沿着路标回到文档原文。否则,测试虽然失败了,修复仍然要重新搜索,自动化只是把人工检查往后推了一步。
正式检查时,结果还应该支持结构化输出。人需要一眼看懂的文件名、行号、引用和目标,其他测试或持续集成流程则需要稳定的 JSON 结果。两种输出服务的是不同的使用者,不必让一个格式承担全部工作。
这里还有一个容易被忽略的停止位置:审计器只负责判断引用完整性,不顺手修正文档、不删除它认为可疑的段落,也不把缺席依赖强行安装进来。质量门的边界越清楚,失败结果越容易被行动,而不是被绕过。
下次我会怎么做
下次再为文档建立类似审计,我会先写出审计对象的定义,再写解析器。第一问不是“还能找到多少路径”,而是“哪些文本已经明确承担引用职责”。只有这一步确定,后面的失败才有可靠含义。
接着我会按层次处理:先识别明确的 Markdown 链接;看到跨目录前缀,先确定目标归属;确认是示例参数就跳过;确认是本地相对引用,再按当前文档的目录解析;遇到整体缺席的外部依赖,输出 skip 警告;只有目标应当存在却确实不存在时,才输出 fail。
最后,我会先制造一个临时断链,检查红灯能否提供完整路标,再接入测试,并保留结构化结果供后续流程使用。这个方法不只适用于技能文档。任何需要审计引用、配置入口或目录依赖的系统,都可以先把“真实依赖”和“展示用文本”分开,再决定错误等级。
表面上我是在检查文档链接,其实是在给维护者建立一套不会轻易失真的判断边界。一个流程成熟的标志,不是它能把所有可疑字符串都报出来,而是它知道哪些问题值得打扰人,哪些内容应该安静地放过。