How to read a codebase you didn't write
A repeatable route into any unfamiliar repo β manifest, entry point, config, routes, data models, tests β plus the specific smells that tell you the README is lying.
12 cards Β· 6 quiz questions Β· 10 min read
An agent has handed you a repo: forty Python files, a React front end, a Dockerfile, a README written in the first fifteen minutes of the project and never touched again. It deploys. It mostly works. You have no idea what it does. This is the normal condition of running agent-written software, and the way out is not to read everything β it is to follow a fixed route that answers the same six questions every time.
The route below takes about twenty minutes on a medium repo and leaves you able to change things on purpose rather than by guesswork.
1. The manifest, before any code
The dependency manifest is the file that lists what the project depends on: pyproject.toml or requirements.txt for Python, package.json for JavaScript, wrangler.toml or wrangler.jsonc for a Cloudflare Worker. It is the cheapest information in the repo.
# pyproject.toml β the whole architecture, in ten lines
[project]
name = "iff-research-api"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"pydantic>=2.9", # v2: model_validate / model_dump, not .parse_obj
"sqlalchemy>=2.0", # 2.0 style: select(), Mapped[...] annotations
"httpx>=0.27", # async HTTP β so someone knew about blocking calls
"azure-identity>=1.19", # managed identity, probably no keys in config
]
Read that and you already know: a FastAPI service on modern Python, Pydantic v2 so validation lives in model classes, SQLAlchemy 2.0 so database access uses select() rather than Model.query, and an async HTTP client, which suggests someone thought about not blocking the server. You also know which documentation to open.
Check the lockfile too. If requirements.txt says pydantic==1.10 and the code calls model_dump(), one of the two is wrong and the repo may not install cleanly at all.
2. The entry point: what actually gets run
A repo has many files and usually one thing that starts. Find it by looking for the command a machine runs, not the file that sounds important.
# Where does the process start? Look at the deploy config, not the README.
grep -rn "uvicorn\|gunicorn\|CMD\|ENTRYPOINT\|startupCommand" \
Dockerfile* *.yml *.yaml .github/workflows/ 2>/dev/null
# Front end: the scripts block is the front end's entry point
node -e "console.log(require('./package.json').scripts)"
If the Dockerfile ends with CMD ["uvicorn", "api.server:app", "--host", "0.0.0.0", "--port", "80"], then api/server.py holds an object called app, and that object is the entire server. Everything the service can do is reachable from there. For the front end, npm run dev leads to Vite, which leads to index.html, which leads to src/main.tsx and the component tree below it.
3. Configuration: the systemβs real inputs
Every environment variable the code reads is an input it cannot function without. Listing them tells you what the system talks to.
# Python: every external input, in one pass
grep -rn "os.environ\|os.getenv\|BaseSettings" --include="*.py" . | grep -v ".venv"
# Front end: anything Vite inlines into the browser bundle at build time
grep -rn "import.meta.env" --include="*.ts" --include="*.tsx" src/
Now compare the two lists to .env.example and to the settings configured on your host. The gaps are where production breaks. And read the front-end list carefully: anything there ends up inside the shipped JavaScript, so an API key in that list is already public.
While you are here, watch for config read at import time:
# Before β an agent favourite. Fails during import, so the container never starts.
API_KEY = os.environ["AZURE_OPENAI_KEY"]
# After β one validated settings object, created once, failing with a named field.
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
azure_openai_key: str # required: missing means a clear error
request_timeout_s: float = Field(30, gt=0) # optional with a sane default
The first version dies with KeyError: 'AZURE_OPENAI_KEY' halfway through module loading β before logging is configured β which on Azure App Service looks like a container that will not start and no useful error anywhere you normally look. The second names the missing field.
4. The URL surface
For a web service, the list of routes is the list of things it can be asked to do. Do not grep for it; ask the framework, because routers mounted with a prefix mean the path in the decorator is not the real path.
# scripts/routes.py β run once, get the truth
from api.server import app
for route in app.routes:
methods = ",".join(sorted(getattr(route, "methods", []) or []))
print(f"{methods:12} {route.path}")
Or open /docs on a running instance, which FastAPI generates from the same information. Either way you will find endpoints nobody mentioned: a leftover /debug/run-sql, an unauthenticated /internal/export, three variants of the same thing from three rounds of agent work.
5. The data models: the shapes everything obeys
Read the models next, and most of the logic becomes obvious, because most functions turn one shape into another.
# The API contract: what the dashboard is allowed to send, and gets back
class ResponseBatchIn(BaseModel):
survey_id: int
responses: list[dict]
submitted_at: datetime | None = None # optional: server fills it if absent
class ResponseBatchOut(BaseModel):
batch_id: str
accepted: int
rejected: int # if the UI ignores this, errors vanish silently
For storage, read the SQLAlchemy models or, for a D1 database, the schema directly β which is often more honest than the ORM, because it is what the database really has:
-- wrangler d1 execute policynews --command "SELECT sql FROM sqlite_master WHERE type='table'"
CREATE TABLE articles (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE, -- the dedupe key; the pipeline relies on this
fetched_at TEXT NOT NULL, -- SQLite has no date type: strings, sorted lexically
summary TEXT -- nullable: the LLM step may not have run
);
That NOT NULL UNIQUE on url tells you the ingest job expects to be run repeatedly and relies on the database to reject duplicates. The nullable summary tells you the LLM step is a separate, possibly failing pass. Two column definitions, two pieces of system design.
6. Tests, and then the README
Tests are a specification with the vagueness removed. Skim the test names alone first β pytest --collect-only -q prints them β and you have a list of the behaviours somebody cared about. The silences matter as much: a 600-line module with no tests is where you should be slowest to change anything.
Read the README last, as a historical document. Its claims are hypotheses to check against the manifest, the start command and the env list. When they disagree, the executable artefacts win.
Smells worth knowing by name
| What you see | What it usually means |
|---|---|
Two settings modules, or BaseSettings plus scattered os.getenv | An agent added a second system; the one you edit may not be on the live path |
utils.py and helpers.py with overlapping functions | Duplicated logic that will drift apart and be fixed in only one place |
requests.get inside an async def | A blocking call on the event loop: one slow upstream stalls every other request |
--reload in a Dockerfile or startup command | The deployment was never reviewed |
| A module-level dict used as a cache or rate limiter | Breaks silently as soon as there is more than one worker |
.env committed to the repo | Rotate those credentials today |
| A model field with no corresponding migration | Works locally, fails on the first production query |
Then trace one request
Finish by following a single real request all the way: the route function, its dependencies, the input model, the database call, the response model, the React component that renders it. One end-to-end path teaches you this repoβs conventions, and conventions are what make the remaining twenty routes skimmable rather than twenty fresh puzzles.
What to check in your own code
- Does the READMEβs start command exist? Compare it with the
CMDor startup command that actually runs. - Run the env grep. Is every variable it finds set in your hostβs configuration, and does every configured setting still appear in the code?
- Run the route dump. Is there an endpoint you did not know existed, or one with no authentication dependency?
- Grep for
os.environ[at module level, outside a function β each hit is a possible failure-to-start. - Grep for
requests.in files that also containasync def. - List files with no test coverage and more than 200 lines; that is your change-with-care list.
- For each database model added recently, confirm a migration exists in the same commit.
You open an unfamiliar Python repo. The README says to run `python app.py`, but there is no `app.py`. The Dockerfile's last line is `CMD ["uvicorn", "api.server:app", "--host", "0.0.0.0", "--port", "80"]`. What should you believe?
Sources
- Brett Cannon et al. β PEP 621 β Storing project metadata in pyproject.toml article Defines the dependency manifest fields you read first in any modern Python repo.
- FastAPI β First Steps website Shows the app object and decorator pattern that make FastAPI entry points and routes findable.
- Adam Wiggins β The Twelve-Factor App β Config website Why configuration belongs in environment variables, which is why grepping for env reads maps a system's real inputs.
- Scott Chacon & Ben Straub β Pro Git book Reference for using history to find the parts of a repo that actually change.