交付前别只看测试过没过:我用三段 AST 检查兜住 AI 改坏的注释

0 阅读3分钟

交付前别只看测试过没过:我用三段 AST 检查兜住 AI 改坏的注释

上周改一个配置加载函数,参数从 path 扩成 path 和 env。测试跑完是绿的,我就提交了。第二天同事来问:docstring 里怎么还写着只接收 path,返回值也写的是 dict,实际明明返回了两个对象。

我回去看了一眼,注释确实没跟着改。这种问题不影响运行,测试也不会红,但它会让接手的人先踩一遍坑再回来问你。

后来我把检查固定成三段,全部基于 Python 自带的 ast,不装额外依赖。写完跑一遍,几秒钟出结果,比在 diff 里翻快得多。

先说清楚边界:这套检查管的是「代码和说明是否一致」,不管逻辑对不对。逻辑还得靠测试和 review。

第一段,看公开函数有没有说明

我不数注释行数。十行「# 循环」不如一句「这里为什么要重试三次」。所以只看公开函数有没有 docstring。

import ast from pathlib import Path

def missing_docs(path): tree = ast.parse(Path(path).read_text(encoding="utf-8")) out = [] for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): if node.name.startswith("_"): continue if ast.get_docstring(node) is None: out.append((node.name, node.lineno)) return out

几个我自己踩过的点:

ast.get_docstring 认的是函数体第一句字符串,写在函数外面的 # 注释不算; 下划线开头的内部函数跳过,不强求; 只报缺失,不自动补。自动补出来的多半是「该函数用于处理数据」这种废话。

第二段,比对注释里的参数名和真实签名

Sphinx 风格的 :param xxx: 正好适合机械比对,因为参数名是显式写出来的。

import ast, re from pathlib import Path

PARAM = re.compile(r":param\s+(\w+)\s*:")

def param_mismatch(path): src = Path(path).read_text(encoding="utf-8") tree = ast.parse(src) bad = [] for node in ast.walk(tree): if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): continue doc = ast.get_docstring(node) or "" documented = set(PARAM.findall(doc)) if not documented: continue real = {a.arg for a in node.args.args} real |= {a.arg for a in node.args.kwonlyargs} extra = documented - real if extra: bad.append((node.name, sorted(extra), node.lineno)) return bad

extra 不为空,通常就是改签名时漏改了注释。反过来「签名有、注释没有」我不报,因为很多参数看名字就懂,强行要求每个都写,只会催生一堆占位文字。

这里有个坑:*args 和 **kwargs 在 AST 里是 vararg 和 kwarg,不在 args 列表里。项目里常用的话要单独加进去,否则会误报。

第三段,清掉遗留标记和删逻辑留下的 import

AI 改代码时很爱留 # TODO: 这里需要处理异常,也很容易在删掉一段逻辑后把 import 留在原地。

def leftovers(path): src = Path(path).read_text(encoding="utf-8") tree = ast.parse(src) todos = [(i + 1, line.strip()) for i, line in enumerate(src.splitlines()) if "TODO" in line or "FIXME" in line]

imported = {}
for node in ast.walk(tree):
    if isinstance(node, ast.Import):
        for a in node.names:
            imported[(a.asname or a.name).split(".")[0]] = node.lineno
    elif isinstance(node, ast.ImportFrom):
        for a in node.names:
            imported[a.asname or a.name] = node.lineno

used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
used |= {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
unused = [(k, v) for k, v in imported.items() if k not in used]
return todos, unused

未使用 import 只能当提示,不能当结论。有些包靠导入时注册副作用生效,AST 里看不到引用,但必须留着。我只处理那些明确是删逻辑留下的,拿不准就保留。

串起来的时候,退出码要分开

单独跑三段太麻烦,我包了一个入口。关键是退出码别一刀切:

公开函数缺说明、注释参数名对不上,算错误,返回 1; 新增 TODO、未使用 import,算提示,返回 0。

如果全算错误,跑两次之后大家就会习惯性忽略它,检查也就废了。

我的实际使用顺序是:先跑检查拿清单,逐条判断改不改,最后才看 diff。顺序反过来,很容易被几百行改动带着走,反而漏掉真正该看的那几处。

自查清单:

公开函数是否都有说明,且写清了返回什么; 改过签名的函数,注释参数名是否同步; 新增 TODO 是否有对应处理计划; 删逻辑后相关 import 是否清理; 检查脚本的退出码是否能区分错误与提示。