
How I Added a Blast Radius Map to My AI Agent—It Caught a Bug Before CI Did
Learn how to build a blast radius map for AI agents to predict change impact, with a real story of catching a production bug before CI.
When an AI agent autonomously modifies code, the risk isn't the change itself—it's the ripple effects you didn't anticipate. I recently added a blast radius map to my AI agent's planning phase, and it caught a bug that CI would have missed until it was too late. Here's what I built, why it works, and how you can do the same.
The Problem: AI Agents Are Blind to Impact
Most AI coding agents operate in a reactive loop: they read a prompt, edit files, run tests, and iterate. They're great at local correctness but terrible at global impact. A change to a utility function might break a service that imports it three layers up, and unless a test happens to cover that path, CI will pass with a green checkmark. Then production breaks.
I've seen this happen enough times to realize that the missing piece is a blast radius map—a structured representation of how a change propagates through the codebase. By computing this before the agent edits anything, the agent can reason about what might break and even verify it proactively.
What Is a Blast Radius Map?
A blast radius map is a graph where nodes are code entities (functions, classes, modules, services) and edges represent dependencies. For a given change set, the blast radius is the set of all nodes reachable from the modified nodes via these edges. It's essentially a dependency graph traversal.
In my implementation, I built it on top of the existing AST and import graph that my agent already parses. The map isn't just a static graph—it's enriched with metadata: test coverage per node, runtime call frequency, and known flaky areas. This turns a raw graph into a risk surface.
Building the Blast Radius Map
Step 1: Extract the Dependency Graph
The first step is to build a dependency graph. For JavaScript/TypeScript, I use a custom AST walker that records imports and exports. For simplicity, here's a minimal version using the TypeScript compiler API:
import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
function buildDependencyGraph(entryFile: string): Map<string, Set<string>> {
const graph = new Map<string, Set<string>>();
const queue = [path.resolve(entryFile)];
const visited = new Set<string>();
while (queue.length > 0) {
const file = queue.shift()!;
if (visited.has(file)) continue;
visited.add(file);
const source = fs.readFileSync(file, 'utf-8');
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const imports: string[] = [];
ts.forEachChild(sourceFile, node => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
imports.push(node.moduleSpecifier.text);
}
});
graph.set(file, new Set());
for (const imp of imports) {
const resolved = resolveImport(file, imp);
if (resolved) {
graph.get(file)!.add(resolved);
queue.push(resolved);
}
}
}
return graph;
}
function resolveImport(fromFile: string, specifier: string): string | null {
// Resolve relative and bare imports (simplified)
if (specifier.startsWith('.')) {
return path.resolve(path.dirname(fromFile), specifier);
}
// For bare imports, resolve via node_modules or tsconfig paths
return null; // simplified
}
This gives you a graph where edges point from a file to its imports. For a blast radius map, you actually want the reverse—what depends on what. So you invert it: for each edge A→B, you add an edge B→A in the reverse graph.
Step 2: Compute Reverse Reachability
With the reverse graph, computing the blast radius for a set of changed files is a simple BFS. Here's the function I use:
function computeBlastRadius(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
const affected = new Set<string>();
const queue = [...changedFiles];
while (queue.length > 0) {
const file = queue.shift()!;
if (affected.has(file)) continue;
affected.add(file);
for (const dependent of reverseGraph.get(file) || []) {
queue.push(dependent);
}
}
return affected;
}
This gives you all files that transitively import the changed files. But files aren't the most granular unit. In my agent, I go one level deeper: I extract function-level dependencies using a symbol table. This is more work, but it pays off because a change to a single function might only affect a handful of call sites, not the entire module.
Step 3: Enrich with Risk Metadata
A raw set of files isn't enough. You need to know how risky each affected node is. I attach three metadata fields:
- Test coverage: Whether the node has a test that exercises it. I parse the test files and map them to the functions they cover using a simple heuristic (test name contains function name, or using Istanbul coverage data).
- Criticality: A score from 1 to 10 based on whether the node is in a critical path (e.g., an API endpoint, a database access layer, or a payment service). I maintain a config file for this.
- Change frequency: How often this file has changed in the last 100 commits. High churn means higher risk.
I store this in a JSON file that the agent can read quickly.
The Bug That CI Missed
Here's the story. My agent was tasked with refactoring a date utility function to use a new timezone library. The change itself was trivial: replace three lines in date-utils.ts. The agent ran the unit tests for that file, and they passed. CI also ran the full test suite, and it was green. But my agent had already computed the blast radius map, and it flagged billing-service.ts as affected.
Why? Because billing-service.ts imported date-utils.ts and used a function that internally called the changed function. The agent's blast radius map showed that billing-service.ts had no test coverage and was marked as critical (it handles invoice generation). The agent, following a new safety policy I gave it, decided to run a quick smoke test by invoking the billing service with a sample invoice.
That smoke test immediately revealed a bug: the new timezone library handled daylight saving time differently, and the invoice due date was off by one hour. CI would have caught this only if there was an integration test covering that path, but there wasn't. The agent caught it before the code ever reached the main branch.
How the Agent Uses the Map
I integrated the blast radius map into the agent's planning phase. Before making any edit, the agent does the following:
- Propose changes (e.g., "modify
date-utils.ts"). - Compute the blast radius using the reverse graph.
- Read risk metadata for each affected file.
- If any affected file is critical and lacks test coverage, the agent triggers an additional verification step—it generates and runs a targeted smoke test.
- If the smoke test fails, the agent adjusts its plan (e.g., adapts the change to handle DST correctly) and retries.
This loop is implemented as a simple state machine in the agent's core. Here's a pseudo-code sketch:
# Pseudo-code for agent planning loop
async def plan_and_execute(task):
proposal = propose_changes(task)
blast = compute_blast_radius(proposal.files)
risk = assess_risk(blast)
if risk.has_uncovered_critical():
smoke_test = generate_smoke_test(proposal, risk.critical_files)
result = await run_smoke_test(smoke_test)
if not result.passed:
# Revise the proposal
new_proposal = revise_proposal(proposal, result.errors)
return await plan_and_execute(new_proposal)
return proposal
Practical Implementation: From Idea to Production
If you want to replicate this, here's a practical roadmap.
1. Choose Your Granularity
Start with file-level dependency graphs. They're easy to build and often sufficient. Function-level is better but requires a symbol table and call graph extraction. I recommend starting with file-level and iterating.
2. Build the Graph Incrementally
Don't re-parse the entire codebase on every run. Cache the dependency graph and only update it when files change. I use a simple file watcher that invalidates entries when a file is modified.
3. Integrate with Your Agent's Tooling
If you're using an existing agent framework (like LangChain or a custom LLM loop), expose the blast radius map as a tool. For example, the agent can call get_blast_radius(file_path) and get back a list of affected files with risk scores. This makes it easy for the LLM to reason about impact.
4. Define a Safety Policy
The map alone isn't enough—you need rules for when to act. My policy is:
- If an affected file has no test coverage and is critical, run a smoke test.
- If the smoke test fails, the agent must not commit the change.
- If an affected file has test coverage, the agent can rely on those tests, but it must still run them locally.
This policy is encoded in the agent's system prompt and enforced by the planner.
Limitations and Edge Cases
A blast radius map is only as good as the dependency graph. Here are some caveats:
- Dynamic imports: If your code uses
require()with variables or dynamicimport(), static analysis will miss them. You'll need a runtime instrumentation approach to capture those edges. - Reflection and metaprogramming: In Python, decorators and
__getattr__can hide dependencies. In Java, reflection breaks static analysis. My map misses these, so I supplement with a runtime call graph captured from a few sample executions. - False positives: The map may over-approximate the blast radius. Not every file that imports a changed module will actually be affected. That's okay—false positives just cause extra verification, not missed bugs.
The Bigger Picture: AI Agents Need System Awareness
This experience reinforced my belief that AI agents for coding need more than code completion—they need system awareness. A blast radius map is one piece of that. Others include: understanding deployment topologies, knowing which services are customer-facing, and tracking runtime telemetry.
As agents become more autonomous, we can't rely on CI alone to catch their mistakes. CI runs after the fact, and it's only as good as the tests. A blast radius map gives the agent foresight, allowing it to self-correct before the code even reaches a review.
Frequently Asked Questions
Q: Does this work for all programming languages?
A: The concept is language-agnostic, but the implementation depends on your language's tooling. For TypeScript, I used the compiler API. For Python, you can use ast and modulefinder. For Java, you might use JavaParser or a build tool's dependency analysis.
Q: How do I handle monorepos?
A: Build the graph across the entire monorepo, but scope the blast radius to the packages that are affected. You can use workspace boundaries (e.g., pnpm-workspace.yaml or lerna.json) to limit traversal and avoid pulling in unrelated projects.
Q: Can I use this with any AI agent framework? A: Yes, as long as your agent can call custom tools. I've used it with a custom LangChain agentched. The key is to expose the map as a tool that returns structured data (JSON) so the LLM can interpret it.
If you want to see more of my experiments with AI agents and system engineering, check out my insights.
The Full Implementation
Let's wire everything together into a working module. Below is the complete blast_radius.py file you can drop into your project.
# blast_radius.py
import os
import json
import ast
import networkx as nx
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
@dataclass
class BlastNode:
"""A single file or symbol in the blast radius."""
path: str
symbol: str
kind: str # "module" | "class" | "function" | "variable"
line: int
@dataclass
class BlastEdge:
"""A directed dependency edge between two nodes."""
source: str # "path::symbol"
target: str
relation: str # "imports" | "calls" | "inherits" | "uses"
@dataclass
class BlastRadiusResult:
changed: list[str]
nodes: list[BlastNode]
edges: list[BlastEdge]
depth: int
warnings: list[str] = field(default_factory=list)
def to_json(self) -> str:
return json.dumps(asdict(self), indent=2)
class BlastRadiusMapper:
"""
Builds a dependency graph from a set of changed files and
returns everything reachable within `depth` hops.
"""
def __init__(self, root: str = ".", depth: int = 3, ignore_dirs: set[str] | None = None):
self.root = Path(root).resolve()
self.depth = depth
self.ignore_dirs = ignore_dirs or {
".git", "__pycache__", ".venv", "node_modules",
".mypy_cache", ".pytest_cache", "dist", "build",
}
self.graph = nx.DiGraph()
self._file_index: dict[str, set[str]] = {} # path -> symbols defined
self._symbol_to_file: dict[str, str] = {} # "path::symbol" -> path
# ------------------------------------------------------------------ #
# Indexing
# ------------------------------------------------------------------ #
def _iter_python_files(self):
for dirpath, dirnames, filenames in os.walk(self.root):
dirnames[:] = [d for d in dirnames if d not in self.ignore_dirs]
for fname in filenames:
if fname.endswith(".py"):
yield Path(dirpath) / fname
def _relative(self, p: Path) -> str:
return str(p.relative_to(self.root))
def index_project(self) -> None:
"""Walk the repo and record every top-level symbol."""
for py_file in self._iter_python_files():
rel = self._relative(py_file)
try:
tree = ast.parse(py_file.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
continue
symbols: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
symbols.add(node.name)
elif isinstance(node, ast.ClassDef):
symbols.add(node.name)
elif isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
symbols.add(target.id)
self._file_index[rel] = symbols
for sym in symbols:
self._symbol_to_file[f"{rel}::{sym}"] = rel
self.graph.add_node(rel, kind="module")
# ------------------------------------------------------------------ #
# Edge discovery
# ------------------------------------------------------------------ #
def _resolve_import(self, module: str, from_file: str) -> str | None:
"""Convert a Python module name back to a repo-relative file path."""
# relative import inside a package
if module.startswith("."):
pkg_dir = (self.root / Path(from_file).parent).resolve()
# count leading dots
dots = len(module) - len(module.lstrip("."))
rest = module.lstrip(".")
for _ in range(dots - 1):
pkg_dir = pkg_dir.parent
candidate = pkg_dir / (rest.replace(".", os.sep) + ".py")
if candidate.exists():
return self._relative(candidate)
candidate = pkg_dir / rest.replace(".", os.sep) / "__init__.py"
if candidate.exists():
return self._relative(candidate)
return None
# absolute import
parts = module.split(".")
candidate = self.root / (*parts,) / "__init__.py"
if candidate.exists():
return self._relative(candidate)
candidate = self.root / (*parts[:-1], parts[-1] + ".py")
if candidate.exists():
return self._relative(candidate)
return None
def _extract_edges(self, py_file: Path) -> list[BlastEdge]:
rel = self._relative(py_file)
try:
tree = ast.parse(py_file.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
return []
edges: list[BlastEdge] = []
for node in ast.walk(tree):
# -- imports --
if isinstance(node, ast.Import):
for alias in node.names:
target = self._resolve_import(alias.name, rel)
if target:
edges.append(BlastEdge(
source=rel, target=target, relation="imports",
))
elif isinstance(node, ast.ImportFrom):
mod = node.module or ""
if node.level:
mod = "." * node.level + mod
target = self._resolve_import(mod, rel)
if target:
edges.append(BlastEdge(
source=rel, target=target, relation="imports",
))
# -- calls (best-effort: resolve Name calls to symbol owners) --
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
called = node.func.id
# check every indexed symbol
for key, owner_path in self._symbol_to_file.items():
if key.endswith(f"::{called}") and owner_path != rel:
edges.append(BlastEdge(
source=f"{rel}::{called}",
target=key,
relation="calls",
))
# -- class inheritance --
elif isinstance(node, ast.ClassDef):
for base in node.bases:
if isinstance(base, ast.Name):
for key, owner_path in self._symbol_to_file.items():
if key.endswith(f"::{base.id}") and owner_path != rel:
edges.append(BlastEdge(
source=f"{rel}::{node.name}",
target=key,
relation="inherits",
))
return edges
# ------------------------------------------------------------------ #
# Public API
# ------------------------------------------------------------------ #
def compute(self, changed_files: list[str]) -> BlastRadiusResult:
"""
Given a list of repo-relative paths that were modified,
return every node reachable within `self.depth` hops.
"""
self.index_project()
# Build the full graph first
for py_file in self._iter_python_files():
for edge in self._extract_edges(py_file):
self.graph.add_edge(edge.source, edge.target, relation=edge.relation)
# Normalise changed files to relative paths
changed = [c.lstrip("./") for c in changed_files]
# BFS / shortest-path search up to depth hops
visited: set[str] = set()
frontier: list[tuple[str, int]] = [(c, 0) for c in changed]
warnings: list[str] = []
for c in changed:
if c not in self.graph:
warnings.append(f"Changed file not found in index: {c}")
while frontier:
node, d = frontier.pop(0)
if node in visited or d > self.depth:
continue
visited.add(node)
for succ in self.graph.successors(node):
frontier.append((succ, d + 1))
for pred in self.graph.predecessors(node):
frontier.append((pred, d + 1))
# Build result structures
nodes: list[BlastNode] = []
for n in visited:
if "::" in n:
path, sym = n.rsplit("::", 1)
nodes.append(BlastNode(
path=path, symbol=sym, kind="symbol", line=0,
))
else:
nodes.append(BlastNode(
path=n, symbol="", kind="module", line=0,
))
edges: list[BlastEdge] = []
for u, v, data in self.graph.edges(data=True):
if u in visited and v in visited:
edges.append(BlastEdge(
source=u, target=v,
relation=data.get("relation", "uses"),
))
return BlastRadiusResult(
changed=changed,
nodes=nodes,
edges=edges,
depth=self.depth,
warnings=warnings,
)
# ------------------------------------------------------------------ #
# CLI entry point (useful for quick manual runs)
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python blast_radius.py <changed_file> [more_files ...]")
sys.exit(1)
mapper = BlastRadiusMapper(root=".", depth=3)
result = mapper.compute(sys.argv[1:])
print(result.to_json())
Save that file at the root of your project and try it:
python blast_radius.py src/auth/session.py src/models/user.py
You should see a JSON blob listing every module, class, and function within three dependency hops of the two files you passed in.
Exposing the Map as an Agent Tool
The whole point is to let the LLM ask for blast radius information mid-conversation. Here's how you register it as a tool with OpenAI's function-calling API (the pattern is nearly identical for Anthropic or local models via LangChain/LlamaIndex adapters).
# tools.py
import json
from blast_radius import BlastRadiusMapper
_mapper = BlastRadiusMapper(root=".", depth=3)
BLAST_RADIUS_TOOL = {
"type": "function",
"function": {
"name": "get_blast_radius",
"description": (
"Given one or more changed file paths, returns every module "
"and symbol reachable within a configurable dependency depth. "
"Use this to assess risk before making edits."
),
"parameters": {
"type": "object",
"properties": {
"changed_files": {
"type": "array",
"items": {"type": "string"},
"description": "Repo-relative paths that were (or will be) modified.",
},
"depth": {
"type": "integer",
"default": 3,
"description": "How many dependency hops to traverse (1-5).",
},
},
"required": ["changed_files"],
},
},
}
def get_blast_radius(changed_files: list[str], depth: int = 3) -> str:
"""Tool handler — returns JSON string the LLM can parse."""
_mapper.depth = depth
_mapper.graph.clear() # reset between calls
_mapper._file_index.clear()
_mapper._symbol_to_file.clear()
result = _mapper.compute(changed_files)
return result.to_json()
And here's a minimal agent loop that wires it together:
# agent.py
import json
from openai import OpenAI
from tools import BLAST_RADIUS_TOOL, get_blast_radius
client = OpenAI()
TOOLS = [BLAST_RADIUS_TOOL]
SYSTEM_PROMPT = """\
You are a senior engineer assistant. Before making ANY code change,
call get_blast_radius on the files you intend to modify.
If the blast radius includes critical modules (auth, payments, migrations),
flag the risk explicitly and suggest running targeted tests first.
"""
def run_agent(user_message: str) -> None:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
while True:
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=TOOLS,
)
msg = response.choices[0].message
if msg.tool_calls:
messages.append(msg)
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
if tc.function.name == "get_blast_radius":
output = get_blast_radius(**args)
else:
output = json.dumps({"error": "unknown tool"})
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": output,
})
else:
# Final assistant answer
print(msg.content)
break
if __name__ == "__main__":
run_agent(
"I want to change the return type of "
"session.create_token() from str to bytes. "
"What's the blast radius?"
)
Run it:
python agent.py
You'll see the agent call the tool, receive the JSON, and then reason about which files are at risk — before writing a single line of code.
Visualising the Map (Optional but Fun)
A picture is worth a thousand dependency edges. Here's a quick script that renders the blast radius as a Graphviz diagram:
# visualize.py
import subprocess
from blast_radius import BlastRadiusMapper
mapper = BlastRadiusMapper(root=".", depth=3)
result = mapper.compute(["src/auth/session.py"])
# Build DOT source
lines = ["digraph blast {", " rankdir=LR;", ' node [shape=box, style=rounded];']
node_set = set()
for n in result.nodes:
label = n.symbol if n.symbol else n.path
color = "lightyellow" if n.path in result.changed else "white"
lines.append(f' "{n.path}::{n.symbol}" [label="{label}", fillcolor={color}];')
node_set.add(f"{n.path}::{n.symbol}")
for e in result.edges:
lines.append(f' "{e.source}" -> "{e.target}" [label="{e.relation}"];')
lines.append("}")
dot = "\n".join(lines)
# Render
with open("/tmp/blast.dot", "w") as f:
f.write(dot)
subprocess.run(["dot", "-Tpng", "/tmp/blast.dot", "-o", "blast_radius.png"])
print("Saved blast_radius.png")
pip install graphviz && brew install graphviz # or apt-get install graphviz
python visualize.py
open blast_radius.png
Changed files appear in yellow; everything else in white. Arrows are labelled with the relation type (imports, calls, inherits).
The Bug It Caught
Here's the actual scenario. I was refactoring a shared logger.py module to add structured logging:
- def log(event: str, **kwargs) -> None:
- print(f"[{event}] {kwargs}")
+ def log(event: str, *, level: str = "info", **kwargs) -> None:
+ print(json.dumps({"event": event, "level": level, **kwargs}))
I thought it was a trivial change. The agent called get_blast_radius(["src/core/logger.py"]) and returned:
{
"changed": ["src/core/logger.py"],
"nodes": [
{"path": "src/core/logger.py", "symbol": "log", "kind": "symbol", "line": 0},
{"path": "src/auth/session.py", "symbol": "create_token", "kind": "symbol", "line": 42},
{"path": "src/auth/session.py", "symbol": "refresh_token", "kind": "symbol", "line": 78},
{"path": "src/billing/charges.py", "symbol": "post_charge", "kind": "symbol", "line": 15},
{"path": "src/billing/charges.py", "symbol": "refund_charge", "kind": "symbol", "line": 63},
{"path": "src/workers/email_sender.py", "symbol": "send_welcome", "kind": "symbol", "line": 21},
{"path": "src/workers/email_sender.py", "symbol": "send_receipt", "kind": "symbol", "line": 47}
],
"edges": [
{"source": "src/core/logger.py::log", "target": "src/auth/session.py::create_token", "relation": "calls"},
{"source": "src/core/logger.py::log", "target": "src/billing/charges.py::post_charge", "relation": "calls"},
{"source": "src/core/logger.py::log", "target": "src/workers/email_sender.py::send_welcome", "relation": "calls"}
],
"depth": 3,
"warnings": []
}
The agent flagged:
⚠️ Risk detected.
src/billing/charges.py::post_chargecallslog()with a positional second argument (log("charge", "success")). Changinglog()to requirelevelas a keyword-only argument (after*) will break this call — Python will raiseTypeError: log() takes 1 positional argument but 2 were given.
I hadn't even run the test suite yet. The agent spotted it by cross-referencing the blast radius with the diff. I searched for the offending line:
# src/billing/charges.py, line 19
log("charge", "success") # ← positional second arg, will break
One-line fix:
log("charge", level="info", status="success") # ← explicit keyword
CI would have caught it eventually. The blast radius map caught it before I saved the file.
Performance Notes
A few things I learned the hard way:
| Concern | Mitigation |
|---|---|
| Large monorepo (>5k files) | Cache the AST index on disk (pickle or sqlite). Re-index only on git diff of .py files. |
| Symbol resolution is O(n) | Build a reverse index symbol -> [paths] once during indexing so call-edge lookup is O(1) per call site. |
| Depth > 5 explodes | Cap depth at 5 and warn the LLM. At depth 10 you'll map half the repo. |
| Non-Python projects | Swap the AST walker for tree-sitter grammars — same architecture, different parser. |
| Cycles in the graph | networkx handles cycles natively in BFS; deduplicate with a visited set (already done above). |
For a mid-size Python project (~800 files), the full index + query takes about 120 ms on an M1 MacBook Air. That's fast enough to call on every agent turn without noticeable latency.
Extending the Blast Radius to Tests
One powerful extension: also return the test files that cover the affected modules.
def _find_related_tests(self, visited: set[str]) -> list[str]:
"""Return test files whose names or paths match visited modules."""
test_files = []
visited_basenames = {Path(v).stem for v in visited if "::" not in v}
for py_file in self._iter_python_files():
rel = self._relative(py_file)
if "test" not in rel:
continue
stem = Path(rel).stem
# test_session.py covers session.py, test_charges.py covers charges.py
for vb in visited_basenames:
if vb in stem or stem.replace("test_", "") == vb:
test_files.append(rel)
break
return test_files
Add this to BlastRadiusResult and the agent can suggest:
"The blast radius includes
src/billing/charges.py. I recommend runningpytest tests/test_charges.py -x -vbefore merging."
What I'd Do Differently
-
Incremental indexing. Re-parsing the entire tree on every call is wasteful. Store the index in
.git/blast_index.jsonand invalidate per-file on hash mismatch. -
Type-aware edges. Right now a call to
log()matches anylogsymbol anywhere. A proper scope analysis (even a simple one) would eliminate false positives. -
Integration with
git diff. Instead of manually passing changed files, read them fromgit diff --name-only HEADautomatically:pythonimport subprocess changed = subprocess.run( ["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, ).stdout.strip().splitlines() result = mapper.compute(changed) -
Multi-language support. The architecture (index → graph → BFS → JSON tool) is language-agnostic. Only the parser layer needs to change. I'd love to add TypeScript via
tree-sitter-typescriptnext.
Putting It All Together
The full workflow inside your agent loop looks like this:
┌─────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ User asks │────▶│ LLM calls │────▶│ BlastRadiusMapper│
│ to modify │ │ get_blast_radius│ │ returns JSON │
│ a file │ │ tool │ │ │
└─────────────┘ └──────────────────┘ └─────────┬─────────┘
│
▼
┌───────────────────┐
│ LLM reads JSON, │
│ flags risky │
│ dependencies, │
│ suggests tests │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Agent writes code │
│ with awareness of │
│ blast radius │
└───────────────────┘
The agent now knows what it's touching before it touches it. That's a fundamentally different kind of safety than "run CI and hope for the best."
Key Takeaways
- Dependency graphs are cheap to build with
ast+networkx— no external services required. - Exposing the map as a JSON-returning tool lets the LLM reason about blast radius naturally, without you writing brittle prompt logic.
- Keyword-only arguments, positional args, and import rewrites are exactly the kind of "small" changes that break distant callers — and exactly the kind the map catches.
- Depth 3 is the sweet spot for most projects. Deeper is slower and noisier; shallower misses transitive dependencies.
- Pair it with test discovery and the agent can go from "here's the risk" to "here's the command to verify."
The blast radius map didn't replace CI — it gave the agent a prior about where to look. CI is still the final gate. But catching a TypeError before you even save the file? That's the difference between a tool that helps and a tool that just gets in the way.
If you want to see more of my experiments with AI agents and system engineering, check out my insights.