# Your agent trusts the first match. Should it?

> Source: <https://dev.to/siddharth_pandey_27/your-agent-trusts-the-first-match-should-it-350k>
> Published: 2026-09-01 09:09:03+00:00

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:

``` js
const funcNode = currentGraph.nodes.find(
  (n) => n.type === 'function' && n.name === functionName,
);
```

`.find()`

. First match wins, silently. That is [issue #103](https://github.com/Sidd27/infrawise/issues/103), and the person who caused me to open it was describing someone else's product.

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](https://github.com/Sidd27/infrawise/issues/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](https://github.com/Sidd27/infrawise/issues/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.

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.

Once I knew the shape, I found it again in the AST scanner. [Issue #44](https://github.com/Sidd27/infrawise/issues/44): resolving an identifier to its string value did this:

```
sourceFile
  .getDescendantsOfKind(SyntaxKind.VariableDeclaration)
  .find((d) => d.getName() === name);
```

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:

``` js
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);
      }
    }
  }
}
```

`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.

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);
```

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;
```

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.

The fix is small, which is usually the case once the shape is named:

``` js
const funcNodes = currentGraph.nodes.filter(
  (n) => n.type === 'function' && n.name === functionName,
);
```

`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', ...)
```

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.

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](https://github.com/Sidd27/infrawise) and [npm](https://www.npmjs.com/package/infrawise) 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.

`.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.`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?
