Someone left a comment on one of my posts describing a bug I had not written about, in a tool I did not build.
They had been working with an MCP codebase-intelligence server. They asked it about a symbol. It answered with a file path, a definition, and a list of references, all well-formed. The answer described a shadow definition sitting in experiments/, not the real one in src/. Nothing errored. Nothing was stale. The tool had found two definitions with the same name and returned whichever one it reached first.
I went and read my own code. analyze_function is the tool an assistant is supposed to call before writing a handler: it reports which tables that function queries, how it queries them, which permissions its role is missing, and the exact event shape of its trigger. It looked like this:
const funcNode = currentGraph.nodes.find(
(n) => n.type === 'function' && n.name === functionName,
);
Enter fullscreen mode Exit fullscreen mode
.find(). First match wins, silently. That is issue #103, and the person who caused me to open it was describing someone else’s product.
The failure mode that has no error to report
I had already thought about two ways infrastructure context goes wrong, and shipped fixes for both.
The first is unread. A source fails to extract, the tool returns an empty list, and an empty list reads exactly like “there is nothing there”. Ask whether a queue has a dead-letter queue after the SQS extractor threw a permissions error, and “no DLQ configured” is a sentence the tool has no right to say. That is issue #101, and the fix was to attach a per-source status to every response so a failed read is never mistaken for an absent resource.
The second is stale. The data was read correctly, and then someone ran terraform apply. The snapshot is internally consistent and describes an account that no longer exists. That is issue #102: every response now carries when the infrastructure was read and how long ago, so a caller can judge a three-day-old answer against the question it is answering.
Issue #103 is neither. The extraction succeeded. The data is seconds old. Every field in the response is a real value read from a real source. The response is simply about a different function than the one you asked about, and there is no signal anywhere in it that says so. Freshness metadata does not help. Source status does not help. Both report, correctly, that everything worked.
This is the part I want to argue about, because it changes what a tool owes its caller. Staleness and read failures are conditions you can attach metadata to. Wrong-candidate resolution is a property of the function signature. A lookup that returns one thing when two things matched has already destroyed the evidence that a choice was made. No amount of metadata bolted onto the response can reconstruct it.
Why the wrong answer is the confident one
Function node IDs in the graph are file-scoped. They are built as function:${op.filePath}:${op.functionName} (src/graph/index.ts:452), so getOrder in src/handler.ts and getOrder in experiments/handler.ts are two genuinely distinct nodes with two distinct sets of outgoing edges.
The lookup matched on n.name alone. Which node came back depended on the order the AST scan happened to walk the file tree.
Now trace what an assistant does with that. It calls analyze_function for getOrder because it is about to modify src/handler.ts. It gets back found: true, a real file path, a real list of table accesses, real findings. Suppose the scan reached experiments/handler.ts first: that scratch file queries public.users, while the real handler scans public.orders and has a high-severity finding attached to that scan. The assistant now believes the function it is editing touches users, has no scan problem, and needs no index work. Every downstream decision it makes is coherent, well-reasoned, and built on the wrong file.
Compare that to a tool that fails loudly. A permissions error is annoying and it is honest. You retry, you fix the role, you move on. A well-formed answer about the wrong file is worse than an error, because nothing in your workflow is designed to catch it. You are not going to double-check a response that looks perfectly correct.
The same bug, one layer down
Once I knew the shape, I found it again in the AST scanner. Issue #44: resolving an identifier to its string value did this:
sourceFile
.getDescendantsOfKind(SyntaxKind.VariableDeclaration)
.find((d) => d.getName() === name);
Enter fullscreen mode Exit fullscreen mode
A file-wide search for a variable name, returning the first declaration found, regardless of which scope the call site was in. Two functions in one file, each with its own const tableName, and every query in the second function got attributed to the first function’s table.
The consequences compound in both directions. Edges land on the wrong table node, so an analyzer flags a missing index on a table that does not need one, and misses a full scan on the table that does. A false finding and a suppressed real finding, from one wrong resolution.
The fix was to stop searching by name and ask the type checker instead:
if (Node.isIdentifier(node)) {
const symbol = node.getSymbol();
if (symbol) {
for (const decl of symbol.getDeclarations()) {
if (Node.isVariableDeclaration(decl)) {
const init = decl.getInitializer();
if (init) return resolveStringValue(init, sourceFile);
}
}
}
}
Enter fullscreen mode Exit fullscreen mode
getSymbol() resolves the identifier the way TypeScript itself resolves it, from the call site outward through enclosing scopes. The name-matching search was never resolution. It was a guess that happened to be right most of the time, which is the most dangerous kind of wrong.
Two different layers of the same codebase, written months apart, both reached for “find the thing with this name” and both got it wrong in the same way. That is not carelessness. .find() is what the language hands you when you ask for a lookup, and it produces a value rather than a complaint. The API shape pulls you toward the bug.
What the codebase was already doing right
The uncomfortable part of issue #103 was that two other resolution paths in the same file already handled ambiguity properly. I had solved this problem, twice, and then not applied it in the third place.
Short-name table qualification. SQL text names tables unqualified (orders), while extracted nodes are schema-qualified (public.orders), so code edges have to be resolved against the extracted schema. When a short name maps to more than one qualified table, the map stores an empty string as a poison value:
qualifiedByShortName.set(key, qualifiedByShortName.has(key) ? '' : n.name);
Enter fullscreen mode Exit fullscreen mode
The empty string is falsy, so qualify() falls through to a placeholder schema instead of binding to one of the two real tables. A collision produces a node that is visibly unresolved rather than an edge pointing confidently at a coin flip.
Schema lookup. get_table_schema uses filter, not find. Ask for orders and you get every table matching that short name across every database, and the tool contract says so explicitly. When nothing matches, it returns up to five suggestions instead of an empty result that might be read as “no such table”.
The Lambda linkers. Both linkers that connect a deployed Lambda to the source function implementing it contain the same line:
if (matches.length !== 1) continue;
Enter fullscreen mode Exit fullscreen mode
The IaC linker reads handler paths out of Terraform or CDK and links only when exactly one source function matches both the file base and the export name; those links are marked confidence: 'proven'. The heuristic linker normalizes names and links only when exactly one function matches; those are confidence: 'inferred'. Either way, two candidates means no link at all. The graph would rather have a missing edge than a wrong one.
So the pattern was established. analyze_function was the one place that had not adopted it.
Return the fork, do not resolve it
The fix is small, which is usually the case once the shape is named:
const funcNodes = currentGraph.nodes.filter(
(n) => n.type === 'function' && n.name === functionName,
);
Enter fullscreen mode Exit fullscreen mode
filter instead of find. Then per-file detail moves into a matches array, one entry per source file defining a function with that name, each with its own file, accesses, and missingPermissions, because all three derive from that specific node’s edges. When more than one matched, the response carries ambiguous: true, so the caller sees a fork rather than having to notice that an array got longer.
The regression test is deliberately literal about the scenario from the comment:
it('analyze_function returns every same-named function, not just the first', ...)
Enter fullscreen mode Exit fullscreen mode
It builds a graph with getOrder in handler.ts and getOrder in experiments/handler.ts, then asserts ambiguous is true, matches has length 2, and the second match’s access resolves to the table only the shadow file touches. If someone reintroduces a .find(), that test fails on the length assertion before anything else.
The same rule applies one level up, where several deployed Lambdas share a handler path. index.handler repeated across a stack is the ordinary case, not the exotic one, and it means several Lambda nodes legitimately link to a single source function. When that happens the tool returns candidateLambdas with each Lambda name and its link confidence, and withholds the triggers entirely, because attaching one Lambda’s SQS trigger to code shared by five of them is exactly the confident-and-wrong answer this whole exercise is about. When exactly one Lambda links, you get resolvedLambda: { lambda, confidence } instead, and the triggers come with it.
Notice what is deliberately absent: there is no file input for disambiguating the call. Adding one would move the decision back to the caller before the caller knows the fork exists. Returning the candidates lets whoever asked pick using context the tool does not have, which is usually just “the file I currently have open”.
That is the tradeoff I would defend. The response got larger and slightly harder to consume. An assistant now has to read ambiguous and decide, rather than taking a single answer and running. In exchange, there is no configuration of the repo where the tool asserts something it did not prove.
The general rule
If a lookup can match more than one thing, the return type has to be able to say so. A tool that resolves ambiguity internally is not saving its caller work, it is making a decision on the caller’s behalf using less information than the caller has, and then hiding that a decision happened at all.
Three shapes are honest: return every candidate, return nothing and say why, or return one with an explicit confidence marker. What is not honest is returning one of several as if it were the only one. Once you look for it, .find() on a name shows up everywhere, and each one is a small silent assertion that names are unique when the code plainly says they are not.
For an AI assistant this matters more than it does for a human reading the same output. A person who gets back experiments/handler.ts when they asked about src/handler.ts notices the path. An assistant folds it into context and moves on, and the wrong file is now a premise for everything it writes next.
Infrawise is on GitHub and npm if you want to see how the graph, the linkers, and the MCP tools fit together. npx infrawise start gets you a live analysis and an .mcp.json without a config file.
Key takeaways
- Fresh, successful, and wrong is a distinct failure mode from stale or unread, and neither freshness metadata nor source-status metadata will catch it. It lives in the function signature, not the response body.
-
.find()on a name is an assertion that names are unique. In a file-scoped graph, in a scoped language, or across the stacks of a monorepo, that assertion is false more often than it looks. - A wrong answer that is well-formed is more expensive than an error, because nothing downstream is built to question it.
- If ambiguity is possible, make the return type able to express it: all candidates, or nothing with a reason, or one with a confidence marker. Not one of several, unmarked.
- Refusing to link is a valid outcome.
if (matches.length !== 1) continue;produces a graph with a missing edge instead of a wrong one, and a missing edge is a thing you can notice.
When a lookup in your codebase matches two things, what does it return today, and would you be able to tell from the output that there was ever a second candidate?