A developer asks an AI assistant to add a single timestamp field to a data model, and the assistant edits the model file flawlessly; then the database migration crashes, the API returns incomplete responses, and the test suite collapses, because four other files needed coordinated changes that never happened. The hard problem is generating a coordinated set of edits across multiple files that collectively implement a feature, fix a bug, or refactor an interface. This coordination requires understanding the dependency structure of the repository, ordering edits so that each file sees a consistent view of the symbols it references, and detecting when a change in one file silently invalidates assumptions in another. This section formalizes the problem, introduces the edit graph abstraction for reasoning about multi-file changes, and builds a generation pipeline that produces consistent patches.
1. Why Single-File Generation Is Not Enough
Consider a seemingly simple task: "Add a created_at timestamp field to the
Experiment model." In a well-structured repository, this single requirement
touches at minimum five files:
- The model definition (
models/experiment.py): add the field. - The database migration (
migrations/add_created_at.py): generate the ALTER TABLE statement. - The serializer (
api/serializers.py): include the new field in API responses. - The test fixtures (
tests/conftest.py): update factory functions to supply the field. - The API documentation (
docs/api.mdor an OpenAPI spec): reflect the new response shape.
A coding agent that edits only the model file produces a change that fails on the next database query, returns incomplete API responses, and breaks the test suite. The SWE-bench benchmark (Jimenez et al., 2024, ICLR) found that approximately 67% of real GitHub issues require edits to two or more files, and roughly 31% require edits to four or more (circa 2024). Multi-file consistency is not an edge case; it is the common case.
When a coding agent misses even one file in a coordinated change, the result is not a minor inconvenience; it is a broken build, a failed deployment, or a silent data corruption that surfaces weeks later in production. Understanding the structure of multi-file edits is what separates a useful assistant from a liability.
Multi-file code generation produces a coordinated set of source code changes across multiple files in a repository. All cross-file references (imports, function calls, type annotations, configuration keys) must remain valid after every edit. Real software changes almost never live in a single file: a field added to a data model ripples into serializers, migrations, tests, and documentation. Any mismatch between these files produces runtime errors or silent data corruption. The approach builds a dependency graph of the planned edits and topologically sorts it so that each file is generated only after the files it references are finalized. Each generation step receives the actual content of its dependencies as prompt context. Use this approach whenever a change touches two or more files with cross-references; for isolated, single-file edits (a local bug fix, a docstring update), standard single-file generation is sufficient.
Formalizing Consistency
We can quantify the coordination challenge. Let \(F = \{f_1, f_2, \ldots, f_n\}\) be the set of files affected by a change, and let \(E = \{e_1, e_2, \ldots, e_n\}\) be the corresponding edits. A change is consistent if every symbol referenced in \(e_i\) is either defined in \(f_i\) itself or defined (with a compatible signature) in some \(f_j\) where \(e_j\) has already been applied. Formally, let \(\text{refs}(e_i)\) be the set of external symbols referenced by edit \(e_i\), and let \(\text{defs}(e_j)\) be the symbols defined by edit \(e_j\). The consistency condition is:
$$ \forall\, e_i \in E,\; \forall\, s \in \text{refs}(e_i): \exists\, e_j \in E \text{ such that } s \in \text{defs}(e_j) \land \text{sig}(s, e_j) \text{ is compatible with } \text{usage}(s, e_i) $$where \(\text{sig}(s, e_j)\) is the signature of symbol \(s\) as defined in edit \(e_j\), and \(\text{usage}(s, e_i)\) is how edit \(e_i\) calls or references \(s\). This condition holds automatically in single-file edits (where all references resolve internally) but becomes a genuine constraint when edits span the module boundary.
Mental Model
Think of multi-file code generation like renovating a kitchen where the plumber, electrician, and tile installer must coordinate. The plumber runs pipes first because the electrician needs to know where the water lines are before routing wires around them, and the tile installer cannot lay tile until both pipes and wires are in the walls. If the electrician starts before the plumber finishes, she has to guess where the pipes will go, and a wrong guess means ripping out finished work. The edit graph is the renovation schedule: it sequences the trades (file edits) so each worker (generation step) sees the actual work of the trades before it, not a prediction. The consistency condition is the building inspector's checklist: every wire must connect to a real outlet, every pipe must reach a real fixture, and every tile must cover a real wall surface.
The order in which a coding agent generates edits matters enormously. If the agent generates the serializer edit before the model edit, it must predict the exact field name, type annotation, and default value that the model edit will introduce. Any mismatch creates an inconsistency. Generating edits in dependency order (model first, then serializer) lets each subsequent edit reference the actual definitions produced by earlier edits, eliminating prediction errors. This is the same principle behind topological sorting in build systems: define before reference.
Common Misconception
A frequent misconception is that multi-file generation fails primarily because LLMs produce bad code in individual files. In practice, each file in isolation is usually correct; the failures arise from inconsistencies between files, such as one file calling a function with three arguments while another file defines that function with four. Improving the quality of single-file generation does not fix this problem. The solution is structural: ensuring that when a file is generated, it has access to the actual definitions from the files it depends on, not a hallucinated version of those definitions.
2. The Edit Graph Abstraction
To reason about multi-file changes, we introduce the edit graph: a directed acyclic graph (DAG) where nodes represent file-level edits and edges represent dependencies between them. An edge from \(e_i\) to \(e_j\) means "edit \(e_j\) references symbols that \(e_i\) defines or modifies." The topological order of this DAG gives us a safe generation sequence.
Building the edit graph requires two pieces of information: the existing dependency
structure of the repository (which files import from which other files) and the
planned change (which files will be affected and what symbols each edit will
introduce or modify). We extract the first from the codebase using
tree-sitter (an incremental parsing library that builds concrete syntax trees from source code without requiring a full compiler toolchain);
the second comes from the task decomposition step.
In short: define before you reference, and generate in that same order.
Figure 16.1 illustrates this structure for the "add created_at" example.
created_at" task, showing five file-edit nodes arranged in three dependency layers. Arrows point from a defining file to files that reference its symbols. Layer 1 (the model) has no dependencies and is generated first; layer 2 files depend only on the model; layer 3 files depend on both the model and the serializer.from dataclasses import dataclass, field
from collections import defaultdict
@dataclass
class FileEdit:
"""A planned edit to a single file."""
path: str
description: str
symbols_defined: set[str] = field(default_factory=set)
symbols_referenced: set[str] = field(default_factory=set)
depends_on: list[str] = field(default_factory=list)
@dataclass
class EditGraph:
"""DAG of file edits with dependency edges."""
edits: dict[str, FileEdit] = field(default_factory=dict)
def add_edit(self, edit: FileEdit) -> None:
self.edits[edit.path] = edit
def resolve_dependencies(self) -> None:
"""Compute dependency edges from symbol references."""
# Build a map: symbol -> file that defines it
symbol_sources: dict[str, str] = {}
for path, edit in self.edits.items():
for sym in edit.symbols_defined:
symbol_sources[sym] = path
# Resolve each edit's references to dependencies
for path, edit in self.edits.items():
for sym in edit.symbols_referenced:
source = symbol_sources.get(sym)
if source and source != path:
if source not in edit.depends_on:
edit.depends_on.append(source)
def topological_order(self) -> list[str]:
"""Return file paths in dependency-safe generation order."""
in_degree: dict[str, int] = {p: 0 for p in self.edits}
for edit in self.edits.values():
for dep in edit.depends_on:
if dep in in_degree:
in_degree[edit.path] = in_degree.get(edit.path, 0)
# dep must come before edit.path
pass
# Kahn's algorithm (a BFS-based method that repeatedly
# removes nodes with zero in-degree to produce a
# topological ordering)
adj: dict[str, list[str]] = defaultdict(list)
in_deg: dict[str, int] = {p: 0 for p in self.edits}
for path, edit in self.edits.items():
for dep in edit.depends_on:
if dep in self.edits:
adj[dep].append(path)
in_deg[path] += 1
queue = [p for p, d in in_deg.items() if d == 0]
order = []
while queue:
node = queue.pop(0)
order.append(node)
for neighbor in adj[node]:
in_deg[neighbor] -= 1
if in_deg[neighbor] == 0:
queue.append(neighbor)
if len(order) != len(self.edits):
cycle_nodes = set(self.edits) - set(order)
raise ValueError(f"Circular dependency among: {cycle_nodes}")
return order
The EditGraph captures the essential structure of a multi-file change.
Each FileEdit declares what symbols it will define and what symbols it
needs from other files. The resolve_dependencies method computes the
edges, and topological_order produces a safe generation sequence. If the
graph contains a cycle (file A depends on file B which depends on file A), the method
raises an error, signaling that the change plan needs restructuring; see Section 16.1.5
for strategies to break such cycles.
3. Extracting Dependencies with Tree-Sitter
The edit graph needs to know which symbols each file imports and exports. We extract this information using tree-sitter, the same incremental parser we used for chunking in Chapter 11. Tree-sitter parses source code into a concrete syntax tree (CST, a parse tree that preserves every token including whitespace and punctuation, unlike an abstract syntax tree which discards syntactic details), making it possible to extract import statements, function signatures, and class definitions with byte-level precision.
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
PY_LANGUAGE = Language(tspython.language())
parser = Parser(PY_LANGUAGE)
def extract_imports(source: str) -> list[dict]:
"""Extract all import statements from Python source code."""
tree = parser.parse(source.encode("utf-8"))
imports = []
# Query for import statements
import_query = PY_LANGUAGE.query("""
(import_statement
name: (dotted_name) @module)
(import_from_statement
module_name: (dotted_name) @module
name: (dotted_name) @name)
""")
captures = import_query.captures(tree.root_node)
for node, name in captures:
imports.append({
"type": name,
"text": node.text.decode("utf-8"),
"line": node.start_point[0] + 1,
})
return imports
def extract_definitions(source: str) -> list[dict]:
"""Extract top-level function and class definitions."""
tree = parser.parse(source.encode("utf-8"))
defs = []
def_query = PY_LANGUAGE.query("""
(function_definition
name: (identifier) @func_name
parameters: (parameters) @params)
(class_definition
name: (identifier) @class_name)
""")
captures = def_query.captures(tree.root_node)
for node, name in captures:
# Only top-level definitions (depth 1 from module)
if node.parent and node.parent.parent == tree.root_node:
defs.append({
"type": "function" if name == "func_name" else "class",
"name": node.text.decode("utf-8"),
"line": node.start_point[0] + 1,
})
return defs
# Example: analyze a source file
source = '''
from models.base import BaseModel
from utils.timestamps import now
class Experiment(BaseModel):
def __init__(self, name: str):
self.name = name
self.created_at = now()
'''
print("Imports:", extract_imports(source))
print("Definitions:", extract_definitions(source))
With these extraction functions, we can scan every file in the repository and build a
complete dependency graph. The graph tells us, for any proposed edit, which files might
be affected: if we change the signature of BaseModel.__init__, every file
that imports from models.base is a candidate for a cascading edit. This
is the foundation of the impact analysis we build in
Section 16.2.
Suppose you need to add JSON Web Token (JWT) authentication to a Flask application with 40 endpoint
files. The edit graph for this change has three layers. Layer 1: create
auth/jwt_handler.py (defines verify_token,
create_token). Layer 2: create auth/decorators.py (defines
@require_auth, imports from jwt_handler). Layer 3: modify
all 40 endpoint files (each imports @require_auth and wraps its route
functions). Generating layer 3 before layers 1 and 2 would force the agent to guess
the decorator's exact name, parameter signature, and error behavior. Generating in
topological order means layer 3 edits can reference the actual @require_auth
implementation, eliminating guesswork. The edit graph has 42 nodes and 81 edges
(each endpoint depends on the decorator, which depends on the handler).
4. The Multi-File Generation Pipeline
With the edit graph in place, we can define a generation pipeline that produces consistent multi-file changes. The pipeline has four stages. (1) Task decomposition breaks a high-level request into file-level edit descriptions. (2) Dependency resolution constructs the edit graph and topologically sorts it. (3) Sequential generation produces each edit in dependency order, including preceding edits in the context. (4) Consistency validation checks the combined patch for import coherence and type compatibility. Figure 16.1.1 illustrates the edit graph and multi-file generation pipeline.
import subprocess
import json
from pathlib import Path
class MultiFileGenerator:
"""Orchestrates multi-file code generation in dependency order."""
def __init__(self, repo_root: str, model: str = "claude-sonnet-4-20250514"):
self.repo_root = Path(repo_root)
self.model = model
self.generated_edits: dict[str, str] = {} # path -> new content
def decompose_task(self, task: str) -> EditGraph:
"""Use an LLM to decompose a task into file-level edits."""
prompt = f"""Analyze this repository and decompose the following task
into file-level edits. For each file, specify:
- path: the file to edit
- description: what changes to make
- symbols_defined: new symbols this edit introduces
- symbols_referenced: symbols from other edited files this edit needs
Task: {task}
Respond in JSON: [{{"path": "...", "description": "...",
"symbols_defined": [...], "symbols_referenced": [...]}}]"""
result = subprocess.run(
["claude", "--print", "--model", self.model,
"--max-tokens", "4096", "-p", prompt],
capture_output=True, text=True, cwd=self.repo_root
)
edits_data = json.loads(result.stdout)
graph = EditGraph()
for ed in edits_data:
graph.add_edit(FileEdit(
path=ed["path"],
description=ed["description"],
symbols_defined=set(ed.get("symbols_defined", [])),
symbols_referenced=set(ed.get("symbols_referenced", [])),
))
graph.resolve_dependencies()
return graph
def generate_edit(self, edit: FileEdit) -> str:
"""Generate code for a single file edit with context from prior edits."""
# Build context from already-generated edits this one depends on
context_parts = []
for dep_path in edit.depends_on:
if dep_path in self.generated_edits:
context_parts.append(
f"--- {dep_path} (already modified) ---\n"
f"{self.generated_edits[dep_path]}"
)
# Read the current file content if it exists
file_path = self.repo_root / edit.path
current_content = ""
if file_path.exists():
current_content = file_path.read_text(encoding="utf-8")
prompt = f"""Apply this edit to the file.
File: {edit.path}
Current content:
{current_content}
Edit description: {edit.description}
Context from related files already modified:
{chr(10).join(context_parts) if context_parts else "(none)"}
Output ONLY the complete new file content, no explanation."""
result = subprocess.run(
["claude", "--print", "--model", self.model,
"--max-tokens", "8192", "-p", prompt],
capture_output=True, text=True, cwd=self.repo_root
)
return result.stdout.strip()
def execute(self, task: str) -> dict[str, str]:
"""Run the full multi-file generation pipeline."""
# Stage 1: Decompose
graph = self.decompose_task(task)
# Stage 2: Topological order
order = graph.topological_order()
print(f"Generation order ({len(order)} files):")
for i, path in enumerate(order, 1):
deps = graph.edits[path].depends_on
dep_str = f" (depends on: {', '.join(deps)})" if deps else ""
print(f" {i}. {path}{dep_str}")
# Stage 3: Sequential generation
for path in order:
edit = graph.edits[path]
print(f"\nGenerating: {path}...")
content = self.generate_edit(edit)
self.generated_edits[path] = content
# Stage 4: Consistency check (see Section 16.2 for full version)
issues = self._check_import_consistency()
if issues:
print(f"\nWarning: {len(issues)} consistency issues found")
for issue in issues:
print(f" - {issue}")
return self.generated_edits
def _check_import_consistency(self) -> list[str]:
"""Basic check: do all imports resolve to defined symbols?"""
issues = []
all_defs: dict[str, set[str]] = {}
for path, content in self.generated_edits.items():
defs = extract_definitions(content)
all_defs[path] = {d["name"] for d in defs}
for path, content in self.generated_edits.items():
imports = extract_imports(content)
for imp in imports:
# Check if imported module is one of our edited files
module_path = imp["text"].replace(".", "/") + ".py"
if module_path in all_defs:
# Imported name should exist in that module's definitions
if imp.get("type") == "name":
if imp["text"] not in all_defs[module_path]:
issues.append(
f"{path} imports '{imp['text']}' from "
f"{module_path}, but it is not defined there"
)
return issues
The key insight in this pipeline is stage 3: each file is generated with the actual content of its dependencies in the prompt context. When the agent generates the serializer, it sees the exact field definition from the model file, not a predicted version. This eliminates the most common source of multi-file inconsistency: signature mismatch between the definition site and the usage site.
The sequential pipeline above is safe but slow: each file waits for its dependencies to finish generating. Recent work pushes toward parallelism and richer agentic orchestration. CodeR (Chen et al., 2024) introduces a multi-agent framework where a "manager" agent decomposes the task, "analyst" agents locate relevant code, and "developer" agents generate patches in parallel, achieving a 17.6% resolve rate on the full SWE-bench dataset. SWE-agent with the "Moatless Tools" integration (Orwall, 2024) uses a search/identify/plan/edit pipeline that performs code search and context gathering as separate tool-use stages before generation, reducing hallucinated references. OpenHands CodeAct (Wang et al., 2024) unifies code generation with code execution in a single action space, letting the agent run tests between file edits and self-correct inconsistencies before completing the patch. The trade-off remains latency versus consistency: parallel generation is faster but requires robust validation to catch mismatches, while agentic loop approaches are slower but self-healing. For repository-scale changes touching more than 20 files, hybrid strategies that generate independent subtrees in parallel while serializing cross-cutting dependencies represent the current engineering frontier. As of 2025, top-performing agents on the SWE-bench Verified subset exceed 50% resolve rates (circa 2025), far surpassing the early 2024 figures; systems such as OpenHands SWE-Agent 3.0 and Amazon Q Developer Agent combine agentic planning with parallel file generation and iterative test feedback to achieve these results.
5. Failure Modes of Multi-File Generation
The generation pipeline above produces consistent patches when the edit graph is correct and complete, but in practice several systematic errors can corrupt the graph or slip past it.
Analysis of SWE-bench submissions and production coding agent logs reveals five recurring failure patterns. Each has a structural signature detectable before the code runs.
5.1 Hallucinated Imports
The agent imports a symbol that does not exist in the target module. This happens
when the model "remembers" an API from its training data that differs from the
actual codebase. For example, importing from utils import retry_with_backoff
when the repository's utils module only exports retry.
Detection: parse all import statements with tree-sitter and verify that each
imported name exists in the target module's definition list.
5.2 Signature Drift
The agent calls a function with arguments that do not match its definition.
This occurs when edits to the definition site and the call site are generated
independently (without dependency ordering). For example, the model edit adds
created_at: datetime as a required parameter, but the test fixture
calls Experiment(name="test") without supplying it.
Detection: extract function signatures and call sites with tree-sitter, then
verify argument compatibility.
5.3 Orphaned References
The agent removes or renames a symbol in one file but fails to update all
references in other files. This is the inverse of the hallucinated import: the
symbol existed before the change but no longer does after. Renaming
process_data to transform_data in the utility module
while leaving 15 call sites unchanged produces 15 NameErrors.
Detection: compute the set difference between pre-change and post-change
definitions, then search for references to removed symbols.
Checkpoint
So far we have identified three failure modes that corrupt multi-file patches: hallucinated imports (referencing symbols that do not exist), signature drift (mismatched function arguments between definition and call site), and orphaned references (removing or renaming a symbol without updating all its callers). Each is detectable by comparing parsed symbol tables across files before the patch is applied.
5.4 Circular Dependencies
The planned edits create a circular import chain that did not exist before.
File A imports from file B, and the new edit to file B adds an import from file A.
Python handles some circular imports gracefully (if the imported name is accessed
only at call time, not at import time), but many cases produce
ImportError at module load.
Detection: build the post-change import graph and check for cycles.
The standard strategy for breaking such cycles is to extract the mutually depended-upon symbols into a new shared module that both files import from, eliminating the circular edge; alternatively, one side can defer its import to function scope (a lazy import) so the dependency is resolved at call time rather than at module load time.
5.5 Missing Transitive Edits
The agent edits the direct dependents of a changed file but misses the transitive dependents, where a transitive dependent is a file that depends on the changed file indirectly through one or more intermediate imports. Changing a base class method signature requires updating not just the immediate subclasses but also any code that calls the method through a reference to the subclass. The impact analysis of Section 16.2 addresses this by computing the transitive closure of the dependency graph (the complete set of all files reachable by following chains of import edges, not just the immediate neighbors).
from dataclasses import dataclass
from enum import Enum
class FailureType(Enum):
HALLUCINATED_IMPORT = "hallucinated_import"
SIGNATURE_DRIFT = "signature_drift"
ORPHANED_REFERENCE = "orphaned_reference"
CIRCULAR_DEPENDENCY = "circular_dependency"
MISSING_TRANSITIVE = "missing_transitive_edit"
@dataclass
class PatchFailure:
failure_type: FailureType
file_path: str
line: int
message: str
severity: str # "error" or "warning"
def detect_hallucinated_imports(
edits: dict[str, str],
repo_definitions: dict[str, set[str]]
) -> list[PatchFailure]:
"""Detect imports that reference non-existent symbols."""
failures = []
for path, content in edits.items():
imports = extract_imports(content)
for imp in imports:
module = imp["text"]
# Check against both edited files and existing repo
target_path = module.replace(".", "/") + ".py"
available_defs = set()
if target_path in edits:
available_defs = {
d["name"] for d in extract_definitions(edits[target_path])
}
elif target_path in repo_definitions:
available_defs = repo_definitions[target_path]
if imp.get("type") == "name" and available_defs:
if imp["text"] not in available_defs:
failures.append(PatchFailure(
failure_type=FailureType.HALLUCINATED_IMPORT,
file_path=path,
line=imp["line"],
message=(
f"Imports '{imp['text']}' from {module}, "
f"but available symbols are: "
f"{', '.join(sorted(available_defs))}"
),
severity="error",
))
return failures
In one memorable SWE-bench submission, an agent imported
from django.utils.functional import cached_classproperty. This symbol
does not exist in Django. It does exist in a popular blog post about Django internals
that the model likely encountered during training. The agent generated a working
implementation of cached_classproperty inline, then imported it from
Django anyway. The inline version worked; the import did not. This pattern, where
the model simultaneously knows how to build something and hallucinates that it
already exists elsewhere, reveals a fundamental tension between generation
capability and factual grounding.
6. Orchestrating Generation with the Claude Code SDK
Detecting failure modes is only useful if the generation tooling can act on those detections, retrying or adjusting edits before the patch is finalized.
The Claude Code Software Development Kit (SDK) provides a programmatic interface for invoking Claude Code as a
subprocess, enabling integration into automated pipelines. For multi-file generation,
the SDK's key advantage is that each invocation can carry a different context window,
allowing us to feed dependency-specific context to each generation step. The SDK
supports both one-shot mode (--print, used above) and interactive
conversation mode, where the agent maintains state across multiple tool calls.
import subprocess
import json
def claude_code_generate(
prompt: str,
repo_root: str,
model: str = "claude-sonnet-4-20250514",
max_tokens: int = 8192,
allowed_tools: list[str] | None = None,
) -> dict:
"""Invoke Claude Code SDK for a single generation step.
Returns the parsed JSON output with conversation and result fields.
"""
cmd = [
"claude",
"--print", # one-shot mode: no interactive session
"--model", model,
"--max-tokens", str(max_tokens),
"--output-format", "json",
]
if allowed_tools:
for tool in allowed_tools:
cmd.extend(["--allowedTools", tool])
cmd.extend(["-p", prompt])
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=repo_root,
timeout=120,
)
if result.returncode != 0:
raise RuntimeError(
f"Claude Code failed (exit {result.returncode}): "
f"{result.stderr[:500]}"
)
return json.loads(result.stdout)
def generate_with_retry(
prompt: str,
repo_root: str,
validation_fn: callable,
max_attempts: int = 3,
) -> str:
"""Generate code with validation and retry on failure."""
for attempt in range(max_attempts):
response = claude_code_generate(prompt, repo_root)
content = response.get("result", "")
issues = validation_fn(content)
if not issues:
return content
# Augment prompt with failure feedback for retry
feedback = "\n".join(f"- {issue}" for issue in issues)
prompt = (
f"{prompt}\n\n"
f"Previous attempt had these issues:\n{feedback}\n"
f"Please fix these issues in your response."
)
raise RuntimeError(
f"Failed to generate valid code after {max_attempts} attempts"
)
The generate_with_retry function implements a critical pattern for
production-grade code generation: generate, validate, and retry. The validation
function can check for any of the failure modes from Section 16.1.5, and the retry
loop augments the prompt with specific failure feedback, giving the model a chance
to correct its mistakes. In practice, hallucinated import errors are typically resolved
on the first retry when the prompt explicitly lists the available symbols.
Aider implements multi-file editing
with its "architect" and "editor" modes. The architect mode uses a planning model to
identify which files need changes and what changes to make, then the editor mode
applies the changes using a diff-based format. In roughly 500 lines of Python, Aider
handles file selection, diff generation, and automatic git commits. What we build in
this section from first principles (edit graphs, dependency ordering, consistency
checking), Aider ships as a turnkey feature. The trade-off: Aider's approach works
well for codebases it can map within the context window, but the explicit dependency
reasoning we develop here scales better to very large repositories where the map
itself exceeds the context window's token budget. For codebases under 50,000 lines,
pip install aider-chat and aider --model claude-sonnet-4-20250514
is the pragmatic choice. As of 2025, Aider has added a "watch" mode that continuously monitors file changes and proposes multi-file edits in real time, and competing tools such as Claude Code and Cursor compose offer similar dependency-aware multi-file editing with tighter IDE integration.
7. Building the Edit Plan from a Task Specification
The SDK and retry loop handle the mechanics of generation, but their output is only as good as the plan that feeds them.
The quality of multi-file generation depends critically on the quality of the
initial decomposition. A vague task like "improve performance" produces a vague
edit plan; a specific task like "replace the O(n^2) duplicate detection in
pipeline/dedup.py with a hash-based approach and update the
corresponding tests" produces a precise one. This connects directly to the
requirements discovery
techniques of Chapter 13: the better the specification, the better the
implementation.
A well-structured edit plan has four components:
- Scope: which files are in scope and which are explicitly out of scope.
- Ordering: which edits must precede which others.
- Contracts: what each edited file promises to its dependents (function signatures, class interfaces, configuration keys).
- Verification: how to test that the change works (which test files to run, what behavior to check).
@dataclass
class EditPlan:
"""A structured plan for a multi-file change."""
task_description: str
scope: list[str] # files to edit
out_of_scope: list[str] # files explicitly not edited
contracts: dict[str, list[str]] # file -> list of interface promises
verification: list[str] # test commands to run
def to_prompt(self) -> str:
"""Convert the plan to a prompt for the generation pipeline."""
lines = [
f"Task: {self.task_description}",
f"\nFiles to edit ({len(self.scope)}):",
]
for f in self.scope:
contracts = self.contracts.get(f, [])
contract_str = "; ".join(contracts) if contracts else "no new interfaces"
lines.append(f" - {f} ({contract_str})")
lines.append(f"\nFiles NOT to edit: {', '.join(self.out_of_scope)}")
lines.append(f"\nVerification: {'; '.join(self.verification)}")
return "\n".join(lines)
# Example: plan for adding a caching layer
plan = EditPlan(
task_description="Add Redis caching to the experiment lookup endpoint",
scope=[
"cache/redis_client.py",
"cache/__init__.py",
"api/endpoints/experiments.py",
"tests/test_experiments.py",
"config/settings.py",
],
out_of_scope=[
"api/endpoints/users.py", # different endpoint, not affected
"models/experiment.py", # model unchanged, only read path cached
],
contracts={
"cache/redis_client.py": [
"def get_cached(key: str) -> dict | None",
"def set_cached(key: str, value: dict, ttl: int = 300) -> None",
],
"config/settings.py": [
"REDIS_URL: str",
"CACHE_TTL: int = 300",
],
},
verification=[
"pytest tests/test_experiments.py -v",
"pytest tests/test_cache.py -v",
],
)
print(plan.to_prompt())
The contract specification is particularly powerful. By declaring upfront that
cache/redis_client.py will export get_cached and
set_cached with specific signatures, we give the agent generating the
endpoint file a concrete interface to code against, even before the cache module is
generated. This is the software engineering principle of
programming to interfaces, applied to AI-assisted code generation.
AutoCodeRover (Zhang et al., 2024) and SWE-agent (Yang et al., 2024) both demonstrate that LLMs can generate reasonable edit plans from natural-language issue descriptions, particularly when given access to code search tools. The quality of these plans varies: on SWE-bench, agent-generated plans typically identify the affected files about 70% of the time but miss transitive dependencies in roughly 30% of cases. Active research focuses on combining LLM planning with static analysis, using the dependency graph to expand agent-proposed scopes to include files the agent overlooks. Here, "blast radius" refers to the full set of files that could be affected by a change, including indirect dependents several links away in the import chain. The MAGIS framework (Tao et al., 2024) introduces a dedicated "manager" agent whose sole job is plan quality, separate from the agents that execute the plan. As of 2025, newer agent frameworks such as Agentless (Xia et al., 2024) demonstrate that lightweight, non-agentic approaches combining fault localization with LLM-based patch generation can match or exceed fully agentic systems on SWE-bench, suggesting that plan quality matters more than agent complexity.
Try It: Build and Validate a Multi-File Edit Graph
This mini-project walks you through constructing an edit graph for an existing Python project, detecting dependency violations, and verifying consistency. You need Python 3.10+, pip install tree-sitter tree-sitter-python, and any multi-file Python project (your own, or clone a small open-source one).
1. Map the import graph. Write a script that uses the extract_imports and extract_definitions functions from Listing 16.2 to scan every .py file in your project. Store the results in a dictionary mapping each file path to its list of imports and definitions. Print the file with the highest in-degree (where in-degree is the number of other files that import from a given file).
2. Simulate a breaking change. Pick a function that at least three other files import. Rename it in your dictionary (do not edit the real files). Run a consistency check: for each file that imports the old name, flag it as having a broken reference. Print the list of affected files.
3. Build the edit graph. Using the EditGraph class from Listing 16.1, create FileEdit nodes for the definition file (which defines the new name) and each file that references it. Call resolve_dependencies() and topological_order(). Verify that the definition file appears first in the ordering.
4. Introduce a cycle. Add a synthetic dependency where one of the consumer files also exports a symbol that the definition file imports. Confirm that topological_order() raises a ValueError identifying the cycle.
5. Break the cycle. Create a new FileEdit for a shared.py file that holds the mutual dependency. Update the edit graph so both the original files depend on shared.py instead of each other. Verify that topological_order() now succeeds and shared.py appears before both files.
Exercise 16.1.1
Given the following four planned edits, determine the topological generation order:
(A) api/views.py references UserSerializer and get_db;
(B) db/connection.py defines get_db;
(C) api/serializers.py defines UserSerializer, references User;
(D) models/user.py defines User.
Which files can be generated in parallel, and which must be sequential?
Hint
Draw the dependency edges: A depends on C and B; C depends on D; B and D have no dependencies. Start with the nodes that have zero in-degree. B and D can be generated in parallel (neither depends on the other), then C (depends only on D), then A (depends on both B and C).
Step-Through: Kahn's Algorithm on an Edit Graph
Trace Kahn's algorithm (Listing 16.1) on a four-node edit graph with edges D→C, B→A, C→A (meaning A depends on B and C; C depends on D).
Initial state: in_deg = {A:2, B:0, C:1, D:0}; queue = [B, D]; order = [].
Iteration 1: Pop B. order = [B]. B's neighbor is A: in_deg[A] = 2−1 = 1. queue = [D].
Iteration 2: Pop D. order = [B, D]. D's neighbor is C: in_deg[C] = 1−1 = 0, so enqueue C. queue = [C].
Iteration 3: Pop C. order = [B, D, C]. C's neighbor is A: in_deg[A] = 1−1 = 0, so enqueue A. queue = [A].
Iteration 4: Pop A. order = [B, D, C, A]. Queue empty, len(order) == 4 == len(edits). No cycle. Final generation order: B, D, C, A. Each file is generated only after everything it depends on is already done.
Real-World Application: Shopify's Code Generation Infrastructure
Shopify's internal coding assistant uses dependency-ordered multi-file generation when developers request changes to their Ruby on Rails monolith, which contains over 3 million lines of code across thousands of files. The assistant builds an edit graph from ActiveRecord model associations and controller routing tables, ensuring that migration files are generated before model changes and model changes before serializer updates. According to Shopify engineering blog posts (2024), this ordering reduced cross-file inconsistency errors by roughly 40% compared to their earlier approach of generating all files independently.
Lab: Mapping and Stress-Testing a Real Dependency Graph
Goal: Build a complete import dependency graph for an open-source Python project and measure how multi-file consistency degrades without topological ordering.
Tools: Python 3.10+, tree-sitter,
tree-sitter-python, networkx, and a medium-sized open-source
repo (try httpx, pydantic, or fastapi).
Procedure (25 min):
(1) Use the extract_imports and extract_definitions functions
from Listing 16.2 to scan every .py file and build a
networkx.DiGraph of import edges.
(2) Pick a function defined in a high-in-degree node and simulate renaming it:
collect all files that reference the old name (direct and transitive dependents).
(3) Generate a random permutation of those files as the "edit order" and count how
many files would be generated before their dependencies (consistency violations).
Repeat 100 times and compute the average violation rate.
(4) Now sort the same files in topological order and verify that the violation count
drops to zero.
What to vary: Try projects of different sizes and graph densities. Observe how the random-order violation rate scales with the number of affected files.
What to observe: The violation rate under random ordering should grow roughly linearly with the number of files, while topological ordering always yields zero violations regardless of project size.
Exercises
-
(Conceptual) A repository has a module hierarchy where
models/depends onutils/,api/depends on bothmodels/andutils/, andtests/depends on all three. Draw the edit graph for a change that modifies a utility function signature, requiring updates to models, API endpoints, and tests. What is the topological order? How many files could be generated in parallel at each stage? -
(Coding) Extend the
EditGraphfrom Listing 16.1 to detect cycles and report the specific files involved. Test it by constructing a graph whereauth/tokens.pyimports fromauth/users.pyand vice versa. Then implement a cycle-breaking strategy that introduces a third file (auth/shared.py) to hold the shared definitions. -
(Analysis) Run the
extract_importsfunction from Listing 16.2 on a Python project you work with. Compute the import graph and find the module with the highest in-degree (most files import from it). Explain why changes to this module require the most careful edit planning and the largest blast radius analysis.
What's Next
We now have machinery to decompose a task into file-level edits, order them by dependency, and generate consistent multi-file patches. But generating code is only half the problem. In Section 16.2: Impact Analysis and Patch Review, we build tools to analyze what a change breaks: tracing change propagation through the dependency graph, computing blast radii, and automating the review process that catches regressions before they reach production.