Skip to content

Commit 45edcb1

Browse files
authored
Merge pull request #41 from vndv/fix/remove-deprecated-tools-and-normalize-files-path
fix: remove deprecated CodeGraph tools and normalize codegraph_files …
2 parents e80f230 + f8a0a7e commit 45edcb1

5 files changed

Lines changed: 188 additions & 41 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vndv/pi-codegraph": patch
3+
---
4+
5+
Normalize `codegraph_files` `path` filters to root-relative POSIX prefixes and append a deterministic hint when no files match, preventing agents from concluding a directory does not exist. Fixes #40.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vndv/pi-codegraph": patch
3+
---
4+
5+
Remove `codegraph_context` and `codegraph_trace` tools, which upstream CodeGraph dropped in v0.9.9+, and update guidance to use `codegraph_explore` instead. Fixes #37.

README.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
Ask pi structural questions about your codebase without falling back to slow grep/read loops.
1010

11-
An extension for [pi](https://pi.dev) that gives the agent access to [CodeGraph](https://github.com/colbymchenry/codegraph) tools. CodeGraph indexes your project with tree-sitter, then pi can query symbols, callers, callees, dependency impact, files, and call paths through native extension tools.
11+
An extension for [pi](https://pi.dev) that gives the agent access to [CodeGraph](https://github.com/colbymchenry/codegraph) tools. CodeGraph indexes your project with tree-sitter, then pi can query symbols, callers, callees, dependency impact, files, and relationships through native extension tools.
1212

1313
---
1414

@@ -36,13 +36,11 @@ Extension tools only. There is no MCP setup for pi users to maintain.
3636

3737
| Tool | Description |
3838
| --- | --- |
39-
| `codegraph_context` | Broad task context: entry points, related symbols, callers, callees, and key code |
4039
| `codegraph_search` | Symbol search by name |
4140
| `codegraph_node` | One symbol's signature, location, source, callers, and callees |
4241
| `codegraph_files` | Indexed file tree |
4342
| `codegraph_callers` | Functions or methods that call a symbol |
4443
| `codegraph_callees` | Functions or methods called by a symbol |
45-
| `codegraph_trace` | Static call path from one symbol to another |
4644
| `codegraph_impact` | Impact radius for changing a symbol |
4745
| `codegraph_explore` | Source for several related symbols grouped by file |
4846
| `codegraph_status` | Index health and pending sync status |
@@ -126,12 +124,10 @@ Use CodeGraph. Show files under internal/services and important symbols.
126124

127125
### 3. Prefer the right tool
128126

129-
Use `codegraph_context` for broad "how does this work?" questions.
127+
Use `codegraph_explore` for broad "how does this work?" or "how does X reach Y?" questions.
130128

131129
Use `codegraph_node` when you already know the symbol name.
132130

133-
Use `codegraph_trace` for "how does X reach Y?" flow questions.
134-
135131
Use `codegraph_search` for declarations and symbols, not arbitrary text or constant values.
136132

137133
---

__tests__/codegraph.test.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { EventEmitter } from "node:events";
33
import { PassThrough } from "node:stream";
4+
import os from "node:os";
5+
import path from "node:path";
46

57
vi.mock("node:child_process", () => ({
68
spawn: vi.fn(() => createMockProcess()),
79
}));
810

9-
function createMockProcess() {
11+
function createMockProcess(returnResult?: { content?: any[]; isError?: boolean }) {
1012
const child = new EventEmitter() as any;
1113
child.stdin = new PassThrough();
1214
child.stdout = new PassThrough();
@@ -24,10 +26,11 @@ function createMockProcess() {
2426
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\n");
2527
}
2628
if (msg.method === "tools/call") {
29+
const result = returnResult ?? { content: [{ type: "text", text: `called ${msg.params.name}` }] };
2730
child.stdout.write(JSON.stringify({
2831
jsonrpc: "2.0",
2932
id: msg.id,
30-
result: { content: [{ type: "text", text: `called ${msg.params.name}` }] },
33+
result,
3134
}) + "\n");
3235
}
3336
}
@@ -40,9 +43,9 @@ describe("pi-codegraph extension", () => {
4043
it("exports all CodeGraph tool names", async () => {
4144
const mod = await import("../extensions/codegraph.js");
4245

43-
expect(mod.codegraphToolNames).toContain("codegraph_context");
44-
expect(mod.codegraphToolNames).toContain("codegraph_trace");
45-
expect(mod.codegraphToolNames).toHaveLength(10);
46+
expect(mod.codegraphToolNames).not.toContain("codegraph_context");
47+
expect(mod.codegraphToolNames).not.toContain("codegraph_trace");
48+
expect(mod.codegraphToolNames).toHaveLength(8);
4649
});
4750

4851
it("proxies tool calls through CodeGraph MCP", async () => {
@@ -74,4 +77,108 @@ describe("pi-codegraph extension", () => {
7477
expect(diagnostic).not.toContain("123456");
7578
expect(diagnostic).not.toContain("hidden");
7679
});
80+
81+
describe("normalizeFilesPath", () => {
82+
it("returns undefined for empty/undefined input", async () => {
83+
const { normalizeFilesPath } = await import("../extensions/codegraph.js");
84+
85+
expect(normalizeFilesPath()).toBeUndefined();
86+
expect(normalizeFilesPath("")).toBeUndefined();
87+
});
88+
89+
it("expands ~ to the home directory", async () => {
90+
const { normalizeFilesPath } = await import("../extensions/codegraph.js");
91+
vi.spyOn(os, "homedir").mockReturnValue("/home/user");
92+
93+
expect(normalizeFilesPath("~/project/src/components", "/home/user/project")).toBe("src/components");
94+
expect(normalizeFilesPath("~/project", "/home/user/project")).toBeUndefined();
95+
});
96+
97+
it("converts an absolute path inside the project to a repo-relative POSIX prefix", async () => {
98+
const { normalizeFilesPath } = await import("../extensions/codegraph.js");
99+
100+
expect(normalizeFilesPath(path.join(process.cwd(), "src/components"), process.cwd())).toBe("src/components");
101+
});
102+
103+
it("drops the filter when the path equals the project root", async () => {
104+
const { normalizeFilesPath } = await import("../extensions/codegraph.js");
105+
106+
expect(normalizeFilesPath(process.cwd(), process.cwd())).toBeUndefined();
107+
});
108+
109+
it("leaves relative inputs and out-of-project absolute paths untouched", async () => {
110+
const { normalizeFilesPath } = await import("../extensions/codegraph.js");
111+
112+
expect(normalizeFilesPath("components", "/project")).toBe("components");
113+
expect(normalizeFilesPath("/outside/project", "/project")).toBe("/outside/project");
114+
});
115+
});
116+
117+
describe("annotateFilesResult", () => {
118+
it("appends a hint to the bare empty marker when a path filter was supplied", async () => {
119+
const { annotateFilesResult } = await import("../extensions/codegraph.js");
120+
121+
const result = annotateFilesResult("No files found matching the criteria.", "components");
122+
expect(result).toContain("Hint:");
123+
expect(result).toContain("root-relative POSIX prefix");
124+
expect(result).toContain('"components"');
125+
});
126+
127+
it("returns non-empty text unchanged", async () => {
128+
const { annotateFilesResult } = await import("../extensions/codegraph.js");
129+
130+
expect(annotateFilesResult("src/Button.ts", "components")).toBe("src/Button.ts");
131+
});
132+
133+
it("returns the empty marker unchanged when no path filter was supplied", async () => {
134+
const { annotateFilesResult } = await import("../extensions/codegraph.js");
135+
136+
expect(annotateFilesResult("No files found matching the criteria.")).toBe(
137+
"No files found matching the criteria.",
138+
);
139+
});
140+
});
141+
142+
it("normalizes codegraph_files path before forwarding to the MCP server", async () => {
143+
const { spawn } = await import("node:child_process");
144+
const { callCodeGraphTool } = await import("../extensions/codegraph.js");
145+
let capturedArgs: Record<string, unknown> | undefined;
146+
147+
vi.mocked(spawn).mockImplementationOnce(() => {
148+
const child = new EventEmitter() as any;
149+
child.stdin = new PassThrough();
150+
child.stdout = new PassThrough();
151+
child.stderr = new PassThrough();
152+
child.killed = false;
153+
child.kill = vi.fn(() => { child.killed = true; });
154+
155+
child.stdin.on("data", (chunk: Buffer) => {
156+
const lines = chunk.toString("utf-8").trim().split("\n").filter(Boolean);
157+
for (const line of lines) {
158+
const msg = JSON.parse(line);
159+
if (msg.method === "initialize") {
160+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\n");
161+
}
162+
if (msg.method === "tools/call") {
163+
capturedArgs = msg.params.arguments;
164+
child.stdout.write(JSON.stringify({
165+
jsonrpc: "2.0",
166+
id: msg.id,
167+
result: { content: [{ type: "text", text: "src/Button.ts" }] },
168+
}) + "\n");
169+
}
170+
}
171+
});
172+
173+
return child;
174+
});
175+
176+
await callCodeGraphTool("codegraph_files", {
177+
projectPath: process.cwd(),
178+
path: path.join(process.cwd(), "src"),
179+
});
180+
181+
expect(capturedArgs).toBeDefined();
182+
expect(capturedArgs!.path).toBe("src");
183+
});
77184
});

extensions/codegraph.ts

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from "node:child_process";
22
import { stat } from "node:fs/promises";
3+
import os from "node:os";
34
import path from "node:path";
45
import { pathToFileURL } from "node:url";
56
import type { ChildProcessWithoutNullStreams } from "node:child_process";
@@ -34,17 +35,6 @@ const ToolDefinitions = [
3435
projectPath: OptionalProjectPath,
3536
}),
3637
},
37-
{
38-
name: "codegraph_context",
39-
label: "CodeGraph Context",
40-
description: "Primary tool for architecture, feature, bug-context, or how-does-X-work questions.",
41-
parameters: Type.Object({
42-
task: Type.String({ description: "Task, question, or code area to understand." }),
43-
maxNodes: Type.Optional(Type.Number({ default: 20 })),
44-
includeCode: Type.Optional(Type.Boolean({ default: true })),
45-
projectPath: OptionalProjectPath,
46-
}),
47-
},
4838
{
4939
name: "codegraph_callers",
5040
label: "CodeGraph Callers",
@@ -120,16 +110,6 @@ const ToolDefinitions = [
120110
projectPath: OptionalProjectPath,
121111
}),
122112
},
123-
{
124-
name: "codegraph_trace",
125-
label: "CodeGraph Trace",
126-
description: "Trace the call path between two symbols.",
127-
parameters: Type.Object({
128-
from: Type.String(),
129-
to: Type.String(),
130-
projectPath: OptionalProjectPath,
131-
}),
132-
},
133113
] as const;
134114

135115
type ToolName = (typeof ToolDefinitions)[number]["name"];
@@ -180,6 +160,34 @@ export async function resolveProjectCwd(projectPath: string | undefined): Promis
180160
return cwd;
181161
}
182162

