ГлавнаяБлогРадиус поражения ИИ-патча: проверка зависимостей
Алгоритмы

Радиус поражения ИИ-патча: проверка зависимостей

Узнайте, как оценить радиус поражения ИИ-патча через граф зависимостей. Скрипт на Python и BFS помогут найти скрытые поломки до мержа.

Al
Редакция Algolitalgolit.ru
8 мин чтения14 августа 2026 г.

Зачем проверять радиус поражения ИИ-патча

ИИ-сгенерированный патч может пройти все тесты и всё равно сломать импорт через три файла. Дифф маленький, тесты зелёные, изменённая функция выглядит корректно. Поломка проявляется только после мержа, когда нетронутый потребитель импортирует новую сигнатуру.

Прежде чем доверять патчу, ревьюеру нужна таблица дистанции зависимостей: какие локальные модули импортируют изменённый модуль напрямую, какие — через один шаг, и насколько глубока цепочка ниже по потоку. Это задача поиска по графу, а не проверка на глаз. Полезный сигнал — не сам патч модели, а статическая форма кодовой базы.

Эта статья строит скрипт радиуса поражения на основе 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 и транзитивных — дальше. Поломка в общей утилите даст длинный хвост, а изменение в листовом модуле — почти ничего. Именно такая информация нужна ревьюеру до чтения диффа.

Почему BFS, а не полный граф зависимостей

Рекурсивный обход импортов показывает все модули репозитория, но не близость к изменению. Поиск в ширину сохраняет дистанцию, потому что расширяется слоями: прямые потребители посещаются раньше косвенных, и каждый модуль хранит кратчайшее расстояние от изменённого файла.

Скрипт хранит только локальные исходные импорты. Он намеренно игнорирует установленные пакеты и модули стандартной библиотеки для поиска потребителей — эти модули не ревьюеры вашего патча. Это делает граф маленьким, а вывод сфокусированным на файлах, которые вы реально можете редактировать.

Опциональное резюме с бесплатной моделью

Сырая таблица дистанций полезна сама по себе, но длинный список может быть шумным. В этом случае бесплатный endpoint модели может превратить отсортированные строки в две короткие заметки: какие прямые потребители стоит прочитать в первую очередь и какие транзитивные кластеры, вероятно, можно пропустить. Бесплатный доступ к модели и бесплатный сервер от MonkeyCode делают этот шаг дешёвым, но скрипт не требует особого поведения модели — только обычной суммаризации.

Держите модель вне цикла принятия решений. Пользователь передаёт только вывод графа и просит организацию, а не одобрение. Если модель предлагает исправление, относитесь к нему как к ещё одному ИИ-патчу и прогоняйте через тот же процесс ревью.

Ограничения скрипта

  • Видит только статические импорты Python. Динамические вызовы importlib, импорты через строки, плагины и условная загрузка могут быть невидны графу.
  • Следует локальным исходным импортам, а не рантайм-зависимостям между бинарниками, сгенерированным кодом или другими языками.
  • Строит синтаксический граф, а не семантический. Потребитель ниже по потоку затронут, только если изменённые символы реально ломают его контракт.
  • Предполагает Git и Python 3.9+, лучше всего работает в репозитории с обычной пакетной структурой.

Кому не стоит использовать этот скрипт

Командам с тяжёлыми динамическими импортами, мультиязычными монорепозиториями или рантаймами, основанными на плагинах, не стоит считать этот граф полным. Это карта триажа, а не доказательство безопасности. Также маленькому плоскому проекту с несколькими модулями он может не понадобиться — чтение диффа быстрее.

Начните с таблицы дистанций

В следующий раз, когда будете ревьюить ИИ-патч, запустите скрипт радиуса поражения до открытия кода. Модули на дистанции 1 — там прячутся большинство сюрпризов, и часто это файлы, которые патч не трогал. Действуйте прямо сейчас: добавьте скрипт в свой рабочий процесс ревью и используйте вывод как чек-лист для проверки.

#граф зависимостей#поиск в ширину#ревью кода#ИИ-патчи#Python
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →