AI coding

How to Map a Codebase Before an Agent Touches It

Antonio Evans11 min read

A technical drawing of a software repository as a city map: each file drawn as a building, imports drawn as roads with one-way arrows, data stores drawn as water pipes crossing between buildings.
Key points

Key points

  1. 1Mapping a codebase before an agent edits it turns an open-ended "make this change" request into a bounded task with named files, known dependencies and explicit assumptions.
  2. 2The mapping pass has five repeatable steps: inventory modules and entry points, extract imports into a directed graph, trace shared state reads and writes, write the map to versioned files, then validate it against tests and runtime traces.
  3. 3The map belongs in version control as plain files (modules.json, edges.csv, boundaries.md, test-coverage.md) so every later agent session starts from the same known baseline instead of re-deriving it.
  4. 4Restricting each agent task to one boundary from the map is the decision rule that prevents the "agents were productive but created more debt" failure mode.

Why the map comes before the prompt

Most teams hand an agent a repository and a change request in the same breath. That works on greenfield code and fails on old systems, for a structural reason: the legacy system is the thing nobody fully understands, which is why documentation is stale and the original authors are gone. A consultancy evaluating a 3 to 4 million line .NET codebase described the problem plainly, asking "who knows what's in there?", and wanted three to four weeks of assessment before committing a dozen people to the engagement (Driver.ai).

The cost of skipping that step is measurable. A team running a 12-week modernization on a 10 million line Java ERP adopted an agent without any context infrastructure; the changes created additional technical debt, the agents went down rabbit holes, and the project was stuck for over nine months. Four developers independently generated markdown documentation and cross-checked it against each other, and still found errors. The consultant's summary: the model "has no chance" on 10 million lines without pre-computed context (Driver.ai).

Michael Feathers frames the same constraint from the testing side: legacy code is code without tests, and code with a good test suite is relatively easy to change while code without one puts you in serious trouble (Tech Lead Journal). Mapping is the companion discipline. Tests tell you whether you broke something; a map tells you what "something" could possibly be, and which files an agent should never open.

There is also a cognitive reason. Programmers spend a substantial share of their time just comprehending code, and a study of professional code auditors found that LLM tools alone fail to supply the business logic and the adaptive, interactive strategies humans use when onboarding an unfamiliar system (arXiv). The fix in that study was a visual, hierarchical map that reduced reliance on reading LLM prose by 79 percent. Your mapping pass is the low-tech version of the same idea: extract structure, write it down, let the agent read the structure instead of the whole repository.

Step 1: Inventory modules and entry points

Start with breadth, not depth. You want a list of every module the build system recognizes, plus every place execution can begin: HTTP handlers, CLI commands, cron jobs, queue consumers, event subscribers, database triggers, and the main function if there is one.

Do this with tooling where you can and with the agent where you cannot. Ask the agent for a single deliverable: a JSON array where each element has a path, a module type, an entry-point flag, an approximate line count, and a one-sentence purpose derived from reading the code, not the folder name. Auditors who onboard new codebases weekly rely on exactly this kind of automated extraction, decomposition and representation before they read line by line (arXiv).

A worked example. Suppose the inventory returns 412 modules, 9 of which are entry points. That ratio alone is a finding: nine doors into 400 rooms means the blast radius of most changes is concentrated. On a 3 to 4 million line .NET system, the entry-point list was short enough to review in an afternoon, which is what made a four-week assessment estimate credible (Driver.ai).

Do not let the agent summarize free-form. Prose summaries drift between sessions and cannot be diffed. Force structured output:

[
  {"path": "src/billing/invoice.py", "type": "module", "entry_point": false, "loc": 480, "purpose": "Builds invoice line items from rate cards"},
  {"path": "src/api/routes.py", "type": "http", "entry_point": true, "loc": 120, "purpose": "Registers all HTTP routes"}
]

The purpose field is the agent's hypothesis, and hypotheses get checked in step 5.

Step 2: Build the import graph

The import graph is the backbone of the map, and it is the one part you should not trust the agent to eyeball. A dependency graph represents nodes (modules, packages, components) and edges (one node relies on another), and the useful analysis lives in the shape: direct versus transitive dependencies, reverse dependencies, and cycles that break the acyclic ideal (Tweag).

Parse imports with the language's own tooling. Python's built-in ast library is enough to extract imports from source without executing it, which is how Hudson River Trading built a dependency graph across a codebase of millions of lines and found tangled regions where importing a single module could transitively pull in an order of magnitude more code. In the most tangled portions, the compounding effect amounted to over 30 seconds of import time for most programs (Python Software Foundation). Their diagnosis is worth internalizing: "tangling" means many overlapping cycles and unrelated parts of the codebase coupled through indirect import paths, and a few misplaced imports can couple hundreds of modules.

Write edges to a flat CSV, one row per edge, because flat files diff and merge cleanly and every graph tool reads them:

source,target,edge_type
src/api/routes.py,src/billing/invoice.py,import
src/billing/invoice.py,src/billing/rates.py,import
src/billing/rates.py,src/billing/invoice.py,import

That third row is a cycle, and cycles are the single most useful thing the graph will tell you. Compute three derived numbers per module before moving on: fan-out (how many modules it imports), fan-in (how many import it), and whether it sits in a cycle of more than one module. High fan-in means a change there is expensive; membership in a cycle means you cannot test the module in isolation. Both belong in boundaries.md.

Then use the graph to draw tier lines. HRT's approach was to establish a layered architecture where lower layers never import from higher ones, then add dependency rules checked in continuous integration so the tangle cannot regress (Python Software Foundation). You do not need the full tool. Ten rules in CI ("billing must not import api") deliver most of the benefit.

Step 3: Trace shared state reads and writes

Imports tell you who calls whom. They do not tell you who mutates what. This is where agents traditionally go wrong: they read a function, change it, and break a distant caller that depended on a side effect nobody documented.

Data flow analysis traces how values are defined, modified and consumed; control flow analysis models the execution paths through branches, loops and calls. Combined, they expose how information propagates through a system, which is exactly what you need before changing a shared structure (IN-COM).

Build a shared-state table with the agent doing the reading and you doing the verification:

Shared stateKindWriters (module:function)ReadersTest coverageRisk
customer.balanceDB columnbilling:apply_credit, payments:settlereporting:daily_totals, billing:renderpartialhigh
session_cachein-process dictauth:login, auth:logoutevery HTTP routenonehigh
RATE_TABLEmodule constantloaded at importbilling, quotingindirectlow
feature_flagsexternal serviceops console14 modulesnonemedium

Four columns do the work. Writers matter because two writers to the same field is a coupling you cannot see from imports. Readers matter because they define the blast radius. Test coverage matters because it decides whether you can change anything here at all. Risk is the decision output.

For each mutable global, ask the agent a narrow question: "List every assignment to this symbol and the conditions guarding it." When a codebase carries several historical implementations of the same pattern, as one fintech CIO described in an 8 million line Ruby monolith, this is how you find out which one is live and which is dead (Driver.ai). Dead implementations go in boundaries.md as "do not modify; candidate for deletion after logging confirms zero traffic," which is the logging application of the strangler fig pattern, where logging reveals how often old code actually runs so you can decide whether to delete or rewrite it (Wikipedia).

Step 4: Write the map to files the agent can reuse

Everything so far is a session. Files are what make it infrastructure. Commit four artifacts at a stable path, for example docs/codemap/, and reference them from the agent's instruction file.

modules.json is the step 1 inventory, regenerated by script rather than chat.

edges.csv is the step 2 import graph, regenerated by script.

boundaries.md is the human-plus-agent judgment layer. Per boundary: name, the modules inside it, the public functions other boundaries may call, the shared state it owns, the tests that cover it, and what is off limits. The table below is the checklist I use for each boundary:

