生成代码禁止手改:再生成 + diff 门禁的实践

契约那篇按过一次话头:生成物不许手改的整套门禁值得单独写。就是这篇。仓里 46 个 proto 文件生成三端代码,8800 多行的单文件 DDL 投影出 153 张表的数据模型文档,小程序 gen/ 下 27 个生成文件直接躺在源码树里。真相源只有 proto 和 DDL 两个目录,其余全是派生物。派生物越多,「手滑改了生成物」「改了源忘了再生成」这两类事故的暴露面越大,这篇讲怎么把两类风险全部变成机器红线。

两类派生物,两种防法

派生物先分两类,防法完全不同。

一类入 git:小程序的 gen/,因为小程序无构建链,生成码必须躺在源码树里被 import(原因契约篇讲过);还有 docs/generated/data-model.md,E-R 文档是给人看的,得随仓走。入 git 的派生物天然有比对基准,git 本身就是比对器,防法是再生成 + 双零 diff:把所有链再生成一遍,git diff 零输出,未跟踪文件零个。

另一类 gitignored:contracts/descriptor-set.bin(Rust 侧 build.rs 拿它生成类型)和 packages/shared 下的生成码(标准 npm 包形态)。这俩不入 git,没有可比对的基准,要防的也从手改换成过期:校验源是不是新于产物,过期了就拦,或者干脆自动重建。

派生物 入 git 防法
小程序 gen/,27 个 TS 文件 入 再生成 + 双零 diff
docs/generated/data-model.md 入 再生成 + 双零 diff
contracts/descriptor-set.bin 忽略 新鲜度校验,过期构建即红
packages/shared 生成码 忽略 新鲜度自检 + 自动重建

双零 diff 门

门是 contracts/Makefile 的 check 目标。前半段 lint、breaking、generate 是常规动作,尾巴上四条 git 命令才是这篇的主角:

1
2
3
4
5
6
7
check: lint build generate breaking er
git diff --exit-code -- ../apps/miniprogram/miniprogram/gen
@git status --porcelain -- ../apps/miniprogram/miniprogram/gen | grep -q . && \
{ echo 'check: gen/ 存在未跟踪生成物(新 rpc 需再生成并 git add)'; exit 1; } || true
git diff --exit-code -- ../docs/generated
@git status --porcelain -- ../docs/generated | grep -q . && \
{ echo 'check: docs/generated 存在未跟踪生成物(schema 变更需 make -C contracts er 再生成并 git add)'; exit 1; } || true

第一个零是 git diff --exit-code:再生成之后,已跟踪文件有一个字节变动就红。手改了生成物,再生成一律以生成器输出覆盖工作区:没提交的手改被静默抹掉,白改;提交了的,工作区与提交对不上,diff 现形。改了 proto 或 DDL 忘了再生成,提交里的旧产物和新生成物对不上,一样红。

第二个零是很多 diff 门漏掉的那半边:git diff 不含未跟踪文件。新加一个 rpc,再生成会在 gen/ 下多出一个新文件,如果没 git add,第一个零是绿的,提交推出去,别人 clone 下来编译就缺文件。所以补 git status --porcelain(git status 的机器可读形态)把这一面也堵上。

红法一共三种:手改进了提交、漏再生成、多出文件没提交。这道门挂在 make -C contracts check,而 check 又在仓级 check:commit 快速道里,改契约的 commit 想不带再生成产物混过去,门不答应。质量门禁怎么分层,后面有专门一篇。

E-R 文档:DDL 的纯投影

data-model.md 是这套门禁里最晚加入的成员,也是我觉得最值的一个。153 张表、39 个域的库,不画图没人看得懂;手工画的 E-R 图,画完那天就开始过期。

生成器 gen-er.mjs,334 行,零 npm 依赖(只用了 node:fs 和 node:crypto),行式状态机解析 01-schema.sql,产出一份 2026 行的 Markdown 文档:域索引、每个域一张 mermaid E-R 图、表说明小表、跨域 FK 汇总。文件开头长这样:

1
2
3
# 数据模型总览(E-R 派生视图)

> 153 表 / 39 域 / 317 条 FK(跨域 236)。

要点在纯投影三个字:生成器零文案新增,所有表说明、域摘要、规格锚点全部取自 DDL 注释原文。域从哪来?DDL 里的域横幅,-- ==== 包夹的注释块,首行当域标题,整块当域摘要:

1
2
3
4
-- ============================================================
-- 参与者(participant.md):微信身份三键、资料、收货地址、偏好设置、consent 闸;
-- auth_tokens = dev 会话(opaque token,DB 侧)。
-- ============================================================

表说明取表注释的首句。参与者域投影出来长这样(节选):

1
2
3
4
5
6
7
8
9
10
11
12
erDiagram
participants {
BIGINT id PK
UUID tenant_id FK
BIGINT identity_id FK
}
auth_tokens {
TEXT token_hash PK
BIGINT participant_id FK
UUID tenant_id FK
}
participants ||--o{ auth_tokens : "participant_id"

缺省每个实体只画 PK、FK、UK 键列,153 张表全列铺开没法看;要看全列有 –full 开关。

投影的收益:DDL 改了,图跟着再生成,手工图「画完即过期」的问题不存在。代价:图的质量上限就是 DDL 注释的质量,注释写得啰嗦、带着历史注记,图里原样呈现,生成器不做任何美化。所以这套做法实际是把文档质量前移成了 DDL 注释质量,注释顺手写好,文档就是免费的。

解析器会静默漏表

用行式状态机解析 SQL 很脆,gen-er.mjs 的正则一路被新批次的语法形态推着长:列级 REFERENCES、表级 FOREIGN KEY、ALTER TABLE ADD CONSTRAINT,还有 ALTER ADD COLUMN 顺带挂 FK 的形态,每一种都是后补的。脆的失败形态不是报错,是漏:某个形态没覆盖到,那张表的边就悄悄不在图上。

所以生成器内置四条断言,任一失败直接退出非零:

  1. 解析出的表数必须等于全文 CREATE TABLE IF NOT EXISTS 的计数;
  2. 每条 REFERENCES 的目标表必须存在于表集,悬空 FK 红;
  3. 每张表必须有主键、必须归属某个域横幅;
  4. 每条 FK 的列必须在该表列集里,列行缩进一漂移就红。

断言一防整表漏掉,断言四防列解析悄悄失效,两条都是解析器与 DDL 漂移的哨兵。断言三有显式例外登记:无主键的纯联结表(目前两张,券批次挂活动、抽奖挂活动)登记在一个例外集合里,判重靠 UNIQUE;新增无主键表断言即红,要么登记要么补主键,不许静默混过去。

这个模式我后来到处用:解析器、扫描器这类「读代码的代码」,输出对错没法自证,就让输入侧的可数事实当裁判,文本计数、引用闭合都是现成的裁判。断言一挂,解析器退化从静默错变成当场红。

生成确定性:源锚判例

diff 门有个前提前面没展开:再生成必须确定性,同一份输入永远产出同一份文件。时间戳不能进输出,环境信息不能进输出,连生成代码的折行宽度都是定死的。这条我原以为自己懂,被源锚打了一次脸才算真懂。

data-model.md 文件头有个源锚,标注这份文档从哪个版本的 schema 投影来。第一版是双锚:

1
源锚:git 2097ab11 · schema sha256:3eef48e219e0

前半是 git rev-parse --short HEAD 的提交短哈希,后半是 schema 内容哈希。看着严谨,实际埋了个结构性漂移:锚跟着 HEAD 走。改完 schema,再生成,锚记下当时的提交;接着提交这份文档,HEAD 又变了,下一次再生成,锚必变,diff 必红。照着正确流程走一遍,门还是红的,而且每次都是。

门常红比门不红危险,它会训练人绕门。当时的权宜是给锚那两行加豁免、把比对收窄,门继续跑,但比对面缺了一角。这个状态没活过夜,根治批当天就到:锚改成单源,schema 内容 sha256 前 12 位,内容寻址,同 schema 字节必同锚,输出无时间戳、无 git 依赖。豁免随批回撤,门还原全量比对:

1
源锚:schema sha256:3e14e8b40512

教训就一条:确定性不是生成器的加分项,是 diff 门的入场券;锚这类输入之外的变量,一个都不能进输出。

过期怎么防:新鲜度校验与自动重建

descriptor-set.bin 是 buf build 出来的二进制,gitignored。没人会手改二进制,这里的敌人只剩过期:改了 proto 忘了 build,Rust 还按旧契约编译。这个状态的失败形态特别误导:build.rs 拿旧 descriptor 生成旧类型,业务代码按新契约写,编译器报一堆类型错误,你盯着自己的代码找 bug,方向整个是错的。

解法是新鲜度校验。一个构建助手 crate 里的函数,各 build.rs 开头调用:

1
2
3
if newest_proto_mtime > descriptor_mtime {
panic!("contracts/descriptor-set.bin is older than contracts/protos; run `make -C contracts build`");
}

mtime 比对,任何 proto 新于 descriptor 就 panic,报错文案直接给解法。问题从「编译器误导你查代码」提前成「一句话告诉你跑哪条命令」。这个 crate 是从另一个仓平移来的,门禁代码也是资产,写一次处处能用。

再往前一步是自动重建。ensure-generated.sh 把「校验后拒绝」换成「校验后自己动手」:任何源新于产物,或者产物干脆缺失(新 clone 下来就是这形态),直接调生成命令重建。判定就几行:

1
2
3
4
5
6
# 任一 $1 下源文件新于产物 $2 → 0(stale/missing)
is_stale() { # $1=源目录/文件glob $2=产物
[ -e "$2" ] || return 0
[ -n "$(find $1 -type f -newer "$2" -print -quit 2>/dev/null || true)" ] && return 0
return 1
}

它挂在 type-check、dev.sh、prod.sh 三个入口上,产物缺失无条件重建,缺失比过期更不可接受;服务器上的 prod 构建走的也是这道自检,新机器 clone 下来直接 deploy,不会拿旧 descriptor 编出旧契约。一个实现细节值得记:基准不能取目录 mtime,目录的 mtime 不随内部文件重写更新,必须取 gen/ 下最新那个文件的 mtime,否则判定直接失真。脚本里还有个 macOS 的坑顺手记下:系统自带的 bash 3.2 在 set -u 下,裸 $var 紧跟全角标点会把多字节字符粘连进变量名,误报 unbound variable。所有取值一律花括号化,一个都不裸奔。

mtime 法不完美,touch 和时钟能骗过它。我不为这个上内容哈希:量级就 46 个 proto,mtime 够用,简单的东西坏得慢;真撞上半程失败的残缺态,后面还有 type-check 兜着。

小结

这套门禁一句话讲完:真相源只有一份,其余全部可重建,「重建结果等于已提交状态」由机器断言,不靠人记得。

两类风险两种收法。入 git 的派生物走再生成 + 双零 diff,手改、漏生成、漏提交三种红;gitignored 的走新鲜度校验,过期在编译前变成一句可操作的报错。门禁成立的前提是生成确定性,连文档头一个源锚都得内容寻址,这课是那次门红教我的。

还有一层收益属于 AI 协作。禁手改写在 AGENTS.md 里是倡议,AI 手滑改了生成物,它自己未必察觉;挂在门上是物理,diff 一红它自己会改回来重来。规则写给人和 AI 看,门替你盯着两边,这个话题后面写 AI 协作的篇目再展开。

系列下一篇离开 contracts 讲部署:单机 systemd + nginx 撑起双环境并存,以及部署信息怎么做到永不入仓。

分享到