fix(mcp): resolve module-qualified symbol lookups (#173) (#179)

`codegraph_callees stage_apply::run` (and `_node`, `_impact`, ...)
returned "not found" against a repo with 7-9 sibling Rust modules,
each exporting `pub async fn run`. Two underlying issues:

1. The FTS5 query builder stripped `:` as a special char without
   splitting on `::`, so `stage_apply::run` collapsed to the literal
   `stage_applyrun` which matches nothing. Treat `::` as whitespace
   before the strip step so both halves become FTS tokens.

2. `matchesSymbol` only understood `Parent.child` qualifiers and
   relied on `qualifiedName` carrying the module path. Rust file-
   level functions don't have their module name in `qualifiedName`
   (it's encoded in the file path instead), so even dot-style
   lookups failed. Accept `::`, `.`, `/` as separators; multi-level
   forms compose; Rust `crate::`/`super::`/`self::` prefixes get
   stripped before path matching. Fall back to file-path containment
   when the qualified-name suffix doesn't match — `stage_apply::run`
   matches a `run` in any file whose path has a `stage_apply` segment.

Also tightens the no-match branch: qualified lookups no longer fall
through to a fuzzy text match. `stage_apply::nonexistent_fn` returns
`null` instead of silently resolving to an unrelated `rollback` in
the same file.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-19 11:02:26 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e176062c56
commit 83f36dc170
4 changed files with 311 additions and 17 deletions
+7 -1
View File
@@ -696,8 +696,14 @@ export class QueryBuilder {
const { kinds, languages, limit = 100, offset = 0 } = options;
// Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate")
// Escape special FTS5 characters and add prefix wildcard
// Escape special FTS5 characters and add prefix wildcard.
//
// `::` is a qualifier separator in Rust/C++/Ruby, not a token char,
// so treat it as whitespace before the strip step. Otherwise queries
// like `stage_apply::run` collapse to `stage_applyrun` (the colons
// are stripped without splitting) and find nothing. See #173.
const ftsQuery = query
.replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator
.replace(/['"*():^]/g, '') // Remove FTS5 special chars
.split(/\s+/)
.filter(term => term.length > 0)
+87 -16
View File
@@ -16,6 +16,21 @@ import { WASM_FALLBACK_FIX_RECIPE } from '../db';
/** Maximum output length to prevent context bloat (characters) */
const MAX_OUTPUT_LENGTH = 15000;
/**
* Rust path roots that have no file-system equivalent — `crate` is the
* current crate, `super` is the parent module, `self` is the current
* module. Used by `matchesSymbol` to strip these before file-path
* matching so `crate::configurator::stage_apply::run` resolves the
* same as `configurator::stage_apply::run`.
*/
const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
/** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
function lastQualifierPart(symbol: string): string {
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
return parts[parts.length - 1] ?? symbol;
}
/**
* Calculate the recommended number of codegraph_explore calls based on project size.
* Larger codebases need more exploration calls to cover their surface area,
@@ -1204,9 +1219,22 @@ export class ToolHandler {
* Returns the best match and a note about alternatives if any.
*/
/**
* Check if a node matches a symbol query, supporting both simple names and
* qualified "Parent.child" notation (e.g., "Session.request" matches a method
* named "request" inside a class named "Session").
* Check if a node matches a symbol query.
*
* Accepts simple names (`run`) and three flavors of qualifier:
* - dotted `Session.request` (TS/JS/Python)
* - colon-pair `stage_apply::run` (Rust, C++, Ruby)
* - slash `configurator/stage_apply` (path-ish)
*
* Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
* works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
* the canonical `crate::module::symbol` form resolves.
*
* Resolution order, last part must always equal `node.name`:
* 1. Suffix-match against `qualifiedName` (handles class-scoped methods
* where the extractor builds the qualified name from the AST stack)
* 2. File-path containment (handles file-derived modules in Rust/
* Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
*/
private matchesSymbol(node: Node, symbol: string): boolean {
// Simple name match
@@ -1214,21 +1242,52 @@ export class ToolHandler {
// File basename match (e.g., "product-card" matches "product-card.liquid")
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
// Qualified name match: "Parent.child" → look for "::Parent::child" in qualified_name
if (symbol.includes('.')) {
const parts = symbol.split('.');
const qualifiedSuffix = parts.join('::');
if (node.qualifiedName.includes(qualifiedSuffix)) return true;
}
// Qualified-name lookups: split on any supported separator. `\w` keeps
// identifier chars (incl. `_`) intact; everything else is treated as
// a separator we tolerate.
if (!/[.\/]|::/.test(symbol)) return false;
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
if (parts.length < 2) return false;
return false;
const lastPart = parts[parts.length - 1]!;
if (node.name !== lastPart) return false;
// Stage 1: qualified-name suffix match. The extractor joins the
// semantic hierarchy with `::`, so `Session.request` and
// `Session::request` both become `Session::request` here.
const colonSuffix = parts.join('::');
if (node.qualifiedName.includes(colonSuffix)) return true;
// Stage 2: file-path containment. Rust modules and Python packages
// are not in `qualifiedName` — they're encoded in the file path. So
// `stage_apply::run` matches a `run` in any file whose path
// contains a `stage_apply` segment (with or without an extension).
//
// Filter out Rust path prefixes that have no file-system equivalent.
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
if (containerHints.length === 0) return false;
const segments = node.filePath.split('/').filter((s) => s.length > 0);
return containerHints.every((hint) =>
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
);
}
private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
// Use higher limit for qualified lookups (e.g., "Session.request") since the
// target may rank lower in FTS when there are many partial matches
const limit = symbol.includes('.') ? 50 : 10;
const results = cg.searchNodes(symbol, { limit });
// Use higher limit for qualified lookups (e.g., "Session.request",
// "stage_apply::run") since the target may rank lower in FTS when
// there are many partial matches across the qualifier parts.
const isQualified = /[.\/]|::/.test(symbol);
const limit = isQualified ? 50 : 10;
let results = cg.searchNodes(symbol, { limit });
// FTS strips colons as a special char, so `stage_apply::run` searches
// for the literal `stage_applyrun` and finds nothing. Re-search by
// the bare last part and let `matchesSymbol` filter by qualifier.
if (isQualified && results.length === 0) {
const tail = lastQualifierPart(symbol);
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
}
if (results.length === 0 || !results[0]) {
return null;
@@ -1250,7 +1309,11 @@ export class ToolHandler {
return { node: picked, note };
}
// No exact match, use best fuzzy match
// No exact match. For qualified lookups, don't silently fall back
// to a fuzzy result — the user typed a specific qualifier, and
// resolving `stage_apply::nonexistent_fn` to the unrelated
// `stage_apply.rs` file would be actively misleading (#173).
if (isQualified) return null;
return { node: results[0]!.node, note: '' };
}
@@ -1259,7 +1322,15 @@ export class ToolHandler {
* results across all matching symbols (e.g., multiple classes with an `execute` method).
*/
private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
const results = cg.searchNodes(symbol, { limit: 50 });
let results = cg.searchNodes(symbol, { limit: 50 });
// Mirror the fallback in `findSymbol` for qualified queries — FTS
// strips colons, so a module-qualified lookup needs a second pass
// by the bare last part.
if (results.length === 0 && /[.\/]|::/.test(symbol)) {
const tail = lastQualifierPart(symbol);
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
}
if (results.length === 0) {
return { nodes: [], note: '' };