Dependency Distance: A Blast-Radius Check for AI-Generated Changes
An AI-generated patch can pass every test and still break an import three files away. The diff is small, the tests are green, and the changed function looks correct. The breakage shows up only after merge, when an untouched consumer imports the new signature.
Before trusting a patch, a reviewer needs a dependency-distance table: which local modules import the changed module directly, which import it one step later, and how deep the downstream chain goes. This is a graph-search problem, not a vibe check. The useful signal is not the model's patch; it is the static shape of the codebase.
This article builds a blast-radius script from Git and Python's standard library. It is intentionally deterministic, so it runs the same way on a laptop or a free CI server. The optional last step uses a free model only to summarize the already-computed list.
The script
Save the file as blast_radius.py and run it from anywhere in a Git repository.
#!/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}')
The output lists the changed module at distance 0, direct importers at distance 1, and transitive importers farther out. A break in a common utility can produce a long tail, while a change in a leaf module can produce almost nothing. That is exactly the kind of triage information a review needs before a human reads the diff.
Why BFS instead of a full dependency graph
A recursive imports walk tells you every module in the repository, but it does not tell you proximity to the change. Breadth-first search preserves distance because it expands in layers. Direct consumers are visited before indirect ones, and every module keeps the shortest distance from a changed file.
The script only stores local source imports. It deliberately ignores installed packages and standard-library modules for consumer discovery; those modules are not reviewers of your patch. That keeps the graph small and the output focused on files you can actually edit.
Optional free-model summary
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The raw distance table is useful on its own, but a long list can still be noisy. For that case, a free model endpoint can turn the sorted rows into two short notes: which direct consumers are worth reading first, and which transitive clusters are probably safe to skip. MonkeyCode's free model access and free server option make that step cheap, but the script does not require any special model behavior beyond plain summarization.
Keep the model out of the decision loop. The user supplies only the graph output and asks for organization, not approval. If the model suggests a fix, treat that suggestion as another AI-generated patch and run it through the same review workflow.
Limitations
- It sees only static Python imports. Dynamic
importlibcalls, string-based imports, plugins, and conditional loading can be invisible to the graph. - It follows local source imports, not runtime dependencies across binaries, generated code, or other languages.
- It builds a syntactic graph, not a semantic one. A downstream consumer is affected only if the changed symbols actually break its contract.
- It assumes Git and Python 3.9+, and it works best in a repository with a conventional package layout.
Who should not use this
Teams with heavy dynamic imports, mixed-language monorepos, or plugin-heavy runtimes should not treat this graph as complete. It is a triage map, not a safety proof. Likewise, a tiny flat project with a handful of modules may not need it; reading the diff is faster.
Start with the distance table
The next time you review an AI-generated patch, run the blast-radius script before opening the code. The modules at distance 1 are where most surprises hide, and they are often the files the patch did not touch.
Top comments (0)