Узнайте, как оценить радиус поражения ИИ-патча через граф зависимостей. Скрипт на Python и BFS помогут найти скрытые поломки до мержа.
ИИ-сгенерированный патч может пройти все тесты и всё равно сломать импорт через три файла. Дифф маленький, тесты зелёные, изменённая функция выглядит корректно. Поломка проявляется только после мержа, когда нетронутый потребитель импортирует новую сигнатуру.
Прежде чем доверять патчу, ревьюеру нужна таблица дистанции зависимостей: какие локальные модули импортируют изменённый модуль напрямую, какие — через один шаг, и насколько глубока цепочка ниже по потоку. Это задача поиска по графу, а не проверка на глаз. Полезный сигнал — не сам патч модели, а статическая форма кодовой базы.
Эта статья строит скрипт радиуса поражения на основе 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():
"""Возвращает множество изменённых .py файлов относительно HEAD~1"""
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():
"""Все отслеживаемые .py файлы в репозитории"""
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):
"""Преобразует путь в имя модуля Python"""
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):
"""BFS от изменённых модулей, возвращает (модуль, дистанция)"""
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('Нет изменённых Python-файлов.')
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 и транзитивных — дальше. Поломка в общей утилите даст длинный хвост, а изменение в листовом модуле — почти ничего. Именно такая информация нужна ревьюеру до чтения диффа.
Рекурсивный обход импортов показывает все модули репозитория, но не близость к изменению. Поиск в ширину сохраняет дистанцию, потому что расширяется слоями: прямые потребители посещаются раньше косвенных, и каждый модуль хранит кратчайшее расстояние от изменённого файла.
Скрипт хранит только локальные исходные импорты. Он намеренно игнорирует установленные пакеты и модули стандартной библиотеки для поиска потребителей — эти модули не ревьюеры вашего патча. Это делает граф маленьким, а вывод сфокусированным на файлах, которые вы реально можете редактировать.
Сырая таблица дистанций полезна сама по себе, но длинный список может быть шумным. В этом случае бесплатный endpoint модели может превратить отсортированные строки в две короткие заметки: какие прямые потребители стоит прочитать в первую очередь и какие транзитивные кластеры, вероятно, можно пропустить. Бесплатный доступ к модели и бесплатный сервер от MonkeyCode делают этот шаг дешёвым, но скрипт не требует особого поведения модели — только обычной суммаризации.
Держите модель вне цикла принятия решений. Пользователь передаёт только вывод графа и просит организацию, а не одобрение. Если модель предлагает исправление, относитесь к нему как к ещё одному ИИ-патчу и прогоняйте через тот же процесс ревью.
importlib, импорты через строки, плагины и условная загрузка могут быть невидны графу.Командам с тяжёлыми динамическими импортами, мультиязычными монорепозиториями или рантаймами, основанными на плагинах, не стоит считать этот граф полным. Это карта триажа, а не доказательство безопасности. Также маленькому плоскому проекту с несколькими модулями он может не понадобиться — чтение диффа быстрее.
В следующий раз, когда будете ревьюить ИИ-патч, запустите скрипт радиуса поражения до открытия кода. Модули на дистанции 1 — там прячутся большинство сюрпризов, и часто это файлы, которые патч не трогал. Действуйте прямо сейчас: добавьте скрипт в свой рабочий процесс ревью и используйте вывод как чек-лист для проверки.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →