Chapter 4 of 6 · 9 min read
A span carries the code but not the file's local rules
On this page
The retrieval step did its job. The task was to extend token refresh with a grace period, the top-ranked chunk was refresh_token itself, and it arrived as a span — the contiguous slice of a file that retrieval hands back, here a single complete function — 30 lines and 380 tokens out of a 900-line module.
def refresh_token(session: Session, token: Token) -> Token:
if token.expires_at > datetime.utcnow():
return token
new = _mint(session, token.subject, ttl=DEFAULT_TTL)
session.revoke(token.id)
return newThe patch that came back added the grace period correctly and called datetime.utcnow() twice more while doing it. Every other function in auth/tokens.py takes time from an injected clock: Clock parameter, because the test suite freezes time by passing a fake clock, and utcnow() in that file is a leftover from a migration that stopped one function short. The patch also ignored REFRESH_DISABLED, a module-level flag set from an environment variable, which every other entry point in the file checks before minting anything so that the staging environment cannot issue tokens against production keys.
Both of those rules were in the file. Neither was in the span. And the model had no way to know it was missing them, because the 30 lines it received were syntactically complete, internally consistent, and demonstrated utcnow() as the established practice — the one function that used it was the one it was shown.
What a chunk boundary throws away
The habit of retrieving spans came from text search, where a chunk is a paragraph and a paragraph is close to self-contained. Code chunkers inherited the idea and got more careful about boundaries — split on declarations rather than on a fixed character count, so a function is never cut in half, which is what the parser-based chunking approaches recommend and what most production indexers now do. Respecting the syntax tree removes the ugliest failure, where a chunk ends mid-body, and it leaves the deeper one untouched: a declaration is a syntactic unit and not a semantic one. What determines whether an edit to refresh_token is safe is spread across the file in three places that a declaration-aligned chunker will never include.
The imports are the first, and they are the reason the model cannot tell datetime.utcnow from an injected clock, or format_currency from billing.money from the same name imported from anywhere else — the file header is where a name is bound to a meaning. Module-level constants and flags are the second, and REFRESH_DISABLED is typical of them: a constraint expressed once at the top and honoured by convention everywhere below. The conventions of the neighbouring declarations are the third, and they are the hardest to name because they are not written down anywhere; they exist as the shape that eight sibling functions happen to share. A function that takes clock: Clock as its second parameter, in a file where seven others do, is following a rule that only exists in the aggregate.
Reading a span in isolation is close to reviewing a pull request with three lines of context and no file access. The diff is legible, the logic is checkable, and the question a reviewer actually needs to answer — does this match how the rest of this module works — is exactly the question three lines of context cannot answer.
The header and the enclosing declarations, for 13% of the file
Restoring the missing rules does not require the file. It requires the parts of the file that carry the rules, which are small and identifiable by structure: the header down to the last import and module-level assignment, the chain of declarations enclosing the span, and the signatures of the siblings without their bodies.
# --- header (imports + module constants), 210 tokens -------------------
from datetime import datetime
from zoneinfo import ZoneInfo
from auth.clock import Clock, system_clock
from auth.session import Session
from auth.types import Token
DEFAULT_TTL = timedelta(minutes=15)
REFRESH_GRACE = timedelta(seconds=30)
REFRESH_DISABLED = os.environ.get("AUTH_REFRESH_DISABLED") == "1"
# --- sibling signatures, bodies elided, 520 tokens ---------------------
def mint_token(session: Session, subject: str, *, clock: Clock = system_clock) -> Token: ...
def revoke_token(session: Session, token_id: str, *, clock: Clock = system_clock) -> None: ...
def verify_token(raw: str, *, clock: Clock = system_clock) -> Token | None: ...
def _mint(session: Session, subject: str, ttl: timedelta, *, clock: Clock) -> Token: ...
# --- the target span, full body, 380 tokens ----------------------------
def refresh_token(session: Session, token: Token) -> Token:
if token.expires_at > datetime.utcnow():
return token
new = _mint(session, token.subject, ttl=DEFAULT_TTL)
session.revoke(token.id)
return newThat assembly — a skeleton, meaning the file with its imports and constants intact, its declarations present down to the end of each signature, and every body except the target's replaced by a marker — is 1,110 tokens against 11,400 for the whole module, and it carries all three of the missing rules explicitly. The clock keyword appears in four signatures, so the convention is visible as a convention. REFRESH_DISABLED is present with the environment variable it reads. REFRESH_GRACE turns out to already exist, which the span-only run never discovered — the agent defined its own _GRACE = 30 and the reviewer caught the duplicate. The sibling bodies, which are where most of the 11,400 tokens live and where the near-duplicate implementations of the previous chapter tend to hide, are the part that stays out.
For a method inside a class the enclosing chain matters as much as the header. The class statement, its decorators, its class-level attributes and the __init__ signature establish what self holds, and a method span shown without them invites the model to invent state — a patch that stores a cache on self._pending in a class whose __slots__ declaration fixes the permitted attribute names and forbids it, fails at runtime rather than at review.
| whole file | span only | header + enclosing + skeleton | |
|---|---|---|---|
| tokens, 1,400-line service class | 18,000 | 600 | 2,300 |
| imports and module constants | yes | no | yes |
| sibling conventions | yes, with their bodies | no | signatures only |
| every reference to a private helper | yes | no | names only, not call sites |
| unrelated implementations exposed | all of them | none | none |
| cost across a 6-file task | 108k, over the ceiling | 3.6k | 13.8k |
The bottom row is the one that decides most arguments about this. A 1,400-line service class at 18k tokens is genuinely nothing against a 200k window, considered alone, and the reasoning that stops there is reasoning from window size instead of from the ceiling derived in the first chapter. A task touches six or eight files. Whole-file inclusion for all of them is 108k to 144k at turn 0, against a first-turn ceiling of 70k, so the run is over budget before the first test executes — and by the argument of the previous chapter, the sibling bodies that filled it are not inert, they are candidate answers.
Mechanics that decide whether it works
Building the assembly is a syntax-tree walk rather than a heuristic on text, the syntax tree being the structured form of a file that a parser produces, in which each import, declaration, signature and body is a node the walk can address by kind. A query over the parse tree keeps every import and module-level assignment, keeps declaration nodes down to the end of their signature — including decorators, which carry routing, transactions and caching semantics that change what a body is allowed to do — and replaces bodies with an elision marker everywhere except the target. Type aliases, TypedDict definitions and enums are kept whole, since their bodies are their signatures. The walk costs a few milliseconds per file with an incremental parser and the result caches against the file's content hash, which matters because a builder that regenerates skeletons for eight files on every turn adds latency to a loop that already pays for prefill.
The elision marker has to be visible in the rendered context, and this is the detail most implementations get wrong. A body replaced by nothing reads as a function that does nothing, and a model will reason about def revoke_token(...) -> None: with an empty body as though revocation were a no-op. Writing ... # 34 lines elided states both that code was removed and roughly how much, so the model can judge whether it needs to see it. Pairing that with a read_symbol(path, name) tool closes the loop: the skeleton is the index, and the model expands what it decides it needs, which in practice is one or two symbols per task rather than the eight the builder would have guessed.
Keeping the assembly in file order, with original line numbers, is worth the small extra cost. A patch is expressed as hunks against line numbers, and a model that has seen refresh_token at line 412 in a file whose other declarations appeared in their real positions produces hunks that apply; a model shown a reordered digest produces hunks that need fuzzy matching. Line numbers also give the agent a way to ask for a range rather than a symbol, which is the fallback when the parse fails on a file with a syntax error — a common state during a run, since the agent's own half-finished edits are what caused it.
What counts as the header varies more across languages than it first appears. In Python it is imports plus module constants; in Go it is the package clause, the import block and package-level var and const declarations, and a method span without its receiver type's definition is close to useless. Java adds annotations on the class and field declarations that determine transaction and injection behaviour, and TypeScript adds the generic parameters of the enclosing class along with any declare module augmentation, which is where the interesting type behaviour usually hides. Each language needs its own query, and each query is small once written.
The cases where the file is right
Some tasks need the whole file, and they share a property: the edit's correctness depends on completeness rather than on convention.
A pure rename of a private helper is the clearest example. Getting it right means changing every reference, and a skeleton lists the sibling signatures without their bodies, which is precisely where the references are. Send the file, or better, send the reference list that a language server produces in 40 milliseconds, a language server being the background process that answers the go-to-definition and find-references queries in your editor by keeping an index of the project. Its textDocument/references request returns the exact call sites, which is smaller than the file and complete in a way retrieval is not. Where a rename tool exists, the honest answer is that the agent should not be writing this patch by hand at all.
Change the task to "make this method idempotent" and the answer moves. Idempotency is a property of the object's state across calls, so the relevant material is every method that mutates the same attributes, the constructor that initialises them, and the tests that describe the current behaviour — some of which is in this file and some of which is in tests/. A skeleton is too thin, because the mutation happens in the bodies that were elided, and a whole file is too narrow, because the invariant is not confined to one file. Selection is being driven by what makes the edit safe, which changes with the edit rather than with the file.
Two other cases favour the file. Short files should not be skeletonised, because a 120-line module costs 1.5k tokens whole and the skeleton saves a few hundred while adding a step where something can go wrong. Files whose whole point is a flat list of declarations — settings modules, constant tables, route registries — are already close to their own skeleton, and eliding the bodies removes the values that were the reason to include the file.
What the middle option does not do is decide which files enter in the first place. A skeleton of the wrong module is cheaper than the wrong module and no more correct, and the previous chapter's rival implementations arrive as skeletons just as readily as bodies — three format_currency signatures in the window is still three choices. There is also a case the skeleton makes worse rather than better, and it is the subject of the next chapter: for a package the task has no business modifying, a skeleton showing internal helpers is an invitation to reach into them, whereas showing only the public interface is a boundary the model can see. What enters context, and in what depth, turns out to be a statement about permission.
Context selection over a codebase — read the current version at https://daviddominguez.dev/learn/en/context-selection-over-a-codebase/a-span-carries-the-code-but-not-the-file-s-local-rules