πŸͺ΄ GoDeep
Search
← Shipping Software

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 seeWhat it usually means
Two settings modules, or BaseSettings plus scattered os.getenvAn agent added a second system; the one you edit may not be on the live path
utils.py and helpers.py with overlapping functionsDuplicated logic that will drift apart and be fixed in only one place
requests.get inside an async defA blocking call on the event loop: one slow upstream stalls every other request
--reload in a Dockerfile or startup commandThe deployment was never reviewed
A module-level dict used as a cache or rate limiterBreaks silently as soon as there is more than one worker
.env committed to the repoRotate those credentials today
A model field with no corresponding migrationWorks 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 CMD or 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 contain async 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.

Sources