Trimming Context: Dependency Graphs Instead of Full Repos
For code tasks, the fastest way to bloat a prompt is to paste an entire repository when the model only needs a handful of files.
Search across all documentation pages
For code tasks, the fastest way to bloat a prompt is to paste an entire repository when the model only needs a handful of files.
A dependency graph lets you send structure instead of bulk: a map of what imports what, plus the specific snippets that actually matter.
A dependency graph is a small data structure that records which files or modules import, call, or otherwise depend on which others.
Instead of pasting a whole codebase into a prompt, you walk this graph from the file being changed and pull in only the files it's structurally connected to.
This keeps the prompt small and focused, which lowers cost and avoids diluting the model's attention with unrelated code.
It also scales better than manually guessing which files are relevant, since the graph is built once and reused across many prompts.
This page walks through building a minimal dependency graph in Python, using it to select relevant files for a change, and sending that trimmed context to Claude.
Quick-reference recipe card - copy-paste ready.
import ast
import os
def get_imports(filepath: str) -> set[str]:
with open(filepath) as f:
tree = ast.parse(f.read(), filename=filepath)
imports = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module)
return importsWhen to reach for this:
import ast
import os
import anthropic
client = anthropic.Anthropic()
def get_imports(filepath: str) -> set[str]:
"""Return the set of module names a Python file imports."""
with open(filepath) as f:
tree = ast.parse(f.read(), filename=filepath)
imports = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module)
return imports
def build_dependency_graph(repo_root: str) -> dict[str, set[str]]:
"""Map each file path to the module names it imports."""
graph = {}
for dirpath, _, filenames in os.walk(repo_root):
for name in filenames:
if name.endswith(".py"):
path = os.path.join(dirpath, name)
graph[path] = get_imports(path)
return graph
def module_name_to_path(repo_root: str, module: str) -> str | None:
"""Best-effort mapping from a module name back to a file path."""
candidate = os.path.join(repo_root, *module.split(".")) + ".py"
return candidate if os.path.exists(candidate) else None
def relevant_files(repo_root: str, target_file: str, graph: dict[str, set[str]]) -> set[str]:
"""Files the target imports, plus files that import the target."""
related = {target_file}
for module in graph.get(target_file, set()):
path = module_name_to_path(repo_root, module)
if path:
related.add(path)
for path, imports in graph.items():
target_module = os.path.splitext(os.path.relpath(target_file, repo_root))[0].replace(os.sep, ".")
if target_module in imports:
related.add(path)
return related
def prompt_with_trimmed_context(repo_root: str, target_file: str, question: str) -> str:
graph = build_dependency_graph(repo_root)
files = relevant_files(repo_root, target_file, graph)
context_blocks = []
for path in sorted(files):
with open(path) as f:
context_blocks.append(f"# {os.path.relpath(path, repo_root)}\n{f.read()}")
prompt = f"{question}\n\n" + "\n\n".join(context_blocks)
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
answer = prompt_with_trimmed_context(
repo_root="./billing_service",
target_file="./billing_service/retry_queue.py",
question="If I add a max_attempts parameter to retry_queue.py, what callers break?",
)
print(answer)What this demonstrates:
build_dependency_graph walks a repo once and records each file's imports, which is cheap to compute and reusable across many prompts.relevant_files walks one hop in both directions: what the target file imports, and what imports the target file.ast.parse turns Python source into a syntax tree without executing it, which is the safe, standard way to inspect imports.ast.walk finds every Import and ImportFrom node regardless of how deep it's nested in the file.| Depth | What's Included | Best Fit |
|---|---|---|
| 0 hops | Only the target file | Isolated bug fixes, style questions |
| 1 hop | Target file's direct imports and direct importers | Most refactors and "what breaks" questions |
| 2+ hops | Transitive dependencies | Cross-cutting changes to shared utilities or core types |
Going beyond one or two hops usually reintroduces the problem this technique is meant to solve: the prompt grows back toward "the whole repo," just with extra steps.
# Guard against files that fail to parse (syntax errors, non-UTF8, etc.)
def get_imports_safe(filepath: str) -> set[str]:
try:
return get_imports(filepath)
except (SyntaxError, UnicodeDecodeError):
return set()Real repos have generated files, vendored code, or occasional syntax errors from in-progress edits.
Wrapping the parse step so one bad file doesn't crash the whole graph build keeps this practical to run on a real codebase.
ast reports the literal module string, so a relative import like from . import utils needs package-aware resolution, not simple path joining. Fix: resolve relative imports against the importing file's package path, or restrict the graph to absolute imports for a simpler first version.ast. Code that imports via importlib.import_module(computed_name) won't show up as a static Import node. Fix: treat dynamically-loaded modules as a known gap, and manually add known dynamic dependencies to the graph.| Alternative | Use When | Don't Use When |
|---|---|---|
| Manual snippet selection | The task touches one or two files you already know | The repo is large enough that manual selection is slow or error-prone |
| Full-repo context | The repo is small enough to fit comfortably and cost isn't a concern | Cost matters, or the repo is large enough to risk context rot |
| Retrieval-augmented search (embedding-based) | You need semantic relevance, not just import relationships (e.g. "find code related to refunds") | The relationship you care about is structural (what calls what), which a dependency graph answers more precisely |
| A dedicated code-analysis tool (e.g. a language server's call graph) | You need cross-language or highly accurate call-graph data at scale | A lightweight, single-language script is sufficient for the task at hand |
Grep finds text matches, which can both miss relevant files (no shared keyword) and include irrelevant ones (a coincidental string match).
A dependency graph is built from actual import relationships, so it's more precise for the specific question of "what does this file depend on."
For a single-language, moderately sized repo, a simple ast-based script like the one above is often enough.
pydeps, or similar) will be more robust.One hop (direct imports and direct importers) is a reasonable default for most single-file changes.
No, they solve different problems and typically get combined.
The prompt will lack context the model needed, which can produce an incomplete or wrong answer.
Treat the graph as a starting point: for high-stakes changes, have a human review which files were selected before sending the prompt.
Yes, the concept is language-agnostic: any language with a static import or require/use statement can be parsed similarly.
The specific parsing step (ast here) is Python-specific; other languages need their own parser or a general-purpose static-analysis tool.
Circular imports are fine for graph construction since you're just recording import statements, not executing them.
They matter for traversal: track visited files so a cycle doesn't cause the same file to be added, or worse, cause unbounded recursion in a naive traversal implementation.
Building it is a local, offline operation, it parses source files on disk and costs no API tokens.
It only needs to be rebuilt when the codebase changes meaningfully, so the cost is amortized across every prompt that reuses it.
Both can help, depending on the question.
That's a valid alternative, typically implemented as a tool-use loop where Claude requests specific files on demand.
It trades one extra round trip (and the token cost of that round trip) for potentially better relevance than a pre-computed graph, and it pairs well with the tool-call caching described elsewhere in this section.
No, a larger window changes what you can fit, not what you should send.
Unnecessary files still cost tokens, and irrelevant code can still dilute the model's focus on the part of the codebase that actually matters, which is the context rot problem covered elsewhere in this section.
If Claude's answers repeatedly reference a file or a concept it wasn't given, that's a signal the traversal depth or the resolution logic (relative imports, dynamic imports) is missing something.
Widen the hop count or patch the specific resolution gap, rather than defaulting to sending the whole repo.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the official
anthropicPython SDK (latest 0.x release). Model names, pricing, and SDK versions move quickly - verify current specifics at platform.claude.com/docs before relying on them.
Reviewed by Chris St. John·Last updated Jul 19, 2026