AGENTS.md 别手写,让它自己从仓库里长出来

仓库里那份 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 全绿。

相关推荐
用户241015057020811 分钟前
给 100 块的 AI 玩具写了一套英语启蒙 App:100 个词、看图选词、开口打分
人工智能
黄啊码25 分钟前
从 0 到 1 做 FDE:如何获客、成交、交付?百万级 AI 项目实战复盘
人工智能
玩美数据35 分钟前
海外市场数据服务常用术语与名词解释权威指南
大数据·人工智能
Lansonli1 小时前
Claude Code 写 Shell 脚本翻车实录:一份避坑指南
人工智能
IT_陈寒1 小时前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端
东离与糖宝2 小时前
Spring AI 大模型容易混淆概念一次性讲透
人工智能
复杂网络与机器学习3 小时前
全双工 160 毫秒一格:李沐让模型不再「一问一答」
人工智能
武子康3 小时前
Qwen-Drive 的 91.4 分,究竟选中了什么?
大数据·人工智能·agent
王中阳Go4 小时前
你把重试加到 2 次那天,200 条里成功还是 181 条,你回看过吗?
人工智能·后端·架构