FieldPurposeExample
Name and scopeOne sentence plus module globsBilling: src/billing/*
Public surfaceThe only symbols other boundaries may callcreate_invoice, apply_credit
Owned shared stateTables, caches, files it writesinvoices, customer.balance
Entry points insideRequests that land here firstPOST /invoices
Tests covering itSuite names and current pass statetests/billing, 214 passing
Known assumptionsUnverified beliefs with a date"rates only change monthly (unverified)"
Off limitsFiles the agent must not editsrc/billing/legacy_tax.py

test-coverage.md is the honesty document. Per boundary: which tests exist, whether they run fast enough to loop on, and which modules have none. Feathers' change algorithm puts this first for a reason: identify change points and test points, get the targeted code under tests, then make the change, and keep refactoring inside the covered area because behavior is what users depend on (BSSw).

Characterization tests are the cheapest way to get the safety net up. You do not assert what the code should do; you assert what it does, snapshot style, then change the expected value to the actual when the test fails. DaedTech's walkthrough captures the point: once code is in the wild, "right" and "wrong" stop mattering, and documenting "what is" is what lets you notice when a change makes the system behave differently (DaedTech). Do not hand a codebase without tests to an agent. Feathers is blunt about the stakes (Tech Lead Journal).

# Regenerate the mechanical parts of the map, then review the diff
python tools/codemap/inventory.py --root src --out docs/codemap/modules.json
python tools/codemap/edges.py --root src --out docs/codemap/edges.csv
python tools/codemap/coverage.py --out docs/codemap/test-coverage.md
git diff --stat docs/codemap/

If the diff to modules.json is larger than you expect after a small change, the inventory is picking up generated code and needs an ignore list.

Step 5: Validate the map against tests and runtime traces

A map you have not checked is a set of guesses with good formatting. Errors survive review: four developers independently generated documentation from an AI and cross-checked each other, and still found mistakes (Driver.ai). Check against two independent oracles.

Static oracle: the test suite. Map each test file to the modules it imports and calls. A module marked "covered by tests/billing" that no test imports is a wrong edge in your map or a stale test. A module with fan-in of 30 and no tests is your highest-risk boundary regardless of what the map's purpose field claims.

Dynamic oracle: runtime traces. Enable request tracing or instrument a handful of entry points, exercise the top ten user journeys, and record which modules actually execute. Then compare with the edges that the import graph predicts. Three discrepancies are common and all three matter:

SymptomWhat it meansWhat to do
Module executes but has no inbound edgeDynamic import, plugin loading, or reflectionAdd an edge with edge_type=dynamic and note it in boundaries.md
Import edge exists but module never executesDead codeLog usage before deletion; mark as candidate in boundaries.md
Large cycle, no trace through itCycle is real but dormantLower the risk rating, keep the cycle in the graph

Do this on one boundary, not all of them, before trusting the method. Pick your target boundary, run the traces, and if two or more predicted edges were wrong, fix the extraction script before scaling. Ten lines of tracing coverage will teach you more about the map's accuracy than any amount of agent self-review.

One more validation angle comes from the research: a map that is too noisy is as useless as no map, because the clarity of the graph collapses when it is drawn at the wrong zoom level. Start with the dependency graph of the change you plan to make, and expand only when the node count exceeds what you can hold in your head (Understand Legacy Code).

Step 6: Store the map and lock the agent to one boundary

The map earns its keep only when later tasks start from it. Two rules make that happen.

First, the map files are part of the repository and change by pull request, like code. When an agent changes structure, it updates the map in the same commit. When a human moves a module, they update the map. A stale map is worse than no map because the agent trusts it. Keep the mechanical files script-generated so that a stale map is impossible to miss: the regeneration diff is the alarm.

Second, every agent task names exactly one boundary and one public surface. The prompt stops being "add refunds to the codebase" and becomes "inside the Billing boundary, add apply_refund to the public surface, wire it to POST /refunds, do not edit legacy_tax.py, and update docs/codemap/boundaries.md." A practitioner thread on scaling models to larger codebases lands on a compatible loop: research the current functionality to load the right files, write a plan to a markdown file, clear context, execute the plan, then review it with tests and lint (Hacker News).

The boundary is also your sequencing unit. If the work must touch two boundaries, that is two tasks, ordered by the graph. Where the new code should run alongside the old rather than replace it, the strangler fig approach is the tested way to do it: a facade routes requests to the legacy or the new implementation, and you shift traffic incrementally as each piece is proven, which reduces migration risk and gives earlier value from each released piece (Microsoft Learn). Martin Fowler's account makes the prerequisite explicit, namely identifying the seams that let a component be split off in the first place, because well-designed systems with ready seams are unicorns (martinfowler.com). Your boundaries.md is a list of candidate seams, ranked by risk.

AWS describes the same pattern as the way to move a tightly coupled monolith forward incrementally, precisely because tightly coupled changes cannot be tested in isolation and thorough retesting is required around each one (AWS Prescriptive Guidance). That is the argument for one boundary per task in a sentence.

One note on automation level. Reports of full automated refactoring exist, but the same account puts typical client projects at 60 to 80 percent automation, with human oversight required for schema changes, data migrations and functional enhancements (InterSystems). Plan your review capacity for the difference.

Where the mapping itself needs many models (a long-context model for reading large modules, a cheaper one for summarizing hundreds of them, an embedding model for semantic search over module purposes), routing those calls through one API removes the key-management overhead, which is what Quanta Inference is for. If you would rather hand the whole mapping pass to an expert that reads context and returns finished files, see Quanta Experts.

FAQ

FAQ

How long should the mapping pass take on a large codebase?

Budget about a week for a team of one or two on a system of a few hundred modules: one day for inventory and extraction scripts, two days for the import graph and state tracing, two days for writing and validating the four artifacts. The consultancy cited earlier wanted three to four weeks to assess a 3 to 4 million line .NET codebase before committing a larger team (Driver.ai). Scale from there, and remember the map is regenerable, so the expensive part is the boundaries document, not the extraction scripts.

Do I need to map the whole codebase or just the part I am changing?

Just the part you are changing, plus one hop of its dependencies. Feathers' change algorithm is explicitly targeted: identify the change points and test points for the specific change, get only that subset into a test harness, and keep refactoring inside covered code (BSSw). Map in blocks, on demand, when a change request arrives. The full inventory can stay shallow and script-generated.

The codebase has no tests at all. Which comes first, the map or the tests?

The map comes first, but only far enough to identify the change point and a seam. You cannot write a test for code you have not located, and you often cannot test it without breaking a dependency at a seam, which is a place where you can alter program behavior without changing the source (Understand Legacy Code). So: map the boundary, find the seam, write characterization tests that snapshot current behavior (DaedTech), then let the agent edit.

How is this different from just asking the agent to explore the repo first?

Exploration is a conversation; it lives and dies in one context window. The map is an artifact that persists, can be diffed, can be reviewed by a human, and can be regenerated when code moves. Practitioner reports of agents doing research-then-plan loops are real, but note that even those workflows write the plan to a file and clear context before executing (Hacker News). The file is the point.

What should the agent do when it finds the map is wrong mid-task?

Stop, record the discrepancy in boundaries.md with the evidence (the trace, the failing test, the missing edge), and do not proceed until the map is corrected. Silent map repair inside a change task is how an agent ends up editing outside its boundary. If the correction changes the public surface of another boundary, that is a second task and a second review.

Sources

Sources

  1. AI Coding Agents Should Make Migrations Easier. Without Context ...
  2. Working Effectively with Legacy Code and AI Coding Assistant - YouTube
  3. Understanding Codebase like a Professional! Human, AI ...
  4. Introduction to the dependency graph
  5. Building a Dependency Graph of Our Python Codebase
  6. How Data and Control Flow Analysis Powers Smarter ...
  7. Strangler fig pattern - Wikipedia
  8. Working Effectively with Legacy Code - Better Scientific Software (BSSw)
  9. Characterization Tests - DaedTech
  10. Safely restructure your codebase with Dependency Graphs
  11. Scaling LLMs to Larger Codebases - Hacker News
  12. Strangler Fig Pattern - Azure Architecture Center | Microsoft Learn
  13. Strangler Fig - Martin Fowler
  14. Strangler fig pattern - AWS Prescriptive Guidance
  15. Proven AI, Modernizing Legacy Systems With AI Coding Agents
  16. The key points of Working Effectively with Legacy Code
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles