AI 又改了锁文件和构建产物:哪些文件必须标记为不可手改
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
这类事故的根因几乎不在模型,而在仓库:你的项目里,package-lock.json、__snapshots__/*.snap、dist/、.png、migrations/ 和你手写的业务代码,在文件系统里是完全平等的文本或字节流,没有任何一层告诉工具”这几个是机器生成的,只能由生成它的命令改写”。 模型读到一个 diff 需求,看到锁文件里有个版本号跟需求对不上,它就顺手改了——它做的事情和一个不了解项目约定的新同事一模一样。你骂模型不听话,其实是在骂一个没有收到约定的执行者。
把这件事看清楚,后面的动作就顺了:不是去写一段更严厉的指令让它”别碰这些文件”,而是在仓库里建立可被机器读到、可被 CI 强制、可被 review 一眼看见的标记。指令是软约束,标记是硬约束,只有硬约束能在长会话、多任务并发的情况下活下来。
一、先把”生成物”这个概念定义清楚
工程上真正需要保护的不是”二进制文件”这个类别,而是再生性文件——它的正确内容由某条命令加某组输入唯一决定,人手改它等于伪造了一个不可复现的状态。按这个定义分三类:
第一类,依赖锁文件。 package-lock.json、pnpm-lock.yaml、yarn.lock、poetry.lock、Cargo.lock、go.sum。它们记录的是一次完整依赖解析的结果,包含传递依赖、完整性哈希、解析来源。人手改一行版本号,锁文件就不再是任何一次解析的产物:本地能装上是因为缓存里正好有,CI 上装不上是因为哈希对不上,或者装上了但传递依赖跟锁文件描述的树不一致。
第二类,构建与快照产物。 dist/、build/、.next/、编译出的 .wasm、OpenAPI 生成的客户端代码、protobuf 生成的 *_pb2.py、ORM 生成的类型定义、快照测试的 .snap 基线。这些文件的共同特征是:源在别处。改产物不改源,下一次构建就把你的修改抹掉;更糟的情况是产物被改对了、源还是错的,测试绿了,线上炸了。
第三类,真二进制与准二进制。 图片、字体、.xlsx、.pdf、SQLite 文件、证书。模型没有能力正确生成这些字节流,但它有能力”看起来生成了”——把二进制当文本读进上下文再吐出来,编码一转,文件就废了。这类损坏往往不报错,只是图片打不开、字体渲染成方框。
站内另外两篇跟这个话题挨得很近,但分工不同:AI 改动范围失控 讲的是模型一次动了太多本不该动的源文件、怎么把作用域收回来;依赖版本冲突 讲的是版本号该怎么选、冲突怎么解。本篇不碰”选哪个版本”,只解决一件事——哪些文件根本不该由模型落笔,以及怎么让这条规则在仓库里长牙。
二、现象到成因:一张判别表
出问题时先别改代码,先定性。下面这张表按你实际看到的现象倒推:
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 本地装依赖正常,CI 报完整性校验失败 | 锁文件被手改,哈希与包内容不匹配 | 在干净目录用冻结模式安装(npm ci / pnpm install --frozen-lockfile / Yarn 2 及以上用 yarn install --immutable、Yarn 1 用 yarn install --frozen-lockfile),看是否复现 | 丢弃锁文件改动,用清单文件重新解析生成 |
| 锁文件 diff 有几万行,但 manifest 只改了一个包 | 换了包管理器版本或换了 registry,整个树被重解析 | 用 git diff --stat 看改动比例,再看 diff 里记录下载来源的字段(npm/yarn 的 resolved、pnpm 的 resolution)域名是否整体变了 | 回滚锁文件,统一工具版本后重跑一次安装 |
| 测试全绿,但功能明显不对 | 快照基线被直接改成了当前错误输出 | git log -p 看 .snap 的改动是否与源码改动同批提交、且无人解释 | 还原基线,重跑测试让它真实失败,再修源码 |
| 改了生成代码,重新构建后修改消失 | 改的是产物不是源 | 在仓库里搜生成器配置(模板、schema、.proto、OpenAPI 描述文件) | 改源并重跑生成命令,产物只由命令写入 |
| 图片、字体、Office 文件打不开或渲染异常 | 二进制被按文本读写,编码或换行被转换 | 若该文件尚未在属性文件里标为 binary:git diff --stat 正常应显示 Bin ... -> ... bytes,一旦变成逐行增删统计,说明它已被当成文本重写。若已标 binary(或 -diff),统计恒为 Bin,这条判据失效,改用十六进制查看器比对首部几个字节(魔数)和文件大小是否与上一个正常版本一致 | 从上一个正常提交还原该文件,禁止再纳入模型可写范围 |
| Git 合并时二进制文件出现冲突标记 | 该文件未被标记为 binary,合并驱动按行合并 | 打开文件看是否混入了冲突标记字符 | 还原文件,配置 .gitattributes,参考让 AI 解 Git 冲突的边界 |
| 文本文件被工具改动后编码或首字节变了 | 读写链路带入 BOM 或换行转换 | 用十六进制看首字节、检查行尾风格 | 从基线还原,并在属性文件里锁定行尾策略 |
判别表的价值在于先分因再动手。同样是”锁文件炸了”,手改导致的和工具版本不一致导致的,处置动作完全相反:前者要丢弃改动,后者丢弃了也白搭,必须先统一工具。
三、四层标记:从提示到强制
把保护做成四层,越往下越硬。只做上面两层的团队,迟早还会中招。
第一层,Git 属性层。 在仓库根建 .gitattributes,这是唯一一个所有 Git 客户端、多数代码托管平台和不少 IDE 都会读的机器可读约定:
# 真二进制:禁止行尾转换、禁止按行 diff/merge
*.png binary
*.woff2 binary
*.xlsx binary
# 生成物:diff 折叠、在平台上标为 generated
package-lock.json linguist-generated=true -diff
pnpm-lock.yaml linguist-generated=true -diff
dist/** linguist-generated=true -diff
binary 是 Git 内置的宏属性,展开后等价于同时取消 diff、merge、text 三项,也就是关掉换行转换、关掉按行 diff、关掉按行合并,正好挡住”二进制被当文本改写”和”合并塞进冲突标记”两类损坏。linguist-generated=true 是代码托管平台侧的约定,效果是在 PR 里默认折叠这些文件,人肉 review 时视线不会被几千行噪音淹没。
给锁文件加 -diff 之前要知道它的代价:本地 git diff 从此对该文件只回一句”Binary files … differ”,git diff --stat 也只显示 Bin ... bytes。真要看逐行差异时,加 --text 可以强制按文本出补丁(注意 --stat 的统计不受 --text 影响,仍显示 Bin):
git diff --text -- package-lock.json
如果你的排查动作高度依赖直接读锁文件 diff,可以只保留 linguist-generated=true 而不加 -diff,折叠交给平台做。改完用一条命令验证属性真的生效了,输出会逐项列出每个文件最终命中的属性值:
git check-attr -a package-lock.json dist/app.js assets/logo.png
第二层,工具可见的项目约定。 大多数 AI 编程工具都会读仓库里的说明文件与忽略配置——项目级的说明文档、各家自己的 rules 文件、以及工具自带的忽略列表。这一层写什么比写多少重要。有效的写法是给规则加上为什么和替代动作,因为模型面对”禁止 X”时如果没有替代路径,往往会绕道完成任务:
锁文件、
dist/、__snapshots__/属于生成物。需要变更时不要编辑文件本身,改对应的清单或源,然后在回复里写出需要人执行的生成命令,由人执行。
这层是软约束,长会话里会被稀释。别把它当防线,当它是”减少无谓摩擦”的说明书。
第三层,CI 强制。 这是真正的防线,因为它不依赖任何人的自觉。下面三条检查能覆盖日常绝大多数场景:
# 1. 锁文件必须能被清洁安装消费
npm ci
# 2. 锁文件不得脱离 manifest 单独变动
git diff --name-only origin/main...HEAD > changed.txt
if grep -q 'package-lock\.json' changed.txt && ! grep -q '^package\.json$' changed.txt; then
echo "锁文件被单独修改,需人工确认"
exit 1
fi
# 3. 生成物必须与源一致:重跑生成后工作区应当干净
<你的生成命令>
git diff --exit-code -- <生成物目录>
第二条有两个落地细节值得先说清楚,否则很容易写出一条永远不报警、或者天天误报的检查。一是退出码:把判断写成 A && ! B && exit 1 这种链式结构,在开了 set -e 的脚本里,只要第一个 grep 没匹配上,整条链的退出码就是非零,CI 会莫名其妙地红掉;写成 if ... then ... fi,未命中时自然落到后续步骤,不会污染退出码。二是路径锚定:上面用 ^package\.json$ 锚定根目录的清单,是因为不锚定的话,package-lock.json 这一行本身也可能被宽松模式误命中,导致检查永远认为”清单也改了”而放行。多包仓库里根目录单一清单的假设不成立,正确做法是按目录配对——取出每个变动的锁文件所在目录,再确认同一目录下的清单是否也在变动清单里,逐对判断。
第三条是最有价值的一条。它把”产物是不是手改的”这个难以人工判断的问题,变成了一个确定性的可执行断言:重新生成一遍,若有差异就说明产物和源对不上。用它来守 protobuf、OpenAPI 客户端、ORM 类型定义都成立。
第四层,评审动线。 让改动”看得见”。PR 模板里放一行勾选:本次是否改动了锁文件或生成物、若改动是由哪条命令生成的。这不增加多少成本,但它把责任从”谁没发现”挪到了”谁没声明”。
四、已经改坏了:按顺序回滚
发现损坏时,动作顺序很重要,顺序错了会把可回滚的状态变成不可回滚的。
先冻结:停掉正在跑的会话或后台任务,别让它在你排查时继续写盘。再取证:git status 和 git diff --stat 记下受影响的文件清单,这份清单后面判断”改干净了没有”时要用。然后分类还原。
尚未提交的改动,直接从索引或 HEAD 还原单个文件:
git restore --source=HEAD --staged --worktree -- package-lock.json
已经提交但没推的,别急着 reset --hard 整个分支——那会连带丢掉同批次里正确的源码改动。用单文件回退更安全:
git checkout <好的提交> -- path/to/asset.png
锁文件的正确恢复方式不是”把那几行手工改回去”,而是让工具重写:先把锁文件整体还原到基线版本,确认 manifest 是你要的状态,再用统一的包管理器版本跑一次安装,由工具自己写出结果。手工拼出来的锁文件即使能装上,也不是一次真实解析的结果。
这里要区分两个力度不同的动作。还原到基线再安装是首选,因为基线锁文件里那些与本次改动无关的依赖,解析结果会被原样保留,改动面最小、最好 review。删除锁文件重新生成是重手段,它会让整棵树按当前时刻的仓库状态重解析,很多你没打算动的传递依赖可能一起跳版本,diff 大到没人看得动。只有在锁文件已经损坏到还原后仍装不上、或冲突反复调和不成时,才用后者,且用完必须把整个 diff 当成一次真实的依赖升级来对待——跑全量测试,而不是看一眼就合。
二进制资源只有一条路:从版本库里取回上一个正常版本。被文本化损坏的字节流没有修复手段,任何”再转回去”的尝试都是在浪费时间。
如果损坏已经进了主干甚至上了线,回滚策略要按影响面走:先让线上回到已知良好版本,再在分支上慢慢查,别把排查和止血放在同一条路径上。
五、什么情况下别再折腾
排查这类问题最大的成本不是修,是”以为快修好了”。给自己设三条止损线:
止损线一:同一个锁文件冲突,你已经手工调和了两轮还没通过冻结安装。 停手。按上一节的顺序来:先把锁文件整体还原到基线,确认 manifest 是你要的状态,重装一次;还原后仍装不上,才升级到删掉重生成。手工调和锁文件的期望收益接近零,因为你没法验证传递依赖树的正确性,只能验证”装上了”。
止损线二:让模型修它自己改坏的生成物,超过两次没有进展。 这时候它已经进入了在错误层面上打补丁的循环——改产物、跑测试、再改产物。判断依据很直接:看它每次改的是不是同一批产物文件。是的话,问题在源不在产物,把源的位置直接指给它,或者你自己跑生成命令。这类循环的识别可以看AI 修不好时的思维循环。
止损线三:受损文件是你无法重新生成的。 比如一个没有源文件的设计资源、一份手工维护的测试夹具二进制。这类文件一旦确认损坏且版本库里没有干净版本,继续在当前分支修就是沉没成本。换路:从上游重新获取,或者接受重做,同时立刻把它加进保护清单。
还有一个反直觉的判断:当你发现自己在给模型解释锁文件的格式时,方向就错了。 正确的目标不是让它学会写锁文件,而是让它压根不需要写。
六、避坑清单
坑一:把生成物加进 .gitignore 就以为万事大吉。 为什么会踩——忽略只影响 Git 是否追踪,不影响模型能否读写工作区里的文件;很多生成物(锁文件、快照基线)还必须提交,根本不能忽略。怎么避——忽略解决”要不要进版本库”,.gitattributes 加 CI 校验解决”能不能被手改”,两件事分开做。
坑二:只在会话开头说一次”别动锁文件”。 为什么会踩——长会话里早期指令会被后续内容挤压,多轮之后约束强度显著下降;并发会话之间更是互不知情。怎么避——约定写进仓库文件让每次都被读到,同时用 CI 兜底,别指望对话记忆。
坑三:让模型”顺手把依赖升一下”。 为什么会踩——升级依赖必然重写锁文件,这时候你已经默认授权它写生成物了,边界一破就难收。怎么避——依赖变更单独成一个提交、单独一次任务,改 manifest 由人或工具执行安装命令,锁文件只由命令产生。
坑四:快照测试基线被批量更新。 为什么会踩——更新基线的命令跑起来太顺手,一条命令就能让所有失败的快照变绿,而这恰好是”测试假通过”最常见的来源。怎么避——基线更新必须单独提交、diff 必须逐个看过;CI 上禁用自动更新模式。相关的假绿现象见测试假通过。
坑五:把二进制文件塞进上下文让模型”看一眼”。 为什么会踩——读进去是有损的,写出来更是;一旦它认为自己”读懂了”,就可能尝试重写。怎么避——工具的忽略配置里显式排除二进制扩展名,需要模型理解文件内容时,先用专门工具提取成文本,让它读提取结果。
坑六:迁移文件(database migrations)被当成普通源码改。 为什么会踩——已经执行过的迁移在语义上就是生成物,改它会让不同环境的迁移历史分叉,本地对、预发错。怎么避——已合并的迁移文件一律只增不改,需要修正就追加一个新迁移,把”历史只读”这条写进属性文件和评审清单。
坑七:.gitattributes 写完没验证。 为什么会踩——路径模式写错(比如漏了 **)不会有任何报错,规则静默失效,你以为有防线其实没有。怎么避——用 git check-attr -a <文件> 逐个确认,把这条也放进 CI 跑一遍。
收束:一份三分钟自检清单
这套东西的收益在于它是一次性的:配置好了以后,同一类事故不会再消耗你的注意力。今天可以照着过一遍:
- 仓库根有
.gitattributes吗?锁文件、dist/、二进制扩展名都覆盖到了吗?用git check-attr -a验过了吗? - CI 里有没有一条冻结安装(
npm ci或等价命令)?它是不是真的会因为锁文件损坏而失败? - 生成物有没有”重跑生成 +
git diff --exit-code”这条断言? - 快照基线的更新是不是必须单独提交、且不允许在 CI 上自动执行?
- 项目说明文件里,生成物那条规则有没有写清楚替代动作,而不只是一句”禁止修改”?
- 你的团队能在多久内说清楚”这个文件是谁生成的、用什么命令”?说不清的文件,就是下一次事故的现场。
最后提一句工具选择上的现实:部分海外 AI 编程工具在中国大陆存在服务区域限制,是否可用、以何种方式合规使用,请以各产品官方最新说明和当地法规为准,本文不提供任何访问方案,也不做产品背书。好在这件事不影响你今天要做的动作——上面这四层标记全都落在仓库侧,由 Git 和 CI 执行,跟你用哪家模型、哪个客户端无关。这也正是把约束放在仓库而不是放在对话里的好处:工具会换,仓库里的规则不会跟着一起换。