Make evidence-based engineering the standard for projects.
本文讨论的不是如何写代码,而是如何下判断。它来自一次完整工程实践,剥离了具体项目的提交号、文件名和数字,只留下原则、决策逻辑、验证步骤和真实踩过的坑。请先记住它的定位:这是默认工作法,不是审批关卡。它的唯一目的,是让“我认为”尽快变成“实测显示”,而不是增加流程负担。
最贵的错误,是自信地错了
在 AI 帮你写代码已成常态的今天,真正让人付出代价的失败,往往不是「不会写」,而是下面这些:
- 用「我逐条核对过了」来代替真正跑一遍;
- 用「只要改几行」去论证一个本就不该做的改动;
- 悄悄改掉自己说错的结论,不留任何痕迹;
- 把软偏好当成硬约束,为了一件没有用户可见收益的事反复纠结——也就是钻牛角尖;
- 决定「不做」,却没人把理由记下来,几周后又被另一个人重新提起。
它们有一个共同点:代价高昂,却本可避免。更要命的是,这类错误往往集中在你最自信的地方——因为你从不会去复查那些你觉得「理所当然没问题」的结论。
这套方法论想做的,就是把「下判断」这件事本身约束住。它把一次次教训固化成可执行的清单 + 判断的尺子 + 反模式对照表,并且用「规则分档」来防止它自己退化成新的教条。
这套方法从哪来:起点不是生成,是核查
一份可靠的方案,开头就该交代它的来历
一份真正靠谱的优化方案,通常在开头就声明自己是怎么来的,形如这样一句话:
基于对某外部评估文档的核查,过滤掉不准确的假设(例如断言「缺少某份本应存在的文档」,而它其实已存在)和过早扩张的建议(例如超出当前阶段范围的平台/功能扩展),聚焦于真实存在的健壮性问题、体验改进和覆盖缺口。
换句话说,方案不是从「一个项目通常应该有什么」倒推出来的,而是老老实实走了四步:
- 拿到一份外部评估——它可能来自他人、来自某个 AI 工具,或来自一次代码审计;
- 逐条到仓库、到代码里去核实;
- 过滤掉两类内容:事实性错误,以及价值判断上的过早扩张;
- 剩下的,才成为任务。
这里有个容易被忽略的要求:这条推导链应当是物理可核对的。源文档、核查产物(也就是方案)、以及由方案沉淀出的方法论,最好同处一地。方案里的每个任务,都应该能回溯到源文档的某条建议,以及核实那条建议时你究竟读了哪些材料。
方案的自我约束也应该来自核实,而不是凭空设定。举个例子:当方案在开头写明了技术栈,并立下「不新增运行时依赖、不破坏既有安装/集成流程」这类硬约束之后,这些约束会贯穿全部任务,并在评审中成为否决某些建议的直接依据——比如据此否决「把某个自研解析器替换为第三方库」。
三步过滤:把不该做的挡在门外
核实阶段被过滤掉的内容,通常落入三类。下面用通用示例说明:
| 过滤类型 | 示例条目 | 核实依据(示例) |
|---|---|---|
| 事实性错误 | 「项目缺少某份贡献/说明文档」 | 仓库里其实已存在该文档,甚至有多语言版本 |
| 过早扩张 | 支持更多第三方平台、增加 shell 补全等非核心能力 | 项目仍处早期;现有简写/手动方式已可覆盖,社区可后续按需补 |
| 隐含约束 | 不引入新依赖 | 现有唯一运行时依赖已足够,方案全程未新增 |
关键点在于:被过滤掉的条目并没有被删除,而是进了「不做的事项」表,并附上理由(这张表后面会专门讲)。
那么,核实到底读了些什么?这也是「方案来自阅读项目本身」的具体含义。通常包括:各类说明文档(README、贡献指南、变更日志)、架构与设计说明、既有的验证/测试记录、CI 配置、议题模板、构建与依赖清单、代码规范配置,以及源码与测试的全部。
核实过程中有两个常见收获,会直接影响后续任务:
- 既有验证文档是否存在,会决定新任务的验收形式(例如「实施后配套产出一份验证记录」);
- 构建/测试脚本的组织方式,会成为改动面评估的关键约束。比如「测试脚本是一份硬编码的全量列表,新增测试文件必须登记,而往已登记文件里加用例则不必」——这一条会显著改变两个看似等价方案的代价。
同一把尺子,也要拿来量方案自己
这是整套流程里最关键的一步。方案定稿之后,评审并没有停止,而是把前面那套核查纪律反过来用在方案本身。一份初看合理的任务清单,逐任务核查之后,条目的最终去向往往分布在以下几种:
| 去向 | 含义 | 典型依据(示例) |
|---|---|---|
| ✅ 保留 → 实施 | 问题真实、方案正确 | 通过实测确认收益与正确性 |
| ❌ 移除 | 问题不存在,或方案比问题更危险 | 误判会自纠正、结局恒正确;或属 YAGNI;或核查发现原实现反而引入数据丢失陷阱 |
| ⚠️ 收敛 | 问题真实但方案过大 | 收敛为「最小外科版」,只改一个叶子函数;用差分实测证明回归为零 |
| 🔧 打补丁 | 方案基本成立但有漏项 | 差分实测发现原方案遗漏了某类输入的处理,需补护栏 |
| ➕ 新增 | 评审中才暴露的项 | 核查发现某处逻辑零测试覆盖,且带着一个已确认可达的缺陷在跑 |
这笔账本身就说明了很多问题:一份看起来合理的计划,逐任务核查后往往有相当比例需要修正——被移除、被收敛、被打补丁,甚至会新增评审中才发现的任务;而「原样未被触动」的条目,可能只占一半左右。
这个数字(无论具体是多少)正是整套方法论的核心论据:核查不是对方案作者的不信任,而是方案应有的最后一道工序。
扩大观测面,问题自己会冒出来
「扩大观测面」这件事本身,会立刻暴露此前不可见的问题。一个典型的链条是这样的:某个任务把 CI 从单一平台扩展到多平台矩阵,并补上了跨平台集成测试;紧接着的下一个提交,往往就是修复一个只在新平台 + 特定运行时版本上才会出现的断言失败。
这样的失败,在旧的观测面下完全不可见。这不是谁写错了,而是观测面不够。所以,扩大观测面本身就是一项有回报的投资——它把「潜伏的平台差异」提前变成「可见的红灯」。
先给规则分档:别让方法论自己变成教条
在展开具体原则之前,必须先说清楚一件事:本文里所有规则都分三档,执行强度并不相同。
[护栏]:不做就会导致数据损坏、错误结论或不可逆后果——默认强制。[默认]:有理由的默认选择,但可以被用户意图或项目既有约定覆盖。[审美]:收益只是纯粹的一致性/洁癖、用户通常根本观测不到差异——只在成本近零时顺手做。
当它们发生冲突时,按这条优先级链从上往下裁决:
用户明确意图 > 项目既有约定 > 本方法论默认 > 个人偏好
为什么非要分档? 因为把经验固化成清单时,天然带着两个失败模式:一是把软偏好当成硬约束,二是脱离产生规则的语境——规则一旦离开它被总结出来的那个项目,就容易被当成放之四海而皆准的律法。分档 + 优先级链 + 反向清单(何时不必遵循),正是防止方法论自身退化成教条的三道护栏。
那么,如何判断某条规则此刻到底该不该遵守? 回到内核那句话——它能不能产生用户可见的收益,值不值这个成本。如果遵守它已经不再服务于「让结论可信、让改动可控、让协作可回溯」这个目的,那就让规则让路。特别地:
- 一条
[审美]规则(比如统一的换行符 / 编码风格),若与所处环境的默认行为相冲突、又没有用户可见收益,就应该顺其自然,而不是反复手动纠正——环境往往会自动把它改回去,你纠正的成本被反复支付,却买不到任何持久收益。 - 一条
[默认]规则遇到项目已有约定时,从项目约定。 - 只有
[护栏]规则(会造成不可逆损害的那些),才值得在任何环境下坚持。
最后,附上一张反向清单——什么时候你可以理直气壮地不遵循:
- 满足某规则不产生任何用户可见收益、且成本非零 → 放弃它,或降级为「顺带做」,按需记一句理由即可。
- 某规则与用户明确意图或项目既有约定冲突 → 遵从用户 / 项目,不要强行套用。
- 某操作在同一环境已连续失败两次 → 换方法或降级,别第三次硬磕;先分清是「我用错了」还是「这环境不可靠」。
- 你正在为没观测到的问题做预防性大动作 → 回到 YAGNI,先问这个问题是否真实存在。
- 规则的满足本身变成了目标、而你已说不清它服务于什么 → 停下来,回到目的。
第一层·事实:怎么知道一件事是真的
能跑的,就必须跑
这条是护栏。 任何关于代码行为的断言,只要能跑,就必须跑一遍。「零回归」「经核验」「应该不会有问题」这类表述,在写进文档之前,必须升级成带计数的实测结论。
为什么这么较真?因为推断的错误率从来不为零,而且错误往往集中在自信的地方;因为实测的成本,通常远低于推断出错之后的返工成本;更因为——实测结果可以写进文档成为证据,而推断只能写进文档成为主张。
「推断会说」和「实测显示」之间的落差,往往大得惊人:
| 场景 | 推断会说 | 实测显示 |
|---|---|---|
| 声称「零回归」 | 「现有用例逐条核验通过」 | fixture 差分后发现约一半行为改变;既存测试的输入全部落在「未变」一侧,但改变项里藏着原方案没想到的输入 |
| 声称「已覆盖」 | 原方案未提及某类输入 | 形如 --flag=false 的布尔标志被误解析为真值,绕过了本应触发的确认 |
| 声称「预期失败」 | 「新用例应该会红」 | 旧实现大部分用例失败、新实现全绿;而那唯一「常绿」的旧用例其实是回归护栏,不是新功能 |
| 声称「更彻底」 | 「完整重写比最小改动更干净」 | 逐字节差分证明最小版回归为零,完整版反而触及共享链路、风险更大 |
| 声称「不可达」 | 「当前不会触发」 | 实跑探针证明可达:某入口标志会被置位、正文被改写 |
怎么读这张表?每一行左边的说法,听起来都无比合理、无比自信;而右边是跑一遍之后才知道的真相。落到操作上,你可以这么做:
- 准备一组 fixture,必须包含现有测试用到的全部输入——这是证明「零回归」的唯一办法;
- 把新旧实现并排 import,逐个 fixture 比较输出(异常也要捕获后一并比较,因为抛错与返回值是两种不同的结果);
- 报告两个计数:行为相同多少、行为改变多少;
- 把改变项逐条归类为「本次意图内的修复」或「意外变更」。意外变更必须处理,或者显式记录为残留并说明不做的理由;
- 把计数写回文档,替换掉那些形容词。
用真代码验证,别拿你的抄本比你的推断
验证一个函数的行为时,要import 真实实现,不要在探针脚本里把它的逻辑复制一遍。验证一份文档里的代码时,要从文档里逐字抽取,不要照着手抄。
道理很简单:复制出来的那个「等价实现」,会在你最需要它的时候变得不等价——而你根本察觉不到,因为你比较的,是你自己的抄本和你自己的推断。
几个具体的做法:
- 探针直接
import被测的真实源码来跑,而不是把那段逻辑抄进脚本。很多「可达性」结论,依赖的其实是调用方的行为,而不是被抄的那段代码本身——只有跑真实链路才能发现(比如「某入口标志是否会被置位」,取决于上游解析器,而不是目标正则)。 - 验证「文档里给出的实现」时,用脚本按标题定位、按代码围栏切片,从方案文档里逐字抽出实现和配套测试,改个名字再编译运行——而不是照着文档手敲一遍。
- 定稿之后,再从已更新的文档里重新抽取、重跑一次。这一步证明的是「文档本身可执行且正确」,而不只是「我脑子里那个版本正确」。
归纳成可复用的四条:
- 探针脚本一律 import 被测源码,禁止复制实现;
- 抽取文档代码块之后,先断言关键标识存在(如函数签名)、条数符合预期,再运行;
- 文档改完之后重跑一次抽取——文档和代码会各自漂移,只有重跑能证明它们仍然一致;
- 需要理解跨模块行为时,把调用链上的每一个环节都读一遍(某个标志「有多严重」,往往只有读到它在下游被怎样消费,才能确定)。
先问「问题真的存在吗」,再问「该怎么修」
评审一个任务时,先问「这个问题真的存在吗、有人观测到过吗」,再问「该怎么修」。这个顺序不能颠倒。
因为一旦开始讨论实现方案,你的注意力就会转移到「怎么做」上,从而不知不觉默认了「该做」。把「存在性」这个问题单独拎出来,是唯一能阻止这个滑坡的办法。
实践中有一句固定的三问,专门用来把事实、价值判断、实施方案强行分开:
分析某任务的问题存在性以及是否应当处理?如果确实需要,再分析方案是否需要调整。最后敲定该任务方案。
这三问会带来几类典型结论:
- 重复代码类:重复属实,但如果它是纯展示逻辑、没有任何观测到的缺陷,那收益就是审美性的;当代价是一次提交要触达大量文件时 → 移除。
- YAGNI 类:某校验针对的字段只有一个版本、也没有变更计划 → YAGNI。更关键的是,核查发现原方案的实现(「先备份、再返回空表」)会在版本降级时静默清空并覆盖数据 → 这不是修复,而是引入了一个数据丢失陷阱 → 移除,并在「不做」表里写明「若将来引入新版本,应改为 fail-safe(拒绝 + 提示升级、绝不自动覆盖)」。
- 自纠正类:某处误判会自纠正、结局恒正确,而真正要紧的不变量已经被别的测试覆盖 → 移除。
可复用的四步:
- 对每个候选任务强制回答:「有没有人观测到这个缺陷?还是只是看起来不优雅?」
- 如果答案是后者,检查收益是功能性的还是审美性的;
- 审美性收益不是不能做,但必须与改动面成比例——用「一次提交触达多少文件」来量化代价;
- 核查可能反转结论:有的原实现,比它要修的那个问题还危险。这种情况必须记录下来,否则下一个人会重新提出同一个方案。
第二层·判断:怎么决定做不做
「只要几行」是最危险的论证
一个缺陷该不该修,取决于修好它是否改变用户可见的结果,而不是取决于修它有多便宜。
「只要几行」之所以危险,是因为它把成本当成了理由。便宜的事情如果不产生收益,做了也只是白白增加改动面和验证负担。
看一个便宜但决定不修的例子:某处正文污染缺陷,修法已知、只需几行,但决定不修,决定性理由是严重性——
- 在它的触发场景里,即使修好了污染,用户可见结果也不变:触发前提本身就使后续处理失败,产物照样被跳过;
- 被改写的那一行,位于每轮都会被丢弃重来的一次性托管产物里,下次运行即自愈;
- 所以修它,只买到了「不再对一个可丢弃的文件做外观损伤」。
但决定不做,要附带重新立项条件:若将来那个「根因缺陷」被立项修复,本项应与它同一批处理——因为只有到那时,改动才带来用户可见的收益。
再看一个便宜且严重、必须做的对照例子:为 CLI 增加 --flag=value 语法时,差分实测发现原方案漏了布尔标志的处理——--flag=false 会被读成真值,从而同时绕过两道本应触发的确认护栏,而它的终点,恰好与本任务要修的「静默批量操作」相同。这类补丁又便宜又严重,必须随任务一起做。
可复用的四步:
- 先独立回答两个问题:「修好它,用户能观测到什么变化?」/「修它要动多少东西?」
- 如果第一个答案是「没有变化」,那么第二个答案再便宜,也不构成理由;
- 判断「用户可见」时,要追到数据的生命周期:被改的文件,是用户资产,还是一次性托管产物?后者会自愈;
- 决定不做时,必须写下重新立项条件。
大改之前,先写个探针止损
实施一个较大改动之前,先写一个最省事的版本跑一遍。它的作用不是交付,而是暴露你没想到的失败模式。
举个真实的收敛过程:某次要收敛一处文本改写逻辑。
- 先写一个「更省事的版本」:只做最小修正,不加范围限定;
- 实测发现它引入了一个当前代码里并不存在的新 bug:在整篇文件上匹配某个模式,会改坏正文代码块里的示例内容;
- 于是「范围限定」就从一个看起来多余的复杂度,变成了必需品;
- 最终方案收敛为只改一个叶子函数,共享的解析器/正则一律不动——因为它们被用在整条发现/处理链路上(覆盖多个命令),为一个只在局部运行的重写,去改动全链路解析器的结构,风险面与收益完全不成比例。
由此得到一条清晰的判据:改动能否被限制在叶子函数内。能,就做;不能,就要重新论证收益是否配得上这个风险面。
可复用的四步:
- 大改动前先写一个简化版,并明确写着「这是探针,不是交付物」;
- 探针跑出新缺陷时,别把它当成「简化版的固有问题」忽略掉——它往往指出了完整版的必要组成;
- 用「这次改动触达几个文件 / 几条调用链」来量化风险面;
- 共享的基础设施(被多条链路使用的解析器、工具函数),默认不动。
别给走不到的路径写测试
可达性判定的依据,必须是可追踪的代码不变量,而不是「至今没触发过」。
给不可达的路径写测试,等于把投机行为固化成规格。将来有人读到这条测试,会以为这个输入是个合法场景。
一个确实不可达的例子:判断某注入路径是否可达时,结论是不可达,但理由必须精确——目标函数只在某个分支被调用,传入的值恒为上游某个「净化函数」的输出,而该净化函数会把非白名单字符整体替换掉,输出的字符集不可能含目标字符。因此不为它写测试,并写明「若将来净化函数被放宽,应在其所在模块补不变量测试」。同时还砍掉了另外两类没有信息量的用例:与其他用例重复覆盖的情形,以及任何「修正验收」式的断言(那是修复的验收标准,写进特征测试会立刻变红)。
再看一个可达性误判的反面教材:曾经判定某路径「当前不可达」,理由是「某入口标志需要顶层键存在才会被设置」。这个判定是错的——上游解析取自结构化解析结果,所以当输入写成另一种形态时,该标志会被置位;而目标正则的锚点匹配不到那种形态,扫描于是越过边界、改写了正文。
教训是:可达性必须按两条独立链路分别判断——解析视图决定是否被调用,文本视图决定改到哪一行。 只核实其中一条,就会误判。
可复用的四步:
- 主张「不可达」时,必须指出是哪个不变量保证了它,并给出该不变量在代码里的位置;
- 如果理由只是「没见到过」,那就不是不可达,只是低频;
- 一个入口标志可能由多种输入形态产生(解析后的值 vs 原始文本),逐一核实每一种;
- 不变量被放宽时,测试应该加在不变量所在的模块,而不是加在下游消费者那里。
第三层·自我校正:错了怎么办
自己提的方案,要更狠地怀疑
当一项工作是你自己提议的,你的论证义务更重,而不是更轻。
比如,在完成一处收敛后,你主动提出一个开放项(例如「给某个此前零覆盖的函数补一组特征测试」)。合理的回应不是马上动手写测试,而是要求先论证:
把它单独立项、写进方案。一定要捋一下,确认你想增加的任务是合理的——我们不想把任务搞得过于复杂,但该做的还是得做。
于是先做论证,而不是先动手:
- 为什么值得做(注意,每一条都不是审美性的):① 它带着一个已实测确认可达的缺陷在跑,而相邻的姊妹逻辑刚刚为同一触发条件修掉了同类问题,这个不对称不写成测试,后来者根本看不见;② 方案已经明确推迟了某些修正,而推迟的正当前提必须被记录在案——计划文档会被归档,测试不会;③ 它是任何未来修正的前置条件。
- 为什么它能保持简单:不引入红/绿两阶段 → 无实现改动 → 无回归面 → 验收退化为两条(全部用例首跑即绿;除测试与变更日志外,不得出现任何其他文件改动)。
- 砍掉了什么:主动列出从一批探针 fixture 收敛到少数几条测试的裁剪过程。
可复用的三步:
- 自己提议的任务,先写「为什么值得做」和「为什么它能保持简单」两段,再写实施步骤;
- 主动列出砍掉了什么——裁剪记录比范围声明更能证明你想清楚了;
- 纯增量任务(只加测试、只加文档)的验收标准,要与修复任务不同,不要机械套用红/绿两阶段。
被实测推翻时,先告诉依赖你的人
当证据推翻了你先前给出的判断,你的第一动作是告知那些依赖该判断做决定的人,第二动作才是修正。
以一处「不可达」判定被实测推翻为例,正确的动作顺序是:
- 主动披露:明确告知「我之前给你的一个判断是错的,而你在做范围决定时依赖了它」——因为对方先前的决定部分建立在那个错误判断上,他有权重新决定;
- 把决定权交还:给出几个选项(修 / 不修 / 扩大范围),而不是自行替他选择;
- 更正留痕:把旧判定原文引用后再驳斥,而不是悄悄改掉。于是文档里会同时存在「原判定」(作为被引用的错误)和「这条判定是错的」(作为结论);
- 连带更正那些理由错误的正确结论:若某个结论(如「不可达」)是对的、但理由是错的(如「同上」),也要一并更正为真正的不变量依据;
- 更正记忆:若同一句错误判定已经写进了长期记忆/文档/注释,检索并更新它,必要时新增一个「已更正的错误判定」专段。
可复用的四条:
- 判断被证据推翻时,先检查有谁依赖了这个判断做决定,主动告知;
- 更正要留痕:引用原文再驳斥,而不是替换。悄悄修改会让后来者失去「这里曾经错过」的信息,从而可能重犯;
- 一个由错误理由支撑的正确结论也是问题——理由错了,下次场景一变,结论就会跟着错;
- 错误如果已经写进长期记忆 / 文档 / 注释,必须一并检索并更正,否则它会继续误导。
让文档编辑「可以失败」:计数式自校验
批量编辑文档时,写脚本来执行,并在写盘之前跑一组计数断言。一旦失败就退出,原文件毫发无损。
比如对方案文档做多轮批量替换,每一轮的脚本里都含着数十项断言:
- 每处替换的目标串必须恰好出现 1 次(否则说明锚点不唯一,或已经被改过了);
- 旧表述的归零计数、新表述的出现次数;
- 行数护栏(期望值 = 原行数 + 各编辑预测的增量 ± 容差);
- 代码围栏总数为偶数、无 CR、无 BOM、标题数量不变。
而在这么做的时候,踩过一批很有代表性的坑——它们几乎人人都会遇到:
| 坑 | 症状 | 根因 | 修法 |
|---|---|---|---|
| 字符串替换 API 的反向引用 | 所有计数断言同时以整数倍(如 2×)超标,文档头部被复制多遍 | 新文本里含 $`(美元符号紧跟反引号)之类的特殊序列,被当成「插入匹配位置之前的全部内容」 |
替换串一律用回调 () => newStr |
| 断言作用域过宽 | 「旧表述已归零」却报出剩余 | 新内容为了驳斥而原文引用了旧表述 | 给待查串加上下文限定,并新增正向断言,要求「驳斥引用」各存在 1 次 |
| golden 串跨章节误报 | 期望 1 次、实际多次 | 同一字符串在别的章节里,作为输入字面量合法出现 | 按章节切片后再计数 |
| 自写行对齐算法 | 报出假的增删行数,还说「某段被改动」 | 简易双指针在改写区附近丢失了对齐 | 以版本控制工具的 diff --numstat 与 hunk 头为权威,核对 hunk 数与替换次数一一对应(相邻编辑会被合并成一个 hunk) |
这里有个耐人寻味的地方:$` 这个坑,恰恰可能就是同一份方案里刚刚描述过的某个缺陷。知道它、写过它、然后踩了它。 这说明「知道一个陷阱」和「手上有防住它的机制」是两件完全不同的事——而真正防住它的机制,是那条行数护栏断言,它让损害停在了写盘之前。
可复用的六步:
- 编辑工作区外的文件时(编辑工具可能会拒绝),改用工作区内的临时脚本 + 提权执行;
- 校验必须置于写盘语句之前——这一条能让所有失败都变成无损失败;
- 替换串一律用回调,无论当前文本里有没有
$; - 断言要限定作用域;写「某表述已归零」之前,先想想新内容会不会为了驳斥而引用它;
- 行级差分用版本控制工具,别用自己写的算法;
- 临时脚本用完即删,结束时确认版本控制状态干净。
第四层·闭环:让每个决定都有下落
「可选延伸」不是终态
讨论产生的每一个选项,最终都必须变成「做」或「不做」并写进文档。「可选延伸(需另行决定)」不是一个终态。
当某缺陷的处理方式经决策者拍板之后,方案里应该做这样几处编辑:
- 新增一条排在首位的理由——把严重性论证放在成本论证之前,防止将来有人只看到「便宜、几行」就重新立项;
- 把「可选延伸(需另行决定)」改写为「已决定:不做——本任务不包含,也不另立任务」;
- 附上重新立项条件:「若将来某根因缺陷被立项修复,本项应与它同一批处理——只有那时改动才带来用户可见收益,且两者共享同一触发条件、宜一并验收」;
- 在所有提及该结论的地方同步,避免只在一处留痕。
一个任务的闭环陈述,应该包含三要素:发现了什么 / 已经处理 / 现在的可实施判断。比如:
该任务现在的结论是:方案原本会带着一个自己新造的静默错误路径上线,现已补齐;补完之后,它可以实施了。
可复用的五条:
- 「不做」的决定要写明判据(如「修正能否限制在叶子函数内」+「修好是否改变用户可见结果」);
- 要写明失效条件 / 重新立项条件——决定是在特定认知下做的,认知变了,决定就该重开;
- 理由的排列顺序有意义:把决定性的理由放在第一位,因为它最可能被后来者只读到;
- 多处提及同一结论时全部同步,否则文档内部自相矛盾;
- 每轮结束给出一句明确的可实施判断,不要让状态停在「讨论过了」。
「不做」也要登记在案
被否决的方案不要删除,而是放进「不做的事项」表,并附上理由。
道理很实在:一个被否决的方案,如果没有留下理由,那么下一个人(或者下一次会话里的你自己)会重新提出它,然后重新走一遍否决流程。
一张「不做的事项」表,通常汇集了来自几个源头的条目:
- 来自最初过滤的:过早扩张的平台/功能支持、非核心的补全能力、第三方库替换;
- 来自评审移除的:若干个被判定为 YAGNI / 自纠正 / 比问题更危险的任务(各附完整理由);
- 来自方案收敛的:几条被否决的替代实现——每一条都注明「经实测」(例如某结构化改写方案会丢失注释、重排结构、其错误处理形同虚设;某「更省事版本」会改坏正文;某字符收窄会产出非法格式);
- 来自纯测试任务的:明确「只记录、不修」的已知缺陷;
- 来自最小改动原则的:明确「不顺带修」的相邻既有问题。
可复用的四条:
- 表格两列就够了:建议项 / 不做理由;
- 理由里要包含实测证据(「经实测,X 会导致 Y」),而不只是价值判断;
- 如果否决是基于当前认知,注明失效条件;
- 这张表的长度,是评审质量的一个指标——一份只增不减的方案,说明没人真的在质疑它。
钉住缺陷的测试,必须自己说清楚
特征测试里,凡是锁定已知缺陷行为的用例,测试名必须以 KNOWN DEFECT (编号): 开头,并在测试体内注明「本条钉住的是当前缺陷行为、不是期望行为,将来修复时应反转断言,而非删除」。
因为一条常绿的测试,会被后来者读成对该行为的设计背书。不标注,就等于把「我们决定暂时不修」悄悄变成了「这是正确行为」。
具体来说,一组特征测试里若有钉住已知缺陷的用例,它的命名与注释应做到:
- 名称带缺陷编号,如
... KNOWN DEFECT (编号): <描述当前被钉住的缺陷行为>; - 注释写明同源关系与修法方向,如「与某缺陷同源;将来按某种方式修复本缺陷时,应反转本条断言」。
可复用的三条:
- 编号要与缺陷清单对得上,让测试和文档可以互相索引;
- 注释里要写清修复时该怎么做(反转断言),而不只是「这是 bug」;
- 纯测试任务的验收标准是:所有用例首跑即全绿;若首跑出现红,说明期望值抄错了,应回到实测输出核对,而不是改源码让它变绿。
第五层·工程纪律:把约束变成可执行的检查
说好的边界,每一轮都要验
当你和别人约定「先别动当前项目」时,全程就只改约定范围内的文件;而且每一轮操作结束,都要验证边界没有被突破。
比如,整个评审阶段(可能跨多轮会话)要做到项目源码零修改:
- 每轮结束执行
git status --short,必须为空; - 临时文件计数必须为 0(用统一前缀,如
.tmp-*,便于一键统计与清理); - 所有验证都通过 import 真实源码 + 临时文件完成,不把任何改动落到源码、测试、构建产物、依赖清单、变更日志上;
- 构建产物零漂移,是一条独立的验收项。
可复用的三条:
- 约束要写成可执行的检查(一条命令 + 期望输出),不要写成「注意不要改」;
- 每轮结束跑一次,而不是最后才跑一次;
- 临时文件用统一前缀,便于一键计数与清理。
提交按逻辑拆,不按行数拆
提交要按逻辑原子性来拆分,而不是按文件数或代码行数。判据是:把这个提交单独 checkout 出来时,代码库是否处于一个说得通的状态。
| 场景 | 决定 | 理由 |
|---|---|---|
| 同一处解析循环的等号支持 + 相关 bug 修复 + 护栏 | 合为一个 feat 提交 |
三者是同一次重写;拆开需要构造一个「已支持新语法、但某分支仍丢值」的人为中间态,而那个状态本身是坏的 |
| 功能改动 + 配套纯文档 | 拆成两个(feat/ci 与 docs) |
功能改动与纯文档分开 |
| 实现 + 验证记录文档 | 拆成两个 | 同上 |
| 纯测试任务 | 一个 test 提交 |
只触达测试与变更日志,源码与构建产物零漂移 |
| 扩容后暴露的平台修复 | 独立 fix(test) |
它是扩容暴露出来的问题,不是扩容本身的一部分 |
配套的 commit message 规范(通用约定):
- 采用带 scope 的 Conventional Commits:
feat(scope):/fix(scope):/ci:/docs:/test(scope):; - 详细描述里写:变更内容 + 影响范围 + 验证结果(实测数字、门禁退出码、构建产物是否零漂移);
- 提交前先更新变更日志,相关改动合并为一条记录(不按提交拆分);
- 变更日志条目的结构:
**日期 · 类型 · 标题**+ 背景 / 变更 / 测试(必要时加「顺带修复」「不做」); - 默认仅提交、不推送;禁止自动提交,等待明确指示。
至于「最小原子改动」到底意味着什么:
- 最小版只改一个叶子函数,不碰被全链路使用的共享解析器;
- 在纯测试任务里,版本状态中只要出现任何源码或构建产物改动,都意味着越界;
- 明确不顺带修某个相邻的既有行为——那是既有语义,扩大它就会突破最小原子改动的边界;
- 明确不顺带收紧某处判据——记进「不做」表,另行评估。
可复用的四条:
- 拆分前先问:「这个中间态是不是一个坏状态?」如果是,就别拆;
- commit body 里写实测数字,不写「应该没问题」;
- 类型选择看主要意图:主体是新功能就选
feat,顺带修的 bug 在 body 里说明; - 「顺带能做」不等于「应该顺带做」;每一个顺带项,都要单独过一遍前面的「问题存在性」和「严重性 > 成本」。
工具报错,先分清是「我用错了」还是「它不靠谱」
工具报错时,先判断是「我的用法错了」,还是「这个工具在这个环境里不可靠」,然后切换工具,而不是反复重试。
下面这些坑,以 Windows + PowerShell 5.1 为例,但很有普遍性:
| 工具坑 | 症状 | 切换方案 |
|---|---|---|
| 读取原始内容的字符/行计数不可靠 | 对 UTF-8-no-BOM 文件报出的计数与实际不符 | 改用运行时精确核验(字符数 / CRLF / 孤立 LF / 行数 / BOM) |
内联 node -e "..." |
引号 / 反引号被 shell 转义吃掉 → 语法错误 | 改写成临时脚本文件来执行 |
| 脚本文件被 shell 以错误编码读取 | 脚本里的非 ASCII 字面量(如中文路径)变乱码 → 「路径含非法字符」 | 不要在脚本里硬写非 ASCII 字面量;改用文件系统枚举(如按扩展名 Get-ChildItem)定位目标,或以当前工作目录 + 相对路径规避 |
| 编辑工具无法写工作区外的文件 | 报权限/范围错误 | 工作区内临时脚本 + 提权执行 |
| 管道过滤丢内容 | 过滤工具输出时漏掉部分行(编码破坏) | 让工具用 --output= 写文件,再用运行时解析 |
| 版本控制命令被沙箱拒绝 | 「拒绝访问」 | 单独提权重试 |
| 控制台输出非 ASCII 乱码 | 计数结果被乱码淹没 | 只依赖退出码,以及写文件的结构化输出 |
可复用的四条:
- 同一个命令失败两次就换方法,不要试第三次;
- 在编码敏感的环境里,涉及非 ASCII / 精确字节计数的验证,一律走可靠的运行时,而非 shell 内建;
- 需要解析工具输出时,让工具写文件,而不是走管道;
- 把踩到的坑记进长期记忆——这类条目会在后续会话里,多次替你省下重复排查。
落到手上:四张操作清单
前面讲的都是「为什么」。这一节把它们压成「怎么做」,可以直接照着走。
① 评审一个候选任务时,按顺序回答这六问:
- 问题真的存在吗? 有没有人观测到?还是只是看起来不优雅?
- 如果存在,严重吗? 修好它,用户能观测到什么变化?(追到数据生命周期:是用户资产,还是一次性产物?)
- 改动面多大? 能否限制在叶子函数内?触达几个文件、几条调用链?
- 收益与代价成比例吗? 审美性收益,配得上这个改动面吗?
- 原方案的实现正确吗? 有没有比它要修的问题更危险的地方?
- 决定不做的话,理由和重新立项条件写在哪?
② 采纳一份「重写某函数」的方案前:
- 从文档逐字抽取新旧实现(不手抄),抽取后断言关键标识与条数;
- 构造 fixture 集,必须包含现有测试的全部输入;
- 跑差分,报告「相同 / 改变」两个计数;
- 改变项逐条归类:意图内修复 / 意外变更 / 既有缺陷被顺带修好(这类要补护栏测试,否则是一次未登记的静默行为变更);
- 新测试分别对旧实现和新实现各跑一次,取得精确的红绿条数写回文档;
- 文档改完后重新抽取再跑一次,证明文档本身可执行且正确;
- 用版本控制工具的
diff核对 hunk 数与替换次数一一对应。
③ 批量编辑文档时:
- 先备份;
- 每处替换的目标串,断言「恰好出现 1 次」;
- 替换串一律用回调
() => newStr; - 计数断言 + 行数护栏 + 结构完整性(围栏偶数、无 CR/BOM、标题数不变);
- 校验置于写盘之前;
- 写盘后做行级差分,确认没有意外改动;
- 回读关键区段,确认渲染后的形态与意图一致;
- 删除临时文件,确认版本控制状态干净。
④ 提交前:
- 变更日志先更新(相关改动合并为一条);
- 四步门禁:类型检查 / 静态检查(lint)/ 测试 / 构建,退出码均为 0;
git status --short检查改动面是否符合任务声明(构建产物零漂移);- Conventional Commits + scope;body 写变更 / 影响范围 / 实测验证结果;
- 等待明确指示再提交;默认不推送。
这些坑,我们真的踩过:反模式对照表
下面每一条,都是实际发生过、或被明确避开的。左看反模式,中看它长什么样,右看它会带来什么后果:
| 反模式 | 说明性实例 | 后果 |
|---|---|---|
| 用「逐条核验过了」代替实跑 | 声称「现有用例全部继续通过(逐条已核验)」 | 声称本身可能是对的,但没有发现遗漏的输入类别——核验只覆盖了它想到的地方 |
| 只核实一条链路就断言不可达 | 只看文本视图、没看解析视图,就判「当前不可达」 | 判断错误,且可能被用来支撑范围决定 |
| 用成本论证该不该做 | 「这个缺陷只要几行就能修」 | 差点做了不产生用户可见收益的改动 |
| 悄悄修改错误表述 | ——(没做,选择引用后驳斥) | 若做了,后来者会失去「这里曾经错过」的信息 |
| 机械套用红/绿两阶段 | ——(纯测试任务明确不套用) | 纯测试任务会凭空造出一个「失败」阶段 |
| 替换串直接传入字符串替换 API | 编辑文档时 $` 被展开 |
文档头部被复制多遍,多项断言以整数倍超标 |
| 自己写行对齐算法做差分 | 报出假的增删行数 | 差点误判某段内容被改动 |
| 内联脚本里放引号/反引号 | shell 吃掉转义 | 语法错误,反复踩 |
| 脚本里硬写非 ASCII 字面量 | 被 shell 以错误编码读取 | 中文路径变乱码 →「路径含非法字符」 |
| 给不可达路径写测试 | ——(确认不可达后砍掉) | 若写了,会把投机行为固化成规格 |
| 缺陷测试不自我标注 | ——(改用 KNOWN DEFECT 前缀) |
若不做,常绿测试会被读成设计背书 |
| 顺带修无关问题 | ——(不顺带修相邻既有行为、不顺带收紧判据) | 若做了,会突破最小原子改动边界、模糊验收标准 |
| 让决定停在「可选延伸」 | ——(改写为「已决定:不做」+ 重新立项条件) | 若不做,悬而未决会被下一个人重新打开 |
附录 A:一个项目该留下哪些账目
方法论落地之后,建议为每个项目维护一份「最终账目」,用可核对的事实替代形容词。它通常包含三张表:
① 方案文档演进:记录每轮编辑后的规模与编辑处数,以及一致性保证。
| 阶段 | 规模(行数/字节) | 本轮编辑处数 |
|---|---|---|
| (示例)某轮收敛 + 某任务移除后 | —— | —— |
| (示例)写入某新增任务 | —— | 若干处 |
| (示例)记录某决定 | —— | 若干处 |
全程保持统一的换行/编码约定(如纯 LF、无 BOM);每轮都以版本控制工具的
diff核对 hunk 数与替换次数一一对应;每轮结束时,项目仓库的git status --short为空。
② 任务实施状态:逐任务记录「保留/移除/收敛/新增」的去向、是否已实施,以及对应的提交标识(在内部版本中保留,通用文档中以占位符表示)。
③ 剩余工时与依赖约束:记录尚未实施任务的预估工时,以及任务之间的顺序依赖(例如「某纯测试任务复用了另一任务引入的 helper,顺序不可颠倒」;而与它无依赖的任务,则可独立实施)。
说明:本附录在通用版中不填具体数字与提交号——它们属于单个项目的一次性事实,应记录在该项目自己的方案文档里,并可回溯核对。
附录 B:如果只记得一句话
能跑的就必须跑,跑出来的就照实说,说错了就当场改、并且告诉依赖它的人,决定不做就写下理由和重开条件,改完就验证边界没被突破。