仓库里那份 AGENTS.md 是去年手写的,写着"测试用 pytest"。那会儿确实用 pytest。
后来前后端共用一套测试脚本,跑法换成了 npm test。AGENTS.md 里那行没人想起去改。于是 Codex 每次要跑测试都先 npm test,报错,再翻 package.json,发现路径不对,重来。一来一回两分钟,一天十几次,谁也没觉得这是个问题,只觉得"这 AI 有点笨"。
问题不在模型。手写的约定文件一定会过期,因为它是从仓库里复制出来的第二份事实。凡是第二份事实,都会漂。
我的做法是这部分别手写,让脚本去仓库里扫。
只写能扫出来的东西
AGENTS.md 里真正有用的就三类:这个仓库是什么语言写的、入口在哪、测试怎么跑。这三样全在文件系统里,不用人记。
剩下两类要人判断:代码约定、禁止事项。这部分手写,但结构固定,让脚本原样带过去。
扫描有三个细节必须处理。node_modules 和 .git 一定要跳过,不然一个前端仓库能扫出五万个文件,脚本跑完 AGENTS.md 还没生成。测试文件的命名各语言不一样,Python 是 test_x.py 和 x_test.py,前端是 x.test.tsx,Go 是 x_test.go,一条正则得全兜住。包管理文件决定测试命令,有 package.json 就是 npm test,有 requirements.txt 或 pyproject.toml 就是 pytest。
整个脚本一个文件,零依赖:
python
"""生成并校验 AGENTS.md 项目约定文件。
思路:AI 编码工具每次都要重新猜这个仓库怎么跑、怎么测、哪些目录别碰。
人工写 AGENTS.md 容易过期,所以让脚本从仓库真实结构里扫出事实,再拼成约定文件。
校验部分用来保证生成的约定文件结构完整,可以塞进 CI 当守卫。
"""
from __future__ import annotations
import os
import re
import sys
from dataclasses import dataclass, field
SKIP_DIRS = {
".git", "node_modules", "dist", "build", ".venv", "venv",
"__pycache__", ".next", "target", ".idea", ".vscode", "coverage",
}
# 语言 -> 扩展名
LANG_EXT = {
"python": {".py"},
"javascript": {".js", ".jsx", ".mjs", ".cjs"},
"typescript": {".ts", ".tsx"},
"go": {".go"},
"rust": {".rs"},
"java": {".java"},
"vue": {".vue"},
}
# 入口文件候选,按优先级
ENTRY_CANDIDATES = [
"main.py", "app.py", "manage.py", "server.py",
"index.js", "index.ts", "main.ts", "src/main.ts", "src/index.js",
"main.go", "src/main.rs",
]
TEST_HINTS = re.compile(
r"(^test_.*.py$)|(^.*_test.py$)|(.test.[jt]sx?$)|(.spec.[jt]sx?$)|(_test.go$)"
)
MANIFESTS = [
"package.json", "pyproject.toml", "requirements.txt", "Pipfile",
"go.mod", "Cargo.toml", "pom.xml", "build.gradle",
]
REQUIRED_SECTIONS = ["项目概览", "常用命令", "代码约定", "禁止事项", "变更记录"]
@dataclass
class ScanResult:
languages: dict = field(default_factory=dict) # lang -> file count
entries: list = field(default_factory=list)
tests: list = field(default_factory=list)
manifests: list = field(default_factory=list)
top_dirs: list = field(default_factory=list)
file_count: int = 0
def scan(root: str, max_files: int = 20000) -> ScanResult:
"""扫目录,只统计事实,不做推断。"""
res = ScanResult()
seen = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
rel_dir = os.path.relpath(dirpath, root)
if rel_dir == ".":
res.top_dirs = sorted(dirnames)
for name in filenames:
seen += 1
if seen > max_files:
res.file_count = seen - 1
return res
ext = os.path.splitext(name)[1].lower()
for lang, exts in LANG_EXT.items():
if ext in exts:
res.languages[lang] = res.languages.get(lang, 0) + 1
break
if name in MANIFESTS:
res.manifests.append(os.path.join(rel_dir, name).replace("./", ""))
rel = os.path.join(rel_dir, name).replace("./", "")
if rel in ENTRY_CANDIDATES:
res.entries.append(rel)
if TEST_HINTS.search(name):
res.tests.append(rel)
res.file_count = seen
res.languages = dict(sorted(res.languages.items(), key=lambda kv: -kv[1]))
res.entries.sort()
res.tests.sort()
return res
def guess_test_command(res: ScanResult) -> str:
names = {m.split("/")[-1] for m in res.manifests}
if "package.json" in names:
return "npm test"
if "pyproject.toml" in names or "requirements.txt" in names or "Pipfile" in names:
return "python -m pytest -q"
if "go.mod" in names:
return "go test ./..."
if "Cargo.toml" in names:
return "cargo test"
return "(仓库里没找到包管理文件,自己补一条)"
def render(res: ScanResult) -> str:
lang_line = "、".join(f"{k}({v} 个文件)" for k, v in res.languages.items()) or "没识别出来"
entry_line = "、".join(res.entries[:3]) if res.entries else "没找到明显入口,需要人工补"
test_line = "、".join(res.tests[:3]) if res.tests else "没找到测试文件"
lines = [
"# AGENTS.md",
"",
"> 本文件由 scripts/agents_md.py 从仓库结构生成,改完项目记得重新跑。",
"",
"## 项目概览",
"",
f"- 主要语言:{lang_line}",
f"- 入口文件:{entry_line}",
f"- 测试文件:{test_line}",
f"- 顶层目录:{'、'.join(res.top_dirs) if res.top_dirs else '无'}",
f"- 文件总数:{res.file_count}",
"",
"## 常用命令",
"",
"```bash",
"# 装依赖:按下面的包管理文件走,不要另起一套",
]
if res.manifests:
lines += [f"# - {m}" for m in res.manifests]
else:
lines.append("# - 没找到包管理文件")
lines += [
"",
f"{guess_test_command(res)} # 跑测试",
"```",
"",
"## 代码约定",
"",
"- 改动前先把要动的文件和调用方读一遍,别只读当前文件。",
"- 新增函数必须带一个能被测试直接调用的入口,不要在函数里直接读命令行参数。",
"- 报错信息里写清楚是哪个输入触发的,别只写 failed。",
"- 提交前跑一次上面的测试命令,红了就先修再提交。",
"",
"## 禁止事项",
"",
"- 不要整目录重写,尤其是生成出来的文件和锁文件。",
"- 不要为了让测试过而改断言,除非先说明原断言是错的。",
"- 不要顺手升级依赖版本。",
"- 不要把密钥、内网地址、真实数据写进代码或注释。",
"",
"## 变更记录",
"",
"- 每改一次这个文件,在下面加一行日期和原因。",
f"- {res.file_count} 个文件的快照,由脚本生成。",
"",
]
return "\n".join(lines)
def validate(text: str) -> list:
"""返回问题列表,空列表表示通过。"""
problems = []
for sec in REQUIRED_SECTIONS:
if not re.search(rf"^##\s+{re.escape(sec)}\s*$", text, re.M):
problems.append(f"缺少章节:{sec}")
if len(text) > 6000:
problems.append(f"文件过长({len(text)} 字符),AI 读它会挤掉代码上下文")
# 空章节检查
blocks = re.split(r"^##\s+", text, flags=re.M)[1:]
for block in blocks:
title, _, body = block.partition("\n")
if not body.strip():
problems.append(f"章节为空:{title.strip()}")
return problems
def main(argv):
root = argv[1] if len(argv) > 1 else "."
out = argv[2] if len(argv) > 2 else os.path.join(root, "AGENTS.md")
res = scan(root)
text = render(res)
problems = validate(text)
with open(out, "w", encoding="utf-8") as f:
f.write(text)
print(f"写入 {out}:{len(text)} 字符")
if problems:
for p in problems:
print("校验失败:", p)
return 1
print("校验通过,5 个章节齐全")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
跑一遍
造了个小仓库练手:两个 Python 目录、一个 tsx、一个 readme。
bash
$ python3 agents_md.py ./demo ./demo/AGENTS.md
写入 ./demo/AGENTS.md:584 字符
校验通过,5 个章节齐全
生成的东西长这样:
markdown
# AGENTS.md
> 本文件由 scripts/agents_md.py 从仓库结构生成,改完项目记得重新跑。
## 项目概览
- 主要语言:python(4 个文件)、typescript(2 个文件)
- 入口文件:main.py
- 测试文件:tests/test_util.py、web/App.test.tsx
- 顶层目录:src、tests、web
- 文件总数:8
## 常用命令
```bash
# 装依赖:按下面的包管理文件走,不要另起一套
# - requirements.txt
python -m pytest -q # 跑测试
```
## 代码约定
- 改动前先把要动的文件和调用方读一遍,别只读当前文件。
- 新增函数必须带一个能被测试直接调用的入口,不要在函数里直接读命令行参数。
- 报错信息里写清楚是哪个输入触发的,别只写 failed。
- 提交前跑一次上面的测试命令,红了就先修再提交。
## 禁止事项
- 不要整目录重写,尤其是生成出来的文件和锁文件。
- 不要为了让测试过而改断言,除非先说明原断言是错的。
- 不要顺手升级依赖版本。
- 不要把密钥、内网地址、真实数据写进代码或注释。
## 变更记录
- 每改一次这个文件,在下面加一行日期和原因。
- 8 个文件的快照,由脚本生成。
测试文件那行抓到了 web/App.test.tsx。这个结果我一开始是拿不到的。
两个真踩到的坑
第一个是正则用错方法。最初写的是 TEST_HINTS.match(name),正则里有一条 (^test_.*.py$),锚定开头。App.test.tsx 命中的是 (.test.[jt]sx?$) 那条,没有 ^,按理说从中间也能匹配。但 match 只从字符串第 0 个位置开始试,不扫描中间,所以前端测试文件一个都没进列表。
这个错误很阴,因为纯 Python 项目里 test_util.py 是能匹配上的,扫出来看着正常,只有混了前端代码的仓库才露馅。改成 search 才对。
第二个是提前返回时忘了记账。我给扫描加了个文件数上限,防止误扫到超大目录把内存吃满。写出来是这样:
ini
for name in filenames:
seen += 1
if seen > max_files:
return res
...
res.file_count = seen
看着挺顺。但 return res 一旦走掉,最后那行赋值永远不执行,file_count 就一直是初始值 0。文件数没到上限的时候这条路不走,一切正常;到了上限才炸。
我的用例是 30 个文件、上限 10,跑出来 file_count == 0。改法是在 return 之前补一次 res.file_count = seen - 1。
这两个 bug 手动点页面一辈子测不出来。第二个尤其,只有真丢一组超限数据进去才现形。
校验当守卫用
validate 返回问题列表,空列表算通过。查三件事:五个章节齐不齐、有没有空章节、文件是不是超过六千字符。
最后一条不是凑数。AGENTS.md 是要塞进模型上下文的,你往里堆三千字,等于把半个源文件挤出窗口。见过有人把整个接口文档粘进去,然后抱怨模型答得越来越飘,其实上下文早被这堆东西占满了。
python
def validate(text):
problems = []
for sec in REQUIRED_SECTIONS:
if not re.search(r"^##\s+" + re.escape(sec) + r"\s*$", text, re.M):
problems.append("缺少章节:" + sec)
if len(text) > 6000:
problems.append("文件过长(" + str(len(text)) + " 字符),AI 读它会挤掉代码上下文")
blocks = re.split(r"^##\s+", text, flags=re.M)[1:]
for block in blocks:
title, _, body = block.partition("\n")
if not body.strip():
problems.append("章节为空:" + title.strip())
return problems
CI 里加一行,返回码非 0 就拦下:
bash
python3 scripts/agents_md.py . AGENTS.md || exit 1
什么时候跑
试过三个时机,最后留两个。
加了新目录、或者换了测试命令之后手动跑一次,顺手把 diff 看一眼。这东西的变化本来就该有人过一眼,全自动生成反而危险。
pre-commit 里挂一个,但只跑校验,不跑生成。生成的文件要是没被 commit 进去就报警。这条拦住过两次,都是改了包管理文件忘了重新生成。
第三个试过又放弃了:CI 里全量生成再跟仓库里的 diff,超阈值就失败。撑了两周。原因是"代码约定"那几段是人写的,脚本一扫就把整篇盖掉,diff 全是噪音。
所以现在脚本只管事实,## 代码约定 和 ## 禁止事项 的正文是写在脚本里的模板,每次原样带过去。想改措辞直接改模板字符串,不用跟扫描逻辑纠缠。
代码一个文件,无第三方依赖,Python 3.8 以上能跑。测试 20 个用例,python -m pytest -q 全绿。