{"slug": "your-agent-trusts-the-first-match-should-it", "title": "Your agent trusts the first match. Should it?", "summary": "A developer discovered that an MCP codebase-intelligence server can return the wrong function definition when multiple symbols share the same name, because the lookup uses `.find()` and returns the first match without signaling ambiguity. The issue, filed as #103 in the Infrawise project, follows two earlier fixes for stale and unread infrastructure data, and highlights a class of errors that metadata cannot mitigate.", "body_md": "Someone left a comment on one of my posts describing a bug I had not written about, in a tool I did not build.\n\nThey 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/`\n\n, not the real one in `src/`\n\n. Nothing errored. Nothing was stale. The tool had found two definitions with the same name and returned whichever one it reached first.\n\nI went and read my own code. `analyze_function`\n\nis 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:\n\n``` js\nconst funcNode = currentGraph.nodes.find(\n  (n) => n.type === 'function' && n.name === functionName,\n);\n```\n\n`.find()`\n\n. 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.\n\nI had already thought about two ways infrastructure context goes wrong, and shipped fixes for both.\n\nThe 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.\n\nThe second is **stale**. The data was read correctly, and then someone ran `terraform apply`\n\n. 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.\n\nIssue #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.\n\nThis 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.\n\nFunction node IDs in the graph are file-scoped. They are built as `function:${op.filePath}:${op.functionName}`\n\n(`src/graph/index.ts:452`\n\n), so `getOrder`\n\nin `src/handler.ts`\n\nand `getOrder`\n\nin `experiments/handler.ts`\n\nare two genuinely distinct nodes with two distinct sets of outgoing edges.\n\nThe lookup matched on `n.name`\n\nalone. Which node came back depended on the order the AST scan happened to walk the file tree.\n\nNow trace what an assistant does with that. It calls `analyze_function`\n\nfor `getOrder`\n\nbecause it is about to modify `src/handler.ts`\n\n. It gets back `found: true`\n\n, a real file path, a real list of table accesses, real findings. Suppose the scan reached `experiments/handler.ts`\n\nfirst: that scratch file queries `public.users`\n\n, while the real handler scans `public.orders`\n\nand has a high-severity finding attached to that scan. The assistant now believes the function it is editing touches `users`\n\n, has no scan problem, and needs no index work. Every downstream decision it makes is coherent, well-reasoned, and built on the wrong file.\n\nCompare 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.\n\nOnce 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:\n\n```\nsourceFile\n  .getDescendantsOfKind(SyntaxKind.VariableDeclaration)\n  .find((d) => d.getName() === name);\n```\n\nA 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`\n\n, and every query in the second function got attributed to the first function's table.\n\nThe 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.\n\nThe fix was to stop searching by name and ask the type checker instead:\n\n``` js\nif (Node.isIdentifier(node)) {\n  const symbol = node.getSymbol();\n  if (symbol) {\n    for (const decl of symbol.getDeclarations()) {\n      if (Node.isVariableDeclaration(decl)) {\n        const init = decl.getInitializer();\n        if (init) return resolveStringValue(init, sourceFile);\n      }\n    }\n  }\n}\n```\n\n`getSymbol()`\n\nresolves 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.\n\nTwo 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()`\n\nis 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.\n\nThe 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.\n\n**Short-name table qualification.** SQL text names tables unqualified (`orders`\n\n), while extracted nodes are schema-qualified (`public.orders`\n\n), 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:\n\n```\nqualifiedByShortName.set(key, qualifiedByShortName.has(key) ? '' : n.name);\n```\n\nThe empty string is falsy, so `qualify()`\n\nfalls 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.\n\n**Schema lookup.** `get_table_schema`\n\nuses `filter`\n\n, not `find`\n\n. Ask for `orders`\n\nand 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\".\n\n**The Lambda linkers.** Both linkers that connect a deployed Lambda to the source function implementing it contain the same line:\n\n```\nif (matches.length !== 1) continue;\n```\n\nThe 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'`\n\n. The heuristic linker normalizes names and links only when exactly one function matches; those are `confidence: 'inferred'`\n\n. Either way, two candidates means no link at all. The graph would rather have a missing edge than a wrong one.\n\nSo the pattern was established. `analyze_function`\n\nwas the one place that had not adopted it.\n\nThe fix is small, which is usually the case once the shape is named:\n\n``` js\nconst funcNodes = currentGraph.nodes.filter(\n  (n) => n.type === 'function' && n.name === functionName,\n);\n```\n\n`filter`\n\ninstead of `find`\n\n. Then per-file detail moves into a `matches`\n\narray, one entry per source file defining a function with that name, each with its own `file`\n\n, `accesses`\n\n, and `missingPermissions`\n\n, because all three derive from that specific node's edges. When more than one matched, the response carries `ambiguous: true`\n\n, so the caller sees a fork rather than having to notice that an array got longer.\n\nThe regression test is deliberately literal about the scenario from the comment:\n\n```\nit('analyze_function returns every same-named function, not just the first', ...)\n```\n\nIt builds a graph with `getOrder`\n\nin `handler.ts`\n\nand `getOrder`\n\nin `experiments/handler.ts`\n\n, then asserts `ambiguous`\n\nis `true`\n\n, `matches`\n\nhas length 2, and the second match's access resolves to the table only the shadow file touches. If someone reintroduces a `.find()`\n\n, that test fails on the length assertion before anything else.\n\nThe same rule applies one level up, where several deployed Lambdas share a handler path. `index.handler`\n\nrepeated 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`\n\nwith 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 }`\n\ninstead, and the triggers come with it.\n\nNotice what is deliberately absent: there is no `file`\n\ninput 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\".\n\nThat is the tradeoff I would defend. The response got larger and slightly harder to consume. An assistant now has to read `ambiguous`\n\nand 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.\n\nIf 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.\n\nThree 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()`\n\non 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.\n\nFor an AI assistant this matters more than it does for a human reading the same output. A person who gets back `experiments/handler.ts`\n\nwhen they asked about `src/handler.ts`\n\nnotices the path. An assistant folds it into context and moves on, and the wrong file is now a premise for everything it writes next.\n\nInfrawise 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`\n\ngets you a live analysis and an `.mcp.json`\n\nwithout a config file.\n\n`.find()`\n\non 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;`\n\nproduces 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?", "url": "https://wpnews.pro/news/your-agent-trusts-the-first-match-should-it", "canonical_source": "https://dev.to/siddharth_pandey_27/your-agent-trusts-the-first-match-should-it-350k", "published_at": "2026-09-01 09:09:03+00:00", "updated_at": "2026-09-01 09:23:29.602205+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["MCP", "Infrawise", "Sidd27"], "alternates": {"html": "https://wpnews.pro/news/your-agent-trusts-the-first-match-should-it", "markdown": "https://wpnews.pro/news/your-agent-trusts-the-first-match-should-it.md", "text": "https://wpnews.pro/news/your-agent-trusts-the-first-match-should-it.txt", "jsonld": "https://wpnews.pro/news/your-agent-trusts-the-first-match-should-it.jsonld"}}