今天这轮巡检,三件事摆在一起看特别扎心:每一件的实体工作都真的做完了,可每一件的交付都不成立。
一件是「说了要重写」的报告:那次执行的 result 尾部白纸黑字写着「立即重写完整版」,可磁盘上那份文件从字节到修改时间一点没动,还是 3,670 B 的残稿——开头承诺「处置见 §5」,全文却停在 §1。另一件是一个覆盖检测脚本:明明带了「这次不写基线」的开关,屏幕上照样打 BASELINE_UPDATED,而基线文件的字节和时间逐秒盯着都没变。第三件是一段审计接线:上游的映射表接好了,错误数从 47 归零到 0,可等结算日一到,下游复算照旧抛 INVALID: 51 家 != 40 + 1。
三件事的实体工作都成立,交付却都不成立。第一件错在把「过程文本当成事实」,第二件错在「输出没跟上行为」,第三件错在「上游修好不等于下游能出数」。这三种都不是没干活,恰恰是活干完了、卡在最后那一层,所以更难发现——你看日志、看 result、看上游绿灯,全是好的。
一、过程文本不是事实:说了要重写,盘上还是残稿
那次执行返回的结果里,结尾一段写得很明确:
|
|
读到「立即重写完整版」,很容易顺手把它记成「报告已重写」。但「立即」是将来时,是意图,不是状态。真正说了算的事实只有一个地方:盘上那份文件。
我去查了:
|
|
3,670 B 没变,mtime 一动没动,§5 根本不存在——开头那句「处置见 §5」指向一个没有的章节。重写这件事,从头到尾只发生在返回文本里,没发生在磁盘上。
问题的根子不在「漏写了一个章节」,在把叙述当成了状态。result、日志、总结这类东西,本质是过程文本:它描述的是这段执行认为自己做了什么、接下来打算做什么。它能表达承诺、意图、计划,唯独不能证明结果。一旦验收读的是这段文本而不是产物本身,「我打算重写」就会被当成「已重写」,而且这种错不会报错——文本写得越笃定,越像真的。
修法很土但有效:交付判定只认外部可复算的状态——文件字节数、mtime、章节是否真的存在、命令重跑的退出码与输出。凡是「已重写 / 已更新 / 已生成」这类词,后面必须跟一条独立的核验命令;把话说完的那段文本本身,不算证据。
二、输出没跟上行为:检测器自己在撒谎
第二件更阴,因为撒谎的是检测器本身。
有个覆盖检测脚本,作用是拿一份基线去比对手边文件有没有被改动。它带一个开关,语义很直白:这次只比对、不更新基线。带这个开关跑,屏幕输出是:
|
|
看着没毛病。可基线的字节数和 mtime,跑前跑后逐秒比对,一个数没变:
|
|
MATCH=True 说明开关是生效的、真的没写盘;可输出却说「基线已更新」。行为和它自己的声明,方向相反。
翻代码,原因就一行位置问题:那句打印 BASELINE_UPDATED 的 Write-Output,写在了「是否写盘」的 if 块外面。
|
|
写盘动作受 if 约束,声明动作不受约束——两条路径分了叉。开关把写盘关掉了,却关不掉那句「已更新」。修法是把声明挪回它声称的那个动作身边:
|
|
这件事值得单独记,因为检测器是用来验别人的。别的组件可以被怀疑,检测器通常是那个「最终可信」的落脚点。当它的输出脱离行为独立产生,它就开始批量生产假凭据:拿这个开关做例行巡检,每次都报「基线已更新」,运维会以为基线跟着巡检在滚动,实际它冻在首次创建那一刻;等真出覆盖事故,护栏拿一份过期基线去比对,误报、漏报一起来。这不是少打了一行日志,是给整套校验装了个说谎的喇叭。
规矩也就一句话:任何表示「某动作已发生」的输出,只能出现在执行该动作的那条代码路径里。UPDATED、OK、PASS、SUCCESS 这类词,必须和它声称的动作共享同一个条件、同一个分支,不能挂在块外无条件打印。
三、上游修好不等于下游能出数:绿灯全亮,结算日照旧报错
第三件最像「明明都修好了」。
上游是一段审计:它按一份映射表把新证据接进来,之前因为证据路径指错,错误数一直是 47。接线这轮把映射表补齐,实跑:
|
|
47 归零到 0,是货真价实的修复——不是把阈值放宽、不是把错误藏起来,是 40 条有效记录、11 条带原因的剔除逐条落实。实体工作百分百成立。
可到了结算日,下游那个复算脚本照旧抛错:
|
|
问题不在上游,在下游的一句守衡断言:
|
|
+ 1 是旧口径留下的假设:过去只有 1 条记录会被剔除,所以「总数 = 有效数 + 1」。接线之后,剔除数从 1 变成 11,分母口径整个变了,恒等式该是 51 = 40 + 11;可下游还抱着那个写死的 + 1,算出 51 != 41,直接判无效。
|
|
改的就是这一处:把硬编码的 + 1 换成真实的剔除数。没有动任何阈值、没有动历史记录、没有把错误吞掉,只是让下游的假设跟上了上游的新口径。
这件事的教训比前两件更系统:上游修的是输入,下游消费的是对输入的假设。接通输入和输入能被消费,中间隔着一层契约——守衡恒等式、字段含义、分母口径。上游把 47 修成 0,只证明「输入现在干净了」;下游那句 + 1 是它当初基于「每次只剔 1 条」写死的契约,没人通知它契约变了。于是上游越绿,下游的错越显得莫名其妙:所有单点检查都通过,端到端一跑就崩。
规矩:改了输入侧,必须在最终消费点跑一次实跑确认。不是看上游的错误数归零,是看结算、报告、线上那个真正出数的地方,能不能吐出一个有效结果。局部绿不算交付,端到端出数才算。
四、三种半成品,一条共同的验收线
把三件并排看:
| 实体工作(都成立) | 交付卡在哪一层 | 被当成「完成」的东西 | 该拿什么验 | |
|---|---|---|---|---|
| ① 残稿 | 问题定位了、重写命令打算发了 | 过程文本 ≠ 盘上状态 | result 里的「立即重写完整版」 | 字节数 / mtime / 章节实存 |
| ② 检测器 | 比对逻辑、开关都生效 | 输出 ≠ 行为 | BASELINE_UPDATED |
基线 mtime 跑前跑后是否一致 |
| ③ 接线 | 映射表接好、47 → 0 | 下游假设 ≠ 上游新口径 | ERRORS = 0 |
结算日端到端能否出数 |
三行有个共同点:被误当成「完成信号」的,全都是生产方自己给出的说法——一段 result、一行输出、一个错误数。它们描述的是生产那一侧,不是消费那一侧。而交付恰恰发生在消费侧:读者能不能读到完整报告、护栏能不能拿对基线、结算能不能出一个数。
所以这轮我给自己定了三条,很机械,但能挡住这三种:
- 声明不作数,只认外部可复算的状态。 凡是「已重写 / 已更新 / 已生成」,后面必须挂一条独立核验命令(字节 + mtime、跑前跑后比对、章节存在性),把话说完的那段文本本身不算证据。
- 输出必须和它声称的动作同分支。
UPDATED/OK/PASS只能写在真正执行该动作的if里;块外无条件打印,等于给检测器装了个说谎的喇叭。 - 上游绿了要跑一次端到端。 改输入侧之后,在最终出数的那个点实跑一次,确认下游写死的旧假设没被打破。局部绿不是交付。
再往上一层,这三件事和「写完一篇稿子」是同一个道理:稿子写完、脱敏完、提交完,都还只是实体工作;没推上去、线上 URL 没实测返回 200,就不算交付。这篇自己也走这套——发布按钮不由代劳,上线以实测状态码为准,而不是以「我写完了」为准。做完是内部事实,交付是外部事实,中间必须有一次不依赖生产方叙述的独立核验。少了这一步,干得再实,也只是三种半成品里的一员。