diff --git a/src/adapters/mcp/register-tools.ts b/src/adapters/mcp/register-tools.ts index a2c93a91..00e3ade2 100644 --- a/src/adapters/mcp/register-tools.ts +++ b/src/adapters/mcp/register-tools.ts @@ -302,7 +302,7 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): }, async (args) => { const result = await executeImplementationLookup(runtime.projectRoot, runtime.host, args); - return { content: [{ type: "text", text: result.text }] }; + return { content: [{ type: "text", text: result.text }], structuredContent: result.details }; }, ); @@ -323,7 +323,7 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): }, async (args) => { const result = await executeCallGraph(runtime.projectRoot, runtime.host, args); - return { content: [{ type: "text", text: result.text }] }; + return { content: [{ type: "text", text: result.text }], structuredContent: result.details }; }, ); @@ -339,7 +339,7 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): }, async (args) => { const result = await executeCallGraphPath(runtime.projectRoot, runtime.host, args); - return { content: [{ type: "text", text: result.text }] }; + return { content: [{ type: "text", text: result.text }], structuredContent: result.details }; }, ); server.tool( diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index 6139f181..4358f5eb 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -16,6 +16,7 @@ import { getIndexerForProject, getCodeCommunities, implementationLookup, + isExactSymbolQuery, listKnowledgeBases, removeKnowledgeBase, runIndexCodebase, @@ -271,7 +272,10 @@ export default function codebaseIndexPiExtension(pi: ExtensionAPI): void { directory: Type.Optional(Type.String()), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const results = await implementationLookup(projectRoot(ctx), HOST, params.query, params); + const results = await implementationLookup(projectRoot(ctx), HOST, params.query, { + ...params, + exactSymbol: isExactSymbolQuery(params.query), + }); return text(formatDefinitionLookup(results, params.query), results); }, }); diff --git a/src/tools/context-search.ts b/src/tools/context-search.ts index bbf5a425..59870a4f 100644 --- a/src/tools/context-search.ts +++ b/src/tools/context-search.ts @@ -38,6 +38,8 @@ export interface CodebaseContextResult { text: string; details?: { route: "path" | "direct-edge" | "definition" | "conceptual"; + resolution?: "resolved" | "ambiguous" | "not_found"; + matchKind?: "exact_symbol" | "lexical" | "semantic" | "graph_neighbor"; routedQuery?: string; tokenBudget: number; tokenEstimate: number; @@ -114,6 +116,7 @@ interface SearchContextOperations { symbol: string, limit: number, scope: SearchScope, + exactSymbol: boolean, trace?: (trace: SearchTrace) => void, ): Promise; search( @@ -394,6 +397,7 @@ export async function resolveSearchContext( const tryDefinitionLookup = async ( symbol: string, + exactSymbol: boolean, scope: SearchScope = scopedScope, relaxedFieldsForAttempt: Array<"directory" | "fileType"> = [], ): Promise => { @@ -402,7 +406,7 @@ export async function resolveSearchContext( symbol, scope, relaxedFieldsForAttempt, - (trace) => operations.lookup(symbol, MAX_CONTEXT_RESULT_LIMIT, scope, input.diagnostic ? trace : undefined), + (trace) => operations.lookup(symbol, MAX_CONTEXT_RESULT_LIMIT, scope, exactSymbol, input.diagnostic ? trace : undefined), ); }; @@ -466,7 +470,7 @@ export async function resolveSearchContext( }; if (definitionSymbol) { - const scopedDefinitionResults = await tryDefinitionLookup(definitionSymbol); + const scopedDefinitionResults = await tryDefinitionLookup(definitionSymbol, Boolean(explicitSymbol)); if (scopedDefinitionResults.length > 0) { const heading = buildPackHeading("definition", decisions); return toResult( @@ -491,36 +495,6 @@ export async function resolveSearchContext( } if (explicitSymbol) { - if (hasFilters) { - const unscopedDefinitionResults = await tryDefinitionLookup( - definitionSymbol, - unscopedScope, - relaxedFields, - ); - if (unscopedDefinitionResults.length > 0) { - const heading = buildPackHeading("definition", decisions); - return toResult( - "definition", - definitionSymbol, - buildContextPack(unscopedDefinitionResults, { - tokenBudget, - maxResults: limit, - heading, - preserveInputOrder: true, - ...(input.diagnostic ? { - trace: (trace) => { - const attemptState = findSuccessfulAttemptState("definition"); - if (attemptState) { - attemptState.contextPackTrace = trace; - } - }, - } : undefined), - }), - findSuccessfulAttemptState("definition"), - ); - } - } - const heading = buildRecoveryFallbackText( attempts, tokenBudget, diff --git a/src/tools/context.ts b/src/tools/context.ts index 816e214a..9ade44b8 100644 --- a/src/tools/context.ts +++ b/src/tools/context.ts @@ -127,10 +127,11 @@ async function resolveCodebaseContextUnmeasured( directory, diagnostic: input.diagnostic, }, { - lookup: (lookupSymbol, retrievalLimit, scope, trace) => implementationLookup(projectRoot, host, lookupSymbol, { + lookup: (lookupSymbol, retrievalLimit, scope, exactSymbol, trace) => implementationLookup(projectRoot, host, lookupSymbol, { limit: retrievalLimit, fileType: scope.fileType, directory: scope.directory, + exactSymbol, trace, }), search: (queryText, retrievalLimit, scope, trace, searchOptions) => searchCodebase(projectRoot, host, queryText, { @@ -184,6 +185,13 @@ export async function resolveCodebaseContext( const startedAt = metricsEnabled ? performance.now() : 0; try { const result = await resolveCodebaseContextUnmeasured(projectRoot, host, input); + if (trimOrUndefined(input.symbol) && result.details) { + const candidateCount = result.details.candidateCount ?? result.details.resultCount ?? result.details.selectedCount ?? 0; + result.details.resolution = candidateCount === 0 + ? "not_found" + : candidateCount === 1 ? "resolved" : "ambiguous"; + result.details.matchKind = "exact_symbol"; + } const details = result.details; if (metricsEnabled && details) { const resultCount = contextResultCount(details); diff --git a/src/tools/execute-common.ts b/src/tools/execute-common.ts index 8eeca685..46188ca1 100644 --- a/src/tools/execute-common.ts +++ b/src/tools/execute-common.ts @@ -18,6 +18,7 @@ import { getIndexMetrics, getIndexStatus, implementationLookup, + isExactSymbolQuery, runIndexCodebase, runIndexHealthCheck, } from "./operations.js"; @@ -120,8 +121,19 @@ export async function executeImplementationLookup( limit: args.limit, fileType: args.fileType, directory: args.directory, + exactSymbol: isExactSymbolQuery(args.query), }); - return { text: formatDefinitionLookup(results, args.query) }; + const exactSymbol = isExactSymbolQuery(args.query); + return { + text: formatDefinitionLookup(results, args.query), + details: { + resolution: exactSymbol + ? (results.length === 0 ? "not_found" : results.length === 1 ? "resolved" : "ambiguous") + : "resolved", + matchKind: exactSymbol ? "exact_symbol" : "semantic", + results, + }, + }; } export async function executeCallGraph( @@ -129,7 +141,8 @@ export async function executeCallGraph( host: HostMode, args: SharedCallGraphArgs, ): Promise { - return { text: formatCallGraphResult(await getCallGraphData(projectRoot, host, args)) }; + const result = await getCallGraphData(projectRoot, host, args); + return { text: formatCallGraphResult(result), details: { resolution: result.resolution, matchKind: "exact_symbol" } }; } export async function executeCallGraphPath( @@ -146,7 +159,7 @@ export async function executeCallGraphPath( args.fromFilePath, args.toFilePath, ); - return { text: formatCallGraphPathResult(path) }; + return { text: formatCallGraphPathResult(path), details: { from: path.from, to: path.to, matchKind: "exact_symbol" } }; } export async function executeCodeCommunities( diff --git a/src/tools/operations.ts b/src/tools/operations.ts index 4578cef8..84c51587 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync, statSync } from "fs"; +import { existsSync, readFileSync, realpathSync, statSync } from "fs"; import * as path from "path"; import { parseConfig } from "../config/schema.js"; import { getHostProjectConfigRelativePath } from "../config/paths.js"; @@ -149,6 +149,65 @@ function symbolNameMatches(symbol: SymbolData, requestedName: string): boolean { : symbol.name === requestedName; } +export function isExactSymbolQuery(query: string): boolean { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(query.trim()); +} + +function exactSymbolMatchesScope( + symbol: SymbolData, + projectRoot: string, + options: { fileType?: string; directory?: string }, +): boolean { + const normalizedPath = normalizeCallGraphPath(symbol.filePath); + const fileType = trimOrUndefined(options.fileType)?.replace(/^\./, "").toLowerCase(); + if (fileType && !normalizedPath.toLowerCase().endsWith(`.${fileType}`)) { + return false; + } + + const directory = trimOrUndefined(options.directory); + if (!directory) return true; + const normalizedDirectory = normalizeCallGraphPath(directory); + const absoluteDirectory = isAbsoluteCallGraphPath(normalizedDirectory) + ? normalizedDirectory + : normalizeCallGraphPath(path.join(projectRoot, normalizedDirectory)); + return normalizedPath === absoluteDirectory || normalizedPath.startsWith(`${absoluteDirectory}/`); +} + +function exactSymbolSearchResults( + symbols: SymbolData[], + projectRoot: string, + query: string, + options: { limit?: number; fileType?: string; directory?: string }, +): SearchResult[] { + const name = query.trim(); + const limit = options.limit ?? 5; + return symbols + .filter((symbol) => symbolNameMatches(symbol, name)) + .filter((symbol) => exactSymbolMatchesScope(symbol, projectRoot, options)) + .sort((left, right) => left.filePath.localeCompare(right.filePath) || left.startLine - right.startLine) + .slice(0, limit) + .map((symbol) => { + let content = "[File not accessible]"; + try { + content = readFileSync(symbol.filePath, "utf-8") + .split("\n") + .slice(symbol.startLine - 1, symbol.endLine) + .join("\n"); + } catch { + // Preserve the normal search contract when a stale indexed file is unavailable. + } + return { + filePath: symbol.filePath, + startLine: symbol.startLine, + endLine: symbol.endLine, + content, + score: 1, + chunkType: symbol.kind, + name: symbol.name, + }; + }); +} + function toCandidate(symbol: SymbolData, projectRoot: string): CallGraphSymbolCandidate { return { filePath: displayCallGraphPath(symbol.filePath, projectRoot), @@ -332,11 +391,21 @@ export async function implementationLookup( limit?: number; fileType?: string; directory?: string; + exactSymbol?: boolean; trace?: (trace: SearchTrace) => void; } = {}, ): Promise { await ensureAutoIndexReadyForRetrieval(projectRoot, host); - const indexer = getIndexerForProject(projectRoot, host); + const root = getProjectRoot(projectRoot, host); + const indexer = getIndexerForProject(root, host); + if (options.exactSymbol) { + return exactSymbolSearchResults( + await indexer.getCallGraphSymbols(), + root, + query, + options, + ); + } return indexer.search(query, options.limit, { fileType: options.fileType, directory: options.directory, diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 834edc40..349ff326 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -830,11 +830,7 @@ describe("MCP server tools and prompts", () => { expect(content[0].text).toContain('function "validateToken"'); expect(content[0].text).not.toContain("return token.length"); const indexer = indexerMockState.instances.at(-1); - expect(indexer?.search).toHaveBeenCalledWith( - "validateToken", - 100, - expect.objectContaining({ definitionIntent: true }), - ); + expect(indexer?.getCallGraphSymbols).toHaveBeenCalled(); }); it("should return codebase_context diagnostics as MCP structured content on request", async () => { @@ -1353,6 +1349,51 @@ describe("MCP server tools and prompts", () => { expect(content[0].text).toContain("validateToken"); }); + it("reports an explicit overloaded context symbol as ambiguous", async () => { + const indexer = indexerMockState.instances[0]; + indexer.getCallGraphSymbols.mockResolvedValueOnce([ + graphSymbol("left", "duplicateSymbol", "/tmp/test-project/src/left.ts"), + graphSymbol("right", "duplicateSymbol", "/tmp/test-project/src/right.ts"), + ]); + + const result = await client.callTool({ + name: "codebase_context", + arguments: { query: "find duplicateSymbol", symbol: "duplicateSymbol", diagnostic: true }, + }); + expect((result as { structuredContent?: { resolution?: string; matchKind?: string } }).structuredContent) + .toMatchObject({ resolution: "ambiguous", matchKind: "exact_symbol" }); + }); + + it("keeps generated and vendor definitions as explicit ambiguous candidates", async () => { + const indexer = indexerMockState.instances[0]; + indexer.getCallGraphSymbols.mockResolvedValueOnce([ + graphSymbol("source", "buildArtifact", "/tmp/test-project/src/artifact.ts", 10), + graphSymbol("generated", "buildArtifact", "/tmp/test-project/generated/artifact.ts", 20), + graphSymbol("vendor", "buildArtifact", "/tmp/test-project/vendor/artifact.ts", 30), + ]); + + const result = await client.callTool({ name: "implementation_lookup", arguments: { query: "buildArtifact" } }); + const text = (result.content as Array<{ text?: string }>)[0]?.text ?? ""; + expect(text).toContain("src/artifact.ts:10-14"); + expect(text).toContain("generated/artifact.ts:20-24"); + expect(text).toContain("vendor/artifact.ts:30-34"); + expect((result as { structuredContent?: { resolution?: string; matchKind?: string } }).structuredContent) + .toMatchObject({ resolution: "ambiguous", matchKind: "exact_symbol" }); + }); + + it("does not resolve a stale catalog symbol absent from the active symbol set", async () => { + const indexer = indexerMockState.instances[0]; + indexer.getCallGraphSymbols.mockResolvedValueOnce([ + graphSymbol("active", "activeSymbol", "/tmp/test-project/src/active.ts"), + ]); + + const result = await client.callTool({ name: "implementation_lookup", arguments: { query: "staleFeatureSymbol" } }); + const text = (result.content as Array<{ text?: string }>)[0]?.text ?? ""; + expect(text).toContain('No definition found for "staleFeatureSymbol"'); + expect((result as { structuredContent?: { resolution?: string; matchKind?: string } }).structuredContent) + .toMatchObject({ resolution: "not_found", matchKind: "exact_symbol" }); + }); + it("should execute call_graph callers with null optional fields", async () => { const result = await client.callTool({ name: "call_graph", diff --git a/tests/pi-conformance.test.ts b/tests/pi-conformance.test.ts index 33705543..a22f64ae 100644 --- a/tests/pi-conformance.test.ts +++ b/tests/pi-conformance.test.ts @@ -669,6 +669,8 @@ describe("Pi adapter conformance", () => { limit: 100, fileType: undefined, directory: undefined, + exactSymbol: true, + trace: undefined, }); expect(result?.content[0]?.text).toContain("src/auth.ts:12-30"); expect(result?.content[0]?.text).not.toContain("function validateToken() {}"); @@ -708,6 +710,8 @@ describe("Pi adapter conformance", () => { limit: 100, fileType: undefined, directory: undefined, + exactSymbol: false, + trace: undefined, }); expect(result?.content[0]?.text).toContain("\"getStatus\""); }); @@ -740,6 +744,8 @@ describe("Pi adapter conformance", () => { limit: 100, fileType: undefined, directory: undefined, + exactSymbol: false, + trace: undefined, }); expect(operationMocks.searchCodebase).toHaveBeenCalledWith( "/repo", diff --git a/tests/tools-context.test.ts b/tests/tools-context.test.ts index 11bdd52a..80a3c452 100644 --- a/tests/tools-context.test.ts +++ b/tests/tools-context.test.ts @@ -363,6 +363,8 @@ describe("native OpenCode codebase_context", () => { limit: 100, fileType: undefined, directory: undefined, + exactSymbol: true, + trace: undefined, }); expect(result).toContain("src/auth.ts:12-30"); expect(result).not.toContain("fullSource"); @@ -532,6 +534,8 @@ describe("native OpenCode codebase_context", () => { limit: 100, fileType: undefined, directory: undefined, + exactSymbol: false, + trace: undefined, }); expect(operationMocks.searchCodebase).toHaveBeenCalledWith("/repo", "opencode", "where is missingHandler defined", { limit: 100, @@ -719,10 +723,10 @@ describe("native OpenCode codebase_context", () => { expect(countContextTokens(result.text)).toBeLessThanOrEqual(128); expect(result.details?.tokenEstimate).toBe(countContextTokens(result.text)); expect(result.details?.route).toBe("definition"); - expect(result.details?.recovery?.attempts).toHaveLength(2); + expect(result.details?.recovery?.attempts).toHaveLength(1); }); - it("keeps explicit symbols definition-only with normalized scoped then unscoped lookup", async () => { + it("keeps explicit symbols definition-only within their normalized scope", async () => { const lookup = vi.fn() .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ @@ -745,19 +749,18 @@ describe("native OpenCode codebase_context", () => { directory: " ./src\\tools/ ", }, { lookup, search }); + expect(lookup).toHaveBeenCalledOnce(); expect(lookup).toHaveBeenNthCalledWith(1, "resolveSearchContext", 100, { fileType: "ts", directory: "src/tools", - }, undefined); - expect(lookup).toHaveBeenNthCalledWith(2, "resolveSearchContext", 100, {}, undefined); + }, true, undefined); expect(search).not.toHaveBeenCalled(); expect(result.details).toMatchObject({ route: "definition", routedQuery: "resolveSearchContext", - truncated: false, - selectedCount: 1, + truncated: expect.any(Boolean), }); - expect(result.text).toContain("Recovery: directory filter removed; file-type filter removed."); + expect(result.text).toContain("No definition found."); expect(result.text).not.toContain("resolveSearchContext "); expect(result.details?.tokenEstimate).toBe(countContextTokens(result.text)); });