Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/adapters/mcp/register-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
);

Expand All @@ -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 };
},
);

Expand All @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/pi/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getIndexerForProject,
getCodeCommunities,
implementationLookup,
isExactSymbolQuery,
listKnowledgeBases,
removeKnowledgeBase,
runIndexCodebase,
Expand Down Expand Up @@ -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);
},
});
Expand Down
38 changes: 6 additions & 32 deletions src/tools/context-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,6 +116,7 @@ interface SearchContextOperations {
symbol: string,
limit: number,
scope: SearchScope,
exactSymbol: boolean,
trace?: (trace: SearchTrace) => void,
): Promise<SearchResult[]>;
search(
Expand Down Expand Up @@ -394,6 +397,7 @@ export async function resolveSearchContext(

const tryDefinitionLookup = async (
symbol: string,
exactSymbol: boolean,
scope: SearchScope = scopedScope,
relaxedFieldsForAttempt: Array<"directory" | "fileType"> = [],
): Promise<SearchResult[]> => {
Expand All @@ -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),
);
};

Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/tools/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 16 additions & 3 deletions src/tools/execute-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getIndexMetrics,
getIndexStatus,
implementationLookup,
isExactSymbolQuery,
runIndexCodebase,
runIndexHealthCheck,
} from "./operations.js";
Expand Down Expand Up @@ -120,16 +121,28 @@ 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(
projectRoot: string | undefined,
host: HostMode,
args: SharedCallGraphArgs,
): Promise<ExecutionResult> {
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(
Expand All @@ -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(
Expand Down
73 changes: 71 additions & 2 deletions src/tools/operations.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -332,11 +391,21 @@ export async function implementationLookup(
limit?: number;
fileType?: string;
directory?: string;
exactSymbol?: boolean;
trace?: (trace: SearchTrace) => void;
} = {},
): Promise<SearchResult[]> {
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,
Expand Down
51 changes: 46 additions & 5 deletions tests/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions tests/pi-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}");
Expand Down Expand Up @@ -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\"");
});
Expand Down Expand Up @@ -740,6 +744,8 @@ describe("Pi adapter conformance", () => {
limit: 100,
fileType: undefined,
directory: undefined,
exactSymbol: false,
trace: undefined,
});
expect(operationMocks.searchCodebase).toHaveBeenCalledWith(
"/repo",
Expand Down
Loading