Originally published on tamiz.pro.

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:

  1. Propose changes (e.g., "modify date-utils.ts").
  2. Compute the blast radius using the reverse graph.
  3. Read risk metadata for each affected file.
  4. 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.
  5. 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 dynamic import(), 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) --  Source:https://dev.to/tamizuddin/how-i-added-a-blast-radius-map-to-my-ai-agent-it-caught-a-bug-before-ci-did-50k1

Related posts