AI 生成的 patch 可能影响看似无关的文件,通过静态分析模块依赖链来量化改动影响范围。
AI 生成的补丁可以通过所有测试,却仍然在三步之遥的导入处引发故障。diff 很小,测试全绿,改动的函数看起来也没问题。但破坏性只有在合并之后才显现出来——一个从未触碰过的调用方导入了新的函数签名。
在信任一个补丁之前,评审者需要一张依赖距离表:哪些本地模块直接导入了被改动的模块,哪些在下一步才导入,下游链路有多深。这是一个图搜索问题,而不是靠感觉判断。真正有用的信号不是模型的补丁本身,而是代码库的静态结构。
本文从 Git 和 Python 标准库构建一个爆炸半径脚本。它是有意确定性的,所以在笔记本或免费 CI 服务器上运行方式完全一致。最后一个可选步骤仅用免费模型来总结已经计算好的列表。
将文件保存为 blast_radius.py,从 Git 仓库任意位置运行。
#!/usr/bin/env python3
import ast
import os
import subprocess
import sys
from collections import defaultdict, deque
ROOT = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()
def changed_py_files():
out = subprocess.check_output(
['git', 'diff', '--name-only', 'HEAD~1', 'HEAD'],
text=True,
cwd=ROOT,
)
return {
os.path.relpath(line.strip(), ROOT)
for line in out.splitlines()
if line.strip().endswith('.py')
}
def tracked_py_files():
out = subprocess.check_output(['git', 'ls-files', '*.py'], text=True, cwd=ROOT)
return {
os.path.relpath(line.strip(), ROOT)
for line in out.splitlines()
if line.strip().endswith('.py')
}
def module_name(path):
path = path.replace(os.sep, '/')
if path.startswith('./'):
path = path[2:]
if path.endswith('__init__.py'):
path = path[: -len('__init__.py')]
path = path.rstrip('/') or '.'
return '.'.join(part for part in path.split('/') if part)
return path[:-3].replace('/', '.')
def direct_imports(path):
full = os.path.join(ROOT, path)
try:
with open(full, encoding='utf-8') as f:
tree = ast.parse(f.read(), filename=path)
except (SyntaxError, UnicodeDecodeError):
return set()
found = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
found.add(alias.name.split('.')[0])
elif isinstance(node, ast.ImportFrom) and node.module:
found.add(node.module.split('.')[0])
return found
def build_graph(paths):
graph = defaultdict(set)
for path in paths:
graph[module_name(path)].update(direct_imports(path))
return graph
def consumers(graph):
result = defaultdict(set)
for src, deps in graph.items():
for dep in deps:
result[dep].add(src)
return result
def affected_modules(changed, graph):
changed_mods = {module_name(p) for p in changed}
consumer_map = consumers(graph)
seen = {}
queue = deque((m, 0) for m in changed_mods)
while queue:
mod, dist = queue.popleft()
if mod in seen:
continue
seen[mod] = dist
for user in consumer_map.get(mod, []):
if user not in seen:
queue.append((user, dist + 1))
return sorted(seen.items(), key=lambda kv: (kv[1], kv[0]))
if __name__ == '__main__':
changed = changed_py_files()
if not changed:
print('No Python files changed.')
sys.exit(0)
graph = build_graph(tracked_py_files())
rows = affected_modules(changed, graph)
for dist, mod in rows:
print(f'{dist:>2} {mod}')
输出列表中,距离 0 是被改动的模块,距离 1 是直接导入方,更远的是传递性导入方。常见工具模块的断裂可能产生很长的尾部分支,而叶子模块的改动几乎不会产生任何影响。这正是评审在阅读 diff 之前所需的那种分类信息。
递归导入遍历能告诉你仓库中的每一个模块,但不告诉你它们与改动的接近程度。广度优先搜索保留距离信息,因为它按层级扩展。直接调用方在被间接调用方之前被访问,且每个模块都保留从被改动文件出发的最短距离。
脚本仅存储本地源码导入。它有意忽略已安装包和标准库模块的调用方发现;那些模块不是你的补丁的评审者。这保持了图的精简和输出的聚焦——只关注你真正能编辑的文件。
披露:本文是 MonkeyCode 产品推广的一部分。
原始距离表本身已经有用,但长长的列表仍然可能嘈杂。对于这种情况,免费模型端点可以将排序后的行转化为两条简短笔记:哪些直接调用方值得优先阅读,哪些传递性集群大概可以安全跳过。MonkeyCode 的免费模型访问和免费服务器选项使这一步成本极低,但脚本不需要任何特殊模型行为——只需普通的摘要能力。
让模型远离决策循环。用户只提供图的输出并请求组织整理,而不是审批。如果模型建议了一个修复,请将该建议视为另一个 AI 生成的补丁,并将其纳入相同的评审工作流程。
它只能看到静态的 Python 导入。importlib 动态调用、基于字符串的导入、插件和条件加载可能对图不可见。
它追踪的是本地源码导入,而不是跨二进制文件、生成代码或其他语言的运行时依赖。
它构建的是语法图,而不是语义图。只有在被改动的符号实际破坏了其契约时,下游调用方才会受到影响。
它假设使用 Git 和 Python 3.9+,且在具有常规包布局的仓库中效果最佳。
拥有大量动态导入、混合语言 monorepo 或插件密集型运行时的团队,不应该将此图视为完整的。它是一张分类地图,而不是安全证明。同样,只有少数模块的扁平小项目可能不需要它;直接阅读 diff 更快。
下次你评审一个 AI 生成的补丁时,在打开代码之前先运行爆炸半径脚本。距离 1 处的模块是大多数意外藏身之处,而且往往是补丁没有触碰过的文件。