Agent Rules / Agent workflow
What to check before changing a function signature?
Rule AR-25
Before changing a signature, list every caller and the dynamic sites that might reach it.
A signature is a contract with every line that calls it. A coding agent that edits the definition first and looks for callers afterwards is checking against its own change. Make the list first, write down which entries are direct calls and which are places where the call can't be read from the source (built names, tables, decorators, re-exports), and only then edit.
Why it matters
Take a billing helper used from three modules:
# billing/money.py
def format_amount(value: Decimal, currency: str) -> str: ...The task: "add a locale parameter". The agent greps for format_amount(, finds eleven calls, and updates them. Two more reach the function another way:
# billing/__init__.py
from .money import * # callers import from `billing`, not `billing.money`
# reports/render.py
FORMATTERS = {"amount": billing.format_amount}
...
FORMATTERS[col.kind](row[col.name], cfg.currency) # no name to grepThe search for format_amount( finds neither. The type checker catches the first one only if the call is resolved through the star import, and it can't check the second, because the table lookup has no single target. The edit looks finished. The report render fails on the first run that has an amount column.
The mistake isn't carelessness. A text search is a good start, and it stops being enough exactly where the code is built to hide the call. Listing the dynamic sites before the edit is what turns "I found the callers" into "I know where my list could be short".
The steps
- Name the target exactly. File, qualified name, and which one if the name repeats (two
format_amountin different modules are two targets). - Ask the language server for references. In an editor or an LSP client, that's
textDocument/referenceson the definition (see AR-28 for what the protocol gives you). It follows imports, which text search doesn't. - Run a text search for the bare name as a second pass. It finds what the server can't resolve. ``
bash rg -n --word-regexp "format_amount" --glob "!*.min.js" rg -n "format_amount" --type py --type ts - Search for the dynamic shapes that could reach the function without its name: ``
bash rg -n "getattr\(|__getattr__|importlib|\[[a-z_.]+\]\(" --type py rg -n "export \* from|from .* import \*" --type py --type ts rg -n "\[[a-zA-Z.]+\]\(" --type ts`Also look for dict or list tables that contain the function, decorators that wrap it, and subclasses that override it (rg -n "def format_amount"` finds overrides with the same name). - Write the list down, in the task notes or the reply, in two groups: "direct calls" (file:line) and "possible dynamic sites" (file:line and why).
- Check the tests that cover each group.
rg -n "format_amount" tests/tells you which tests touch it; a dynamic site with no test is a risk to name in the final report (AR-30). - Only then edit. Change the definition and every entry on the list in one pass, then go to AR-26.
Worked example
Before (the agent's pre-edit notes, as an illustration of the practice, not a captured run):
target: billing/money.py::format_amount(value, currency)
change: add locale: str = "en"
references (LSP): 9 in billing/, 2 in reports/
text search: 11 hits (same 11)
dynamic shapes:
billing/__init__.py:3 from .money import * -> names reach callers without an import line
reports/render.py:41 FORMATTERS[col.kind](...) -> table of functions, target not readable
tests: tests/test_money.py (4), tests/test_render.py (0 for amount columns)
plan: add param with default, update 11 calls, read render.py:41 table by hand, add one testAfter the edit the same list is the checklist for AR-26. The plan already says render.py has no test for amount columns, so that fact reaches the final report instead of being found by a user.
A default value on the new parameter is a cheap way to keep unknown callers working. It trades a loud failure for a quiet one, so say so in the report when you rely on it.
Rule block for AGENTS.md / CLAUDE.md
## Before changing a function signature
- Name the exact target (file and qualified name). If the name exists more than once, treat each as its own target.
- List its callers with the language server's find-references, then run `rg -n -w <name>` as a second pass.
- Search for sites that can reach it without its name: getattr or built names, dicts or lists of functions,
decorators, `export *` and `import *` re-exports, subclasses that override it.
- Write the list in two groups, direct calls and possible dynamic sites, before editing.
- Edit the definition and every entry on the list in the same change.
- Anything on the dynamic list that you did not read or test goes in the final message.Edge cases
- A name that appears once. Text search gives the full list, and the steps above take a minute. Do them anyway; the dynamic shapes are what text search can't see.
- Public API with unknown external callers. The list is only your repo. Say so, and prefer adding an optional parameter over changing an existing one.
- Generated code and vendored directories. Exclude them from edits, but include them in the search. A generated client may call your function.
- Overrides and implementations. Changing a base-class method's signature changes every override.
rg -n "def <name>"and the type checker's override checks both belong in the list. - Cross-language calls (a Python function called from a TypeScript service over HTTP or a CLI) don't appear in any reference search. Check the interface files.
What LLMGRAPH shows
One who_calls answer lists the direct calls as certain and adds 'plus 1 dynamic call nearby that could reach anything' for the table dispatch.
References
- LSP 3.17 specification, Find References and Rename: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/. Verified 2026-10-30: the references request is sent "to resolve project-wide references" for the symbol at a position and returns
Location[] | null; the rename request asks the server "to compute a workspace change" and returnsWorkspaceEdit | null, where null means no changes are required;prepareRename(LSP 3.12.0) is used to "setup and test the validity of a rename operation" at a position. - Pyright features (Find References, Rename Symbol): https://github.com/microsoft/pyright/blob/main/docs/features.md
- TypeScript server protocol,
referencesandrenamecommands (CommandTypes): https://github.com/microsoft/TypeScript/blob/v5.6.3/src/server/protocol.ts - ripgrep user guide (
-w,--type,--glob): https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md. Verified 2026-10-30:-w/--word-regexprequires that "all matches of the pattern be surrounded by word boundaries";-tincludes a file type and-Texcludes it, andrg --type-listshows the globs that make up a type;-g/--globmakes sure every file searched matches the glob, with.gitignoreglob rules. By default ripgrep skips files matched by.gitignoreand hidden files and directories (--hiddenincludes them), so a search can miss files unless that is overridden. - Python data model,
getattr: https://docs.python.org/3/library/functions.html
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved
Related
- AR-26 How does an agent know an edit is finished?
- AR-27 Is a list of callers ever the full list?
- AR-28 Why is find-and-replace a bad rename for coding agents?
- AR-07 Why does my coding agent miss calls made through getattr?
- AR-01 Why do star imports make my coding agent miss callers?
Chimeras that use this rule
- CH-01 Why does my Django signal break after a refactor?
Last verified