163+
export function normalizeFilesPath(inputPath?: string, projectCwd?: string): string | undefined {
164+
if (typeof inputPath !== "string" || inputPath.trim() === "") return undefined;
165+
166+
const trimmed = inputPath.trim();
167+
let expanded = trimmed;
168+
if (expanded === "~" || expanded.startsWith("~/") || expanded.startsWith("~\\")) {
169+
expanded = path.join(os.homedir(), expanded.slice(1));
170+
}
171+
172+
if (projectCwd && path.isAbsolute(expanded)) {
173+
const relative = path.relative(projectCwd, expanded);
174+
if (relative === "") return undefined;
175+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
176+
return relative.split(path.sep).join("/");
177+
}
178+
}
179+
180+
return trimmed.split(path.sep).join("/");
181+
}
182+
183+
const EmptyFilesMarker = "No files found matching the criteria.";
184+
185+
export function annotateFilesResult(resultText: string, originalPath?: string): string {
186+
if (!originalPath || !resultText.includes(EmptyFilesMarker)) return resultText;
187+
188+
return `${resultText}\n\nHint: codegraph_files interprets "path" as a root-relative POSIX prefix (e.g. "src/components"). The filter "${originalPath}" did not match any indexed path.`;
189+
}
190+
183191
export function sanitizeDiagnostic(value: string): string {
184192
const withoutAnsi = value.replace(/\u001b\[[0-9;]*m/g, "");
185193
const redacted = withoutAnsi
@@ -332,17 +340,42 @@ async function initializeJsonRpcSession(
332340
sendNotification("initialized", {});
333341
}
334342

343+
async function prepareToolArguments(
344+
name: ToolName,
345+
params: ToolParams,
346+
): Promise<{ args: ToolParams; originalFilesPath?: string }> {
347+
if (name !== "codegraph_files") return { args: params };
348+
349+
const projectPath = typeof params.projectPath === "string" ? params.projectPath : undefined;
350+
const projectCwd = await resolveProjectCwd(projectPath);
351+
const originalFilesPath = typeof params.path === "string" ? params.path : undefined;
352+
const normalizedPath = normalizeFilesPath(originalFilesPath, projectCwd);
353+
354+
const args: ToolParams = { ...params };
355+
if (normalizedPath === undefined) {
356+
delete args.path;
357+
} else {
358+
args.path = normalizedPath;
359+
}
360+
361+
return { args, originalFilesPath };
362+
}
363+
335364
export async function callCodeGraphTool(
336365
name: ToolName,
337366
params: ToolParams,
338367
signal?: AbortSignal,
339368
): Promise<string> {
340-
const projectPath = typeof params.projectPath === "string" ? params.projectPath : undefined;
341-
const result = await withCodeGraphMcp(projectPath, signal, (request) =>
342-
request("tools/call", {
343-
name,
344-
arguments: params || {},
345-
})
369+
const { args, originalFilesPath } = await prepareToolArguments(name, params);
370+
371+
const result = await withCodeGraphMcp(
372+
typeof args.projectPath === "string" ? args.projectPath : undefined,
373+
signal,
374+
(request) =>
375+
request("tools/call", {
376+
name,
377+
arguments: args,
378+
}),
346379
);
347380

348381
const text = (result?.content || [])
@@ -351,16 +384,17 @@ export async function callCodeGraphTool(
351384
.join("\n");
352385

353386
if (result?.isError) throw new Error(text || "CodeGraph tool failed.");
354-
return text || JSON.stringify(result);
387+
const finalText = text || JSON.stringify(result);
388+
return name === "codegraph_files" ? annotateFilesResult(finalText, originalFilesPath) : finalText;
355389
}
356390

357391
export default function codegraphExtension(pi: ExtensionAPI): void {
358392
pi.on("before_agent_start", async (event) => {
359393
const guidance = [
360394
"CodeGraph tools are available as codegraph_* Pi tools.",
361395
"For architecture, flow, where-is-symbol, impact, and codebase navigation questions, use CodeGraph tools directly before grep/read.",
362-
"Use codegraph_context first for broad questions, codegraph_search for symbol-name lookup, codegraph_files for project structure, codegraph_node for a known symbol, and codegraph_trace for call paths.",
363-
"If codegraph_search returns no exact result, try codegraph_context or codegraph_files/codegraph_explore before falling back to grep/read; CodeGraph symbol search may miss literal constants or generated names that still exist in source text.",
396+
"Use codegraph_explore first for broad questions, codegraph_search for symbol-name lookup, codegraph_files for project structure, codegraph_node for a known symbol, and codegraph_callers for impact/flow analysis.",
397+
"If codegraph_search returns no exact result, try codegraph_explore or codegraph_files/codegraph_node before falling back to grep/read; CodeGraph symbol search may miss literal constants or generated names that still exist in source text.",
364398
"Only use grep/read after CodeGraph is insufficient or when the user asks for literal text matching.",
365399
].join("\n");
366400

0 commit comments

Comments
 (0)