From dc4fe92e279b2815e7023a4ea8d9e36ce4189123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Tue, 20 Oct 2026 02:02:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(codegraph):=20codegraph=5Fexplore=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=87=AA=E6=9C=89=20stdio=20JSON-RPC=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=9B=B4=E8=BF=9E=20codegraph=20ser?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次性 `codegraph explore` CLI 不会同步索引,新增符号在手动 sync 前 无法被搜到。改为按索引根惰性启动常驻 `codegraph serve --mcp` 子进程, 由组合包自有的逐行 JSON-RPC 客户端(extensions/codegraph-client.ts, 不依赖 MCP adapter/SDK)收发,serve 内置文件监听自动同步索引,删除 mtime 结果缓存。子进程闲置 10 分钟或会话结束关闭,子进程死亡时透明 重试一次;用户拒绝初始化在会话内记住;用户中止调用不关闭子进程; 所有失败以文本打回引导改用 find/grep/read/LSP。新增模拟服务与真实 CLI 集成测试。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 8 +- README.md | 24 +- extensions/codegraph-client.ts | 378 +++++++++++++++++ extensions/codegraph.ts | Bin 8473 -> 10734 bytes tests/codegraph.test.ts | 564 ++++++++++++++++++++++---- tests/fixtures/fake-codegraph-mcp.mjs | 120 ++++++ 6 files changed, 990 insertions(+), 104 deletions(-) create mode 100644 extensions/codegraph-client.ts create mode 100644 tests/fixtures/fake-codegraph-mcp.mjs diff --git a/AGENTS.md b/AGENTS.md index 8def26d..9519caf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ - `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。 - `pi-web-search/`:完全自维护的统一在线搜索扩展。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名;`web_search` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择并经统一参数翻译调用,`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存,路由失败或被选厂商报错按可用性顺序降级;`web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily。`clients/` 为纯 `fetch` 直连:Tavily 走 `api.tavily.com`(必须有 key),Keenable 复刻上游 client 保留 keyless `/public` 端点与 SSRF 防护,Exa 直接对 `mcp.exa.ai/mcp` 发 JSON-RPC(缓存 `mcp-session-id`,404 自动重建会话,key 只走 `x-api-key` 头),不使用 `pi-mcp-adapter` 或任何上游扩展包。 - `configure.sh search` 与 `scripts/search-auth.mjs`:以交互或参数方式把 `tavily` / `exa` / `keenable` 三条 `api_key` 凭据写入 Pi 的 `auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`);加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 导入。`pi-web-search/auth.ts` 每次调用读取并复刻上游解析语义(`!command` 执行、`$VAR`/`${VAR}` 插值、credential `env` 优先于进程 env、`$$`/`$!` 转义);显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。 -- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,以 `execFile` argv 形式调用机器级 `codegraph` CLI(每次调用为短命进程,无常驻 server/daemon);未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝或失败则把结果打回让模型改用 `find`/`grep`/`read`/LSP;结果按 `codegraph.db` mtime 做有界缓存,索引一变即失效。 +- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp -p ` 子进程,经 `extensions/codegraph-client.ts` 的组合包自有 stdio 逐行 JSON-RPC 客户端直连(不依赖 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭,调用中子进程死亡则换新客户端透明重试一次;未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝记入会话级 declined 不再重复询问;所有失败(未授权、init 失败、spawn ENOENT、调用失败)都以文本打回让模型改用 `find`/`grep`/`read`/LSP,绝不抛出。`codegraph serve` 可能启动的上游 daemon(`.codegraph/daemon.sock`)在子进程退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。 - `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 @@ -87,9 +87,9 @@ - Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。 - `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。 - `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。 -- CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。 -- `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每次调用通过 `execFile`/`pi.exec` 以 argv 形式运行 `codegraph explore`(带超时、不经 shell),结果按 `.codegraph/codegraph.db` mtime 缓存;CLI 缺失、init 被拒绝或执行失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。 -- 组合包不加载任何 MCP adapter;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。 +- CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录,拒绝结果在本会话内记住不再询问。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。运行时每个索引根惰性常驻一个 `codegraph serve --mcp` 子进程(闲置 10 分钟关闭、会话结束统一关闭),其启动的上游 daemon 退出后可多驻留 ≤5 分钟属上游行为。 +- `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每个索引根由 `codegraph serve --mcp -p ` 子进程服务,经组合包自有 stdio JSON-RPC 客户端收发(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 的文件监听自动同步索引,无结果缓存。子进程死亡导致调用失败时换新客户端透明重试一次;CLI 缺失、init 被拒绝或服务调用失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。 +- 组合包不加载任何 MCP adapter 或 MCP SDK;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;CodeGraph 由 `extensions/codegraph-client.ts` 以 stdio 逐行 JSON-RPC 直连 `codegraph serve --mcp`。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。 - 在线搜索对模型只暴露 `web_search` / `web_fetch` 两个统一工具名,不得重新引入按厂商命名的工具;`web_search` 必须经 `router.ts` 的主模型分类选择 provider,`web_fetch` 固定优先 Exa。Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 auth.json 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。 - 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`configure.sh search` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。 - `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。 diff --git a/README.md b/README.md index 7c5c266..89f83aa 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。 - `context-mode@1.0.169`:通过 Pi adapter 提供 `ctx_*` 工具、上下文隔离和会话连续性。 - 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。 -- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,每次调用以短命 `codegraph explore` CLI 进程完成,无常驻 server/daemon;未初始化项目经确认后才 `codegraph init`,结果按索引库 mtime 缓存。 +- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。 - `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。 - `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。 - `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。 @@ -120,7 +120,7 @@ Tool Search 常驻,保证 policy 指示与模型可见工具一致。 `config/pi-hermes-memory.json`。 CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认, -确认后才运行 `codegraph init` 并自动重试本次探索;不需要手动执行任何初始化命令。 +确认后才运行 `codegraph init` 并自动重试本次探索,拒绝一次后本会话不再询问;不需要手动执行任何初始化命令。 默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。 @@ -273,17 +273,21 @@ key 值支持 auth.json 原生解析语义:`$VAR` / `${VAR}` 环境变量插 ### CodeGraph 是原生工具 -`extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP:每次调用以 argv 形式 -直接执行机器级 `codegraph explore`(上游保证其输出与 `codegraph_explore` MCP 工具一致), -进程随调用结束,不维护常驻 server 或 daemon。`projectPath` 缺省为会话 cwd,沿目录向上寻找 -最近的 `.codegraph/codegraph.db`;`maxFiles` 透传 `--max-files`。 +`extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP adapter/SDK:每个索引根 +惰性启动一个常驻 `codegraph serve --mcp -p ` 子进程,由组合包自有的 +`extensions/codegraph-client.ts` 以逐行 JSON-RPC over stdio 直连(与 `pi-web-search` 直连 Exa +同一思路),`initialize` → `tools/list` → 每次调用一次 `tools/call`。`projectPath` 缺省为会话 cwd, +沿目录向上寻找最近的 `.codegraph/codegraph.db` 决定索引根;调用参数只透传上游 +`inputSchema` 声明的属性。 -结果按 `(索引根, query, maxFiles)` 缓存在扩展内,并以索引库 mtime 作为有效性判据:任何 -重建或同步都会更新 mtime 使旧缓存自动失效,模型对同一区域反复探索时重复调用立即返回。 +与一次性 `codegraph explore` CLI 不同,serve 进程内置文件监听会自动同步索引,新符号数秒内 +可搜到,因此扩展不再做结果缓存。子进程闲置 10 分钟或会话结束时关闭;调用因子进程死亡失败时 +换新客户端透明重试一次。serve 可能拉起 CodeGraph 自己的 daemon(`.codegraph/daemon.sock`), +子进程退出后最多再驻留 5 分钟——这是上游行为,组合包不管理它。 若目标目录没有索引,工具先弹出确认;用户同意后运行 `codegraph init` 并自动重试本次调用, -拒绝或 CLI 不可用/执行失败时把结果打回并提示改用 `find`/`grep`/`read`/LSP,不影响其他扩展。 -索引状态可在项目内自行检查: +拒绝一次后本会话不再询问;CLI 不可用或服务调用失败时把结果打回并提示改用 +`find`/`grep`/`read`/LSP,不影响其他扩展。索引状态可在项目内自行检查: ```bash codegraph status diff --git a/extensions/codegraph-client.ts b/extensions/codegraph-client.ts new file mode 100644 index 0000000..a5ec839 --- /dev/null +++ b/extensions/codegraph-client.ts @@ -0,0 +1,378 @@ +/** + * Minimal stdio JSON-RPC 2.0 client for `codegraph serve --mcp -p `. + * + * Same approach as pi-web-search/clients/exa.ts (plain JSON-RPC, no MCP + * adapter or SDK): `codegraph serve --mcp` speaks one JSON object per line on + * stdio. Unlike the one-shot `codegraph explore` CLI, the serve process runs a + * file watcher that keeps the index synced, so newly written symbols become + * searchable without a manual `codegraph sync`. + * + * One client exists per index root; `extensions/codegraph.ts` owns the map, + * idle closing, and the one-shot respawn retry. + */ + +import { + spawn as nodeSpawn, + type ChildProcess, + type SpawnOptions, +} from "node:child_process"; + +const PROTOCOL_VERSION = "2025-06-18"; +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; +const STDERR_TAIL_LINES = 20; +const CLOSE_GRACE_MS = 1_000; + +export type CodegraphSpawn = ( + command: string, + args: string[], + options: SpawnOptions, +) => ChildProcess; + +export interface CodegraphRpcClientOptions { + /** Index root — passed to `codegraph serve --mcp -p ` and used as cwd. */ + root: string; + /** Test seam: replace the child_process spawn. */ + spawn?: CodegraphSpawn; + /** Default "codegraph". */ + command?: string; + /** Default ["serve", "--mcp", "-p", root]. */ + args?: string[]; + /** Per-request timeout; default 120 s. */ + requestTimeoutMs?: number; +} + +interface PendingRequest { + resolve: (result: unknown) => void; + reject: (error: Error) => void; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function contentText(result: Record): string { + const content = Array.isArray(result.content) + ? (result.content as Record[]) + : []; + return content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text as string) + .join("\n") + .trim(); +} + +export class CodegraphRpcClient { + private readonly spawnFn: CodegraphSpawn; + private readonly command: string; + private readonly args: string[]; + private readonly timeoutMs: number; + + private child: ChildProcess | undefined; + private stdoutBuffer = ""; + private stderrLines: string[] = []; + private pending = new Map(); + private nextId = 1; + private startPromise: Promise | undefined; + /** null = upstream tool declares no inputSchema.properties → forward all args. */ + private schemaProps: ReadonlySet | null = null; + private deadFlag = false; + private closedFlag = false; + private deathFinalized = false; + private exitDetail = ""; + private deathReason = "codegraph serve 子进程已退出"; + + private readonly options: CodegraphRpcClientOptions; + + constructor(options: CodegraphRpcClientOptions) { + this.options = options; + this.spawnFn = options.spawn ?? nodeSpawn; + this.command = options.command ?? "codegraph"; + this.args = + options.args ?? ["serve", "--mcp", "-p", options.root]; + this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + } + + /** True once the child has exited or errored — the handler respawns instead. */ + get dead(): boolean { + return this.deadFlag; + } + + /** Property names of the upstream codegraph_explore inputSchema. */ + schemaPropertyNames(): readonly string[] { + return this.schemaProps === null ? [] : [...this.schemaProps]; + } + + /** Spawn + initialize + tools/list. Memoized: concurrent callers share it. */ + start(): Promise { + if (!this.startPromise) { + this.startPromise = this.doStart(); + // A dead child during startup rejects this promise; attach a no-op + // branch so it never surfaces as an unhandled rejection when a + // racing close() kills the child with nobody awaiting start(). + this.startPromise.catch(() => {}); + } + return this.startPromise; + } + + private async doStart(): Promise { + let child: ChildProcess; + try { + child = this.spawnFn(this.command, this.args, { + cwd: this.options.root, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + this.markDead( + `无法启动 codegraph serve:${error instanceof Error ? error.message : String(error)}`, + ); + throw this.deathError(); + } + this.child = child; + this.attach(child); + await this.request("initialize", { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "my-pi-codegraph", version: "1.0.0" }, + }); + this.notify("notifications/initialized"); + const list = (await this.request("tools/list", {})) as Record< + string, + unknown + >; + const tools = Array.isArray(list.tools) ? list.tools : []; + const tool = tools.find( + (entry) => isRecord(entry) && entry.name === "codegraph_explore", + ); + if (!tool) { + throw new Error("codegraph serve 未提供 codegraph_explore 工具"); + } + const properties = + isRecord(tool) && isRecord(tool.inputSchema) + ? tool.inputSchema.properties + : undefined; + this.schemaProps = isRecord(properties) + ? new Set(Object.keys(properties)) + : null; + } + + /** + * `tools/call codegraph_explore`. Forwards only the argument properties the + * upstream inputSchema declares (e.g. `projectPath` is forwarded only when + * upstream lists it). Returns the raw result object; the handler passes + * `result.content` through verbatim. + */ + async callExplore( + args: Record, + signal?: AbortSignal, + ): Promise> { + await this.start(); + const forwarded: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (this.schemaProps === null || this.schemaProps.has(key)) { + forwarded[key] = value; + } + } + const result = await this.request( + "tools/call", + { name: "codegraph_explore", arguments: forwarded }, + signal, + ); + if (isRecord(result) && result.isError) { + throw new Error( + `codegraph_explore 返回错误:${contentText(result) || "unknown"}`, + ); + } + return isRecord(result) ? result : {}; + } + + /** End stdin, then SIGTERM after a 1 s grace. Idempotent. */ + async close(): Promise { + if (this.closedFlag) return; + this.closedFlag = true; + const child = this.child; + this.failAll(new Error("codegraph serve 客户端已关闭")); + if (!child) return; + try { + child.stdin?.end(); + } catch { + // stdin already closed. + } + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + const timer = setTimeout(() => { + try { + child.kill("SIGTERM"); + } catch { + // Already gone. + } + resolve(); + }, CLOSE_GRACE_MS); + timer.unref(); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.once("error", () => { + clearTimeout(timer); + resolve(); + }); + }); + } + + // ── internals ──────────────────────────────────────────────────────────── + + private attach(child: ChildProcess): void { + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + this.stdoutBuffer += chunk; + for (;;) { + const newline = this.stdoutBuffer.indexOf("\n"); + if (newline < 0) break; + const line = this.stdoutBuffer.slice(0, newline).trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); + if (line) this.handleLine(line); + } + }); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + for (const line of chunk.split(/\r?\n/u)) { + if (!line.trim()) continue; + this.stderrLines.push(line); + if (this.stderrLines.length > STDERR_TAIL_LINES) { + this.stderrLines.shift(); + } + } + }); + child.once("error", (error) => { + // Spawn failure (e.g. ENOENT) — the message carries the detail. + this.markDead(`无法启动 codegraph serve:${error.message}`); + }); + child.once("exit", (code, signal) => { + this.deadFlag = true; + this.exitDetail = + signal !== null ? `signal ${signal}` : `code ${code}`; + }); + // Reject pending on "close" (stdio drained), not "exit" — otherwise the + // final stderr lines may not have been delivered yet. + child.once("close", () => { + this.markDead(`codegraph serve 子进程退出(${this.exitDetail})`); + }); + } + + private handleLine(line: string): void { + let message: unknown; + try { + message = JSON.parse(line); + } catch { + return; // Not JSON — server chatter; ignore. + } + if (!isRecord(message)) return; + + // Server → client request: we implement no methods; reply -32601. + if (typeof message.method === "string" && message.id !== undefined) { + this.sendRaw({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: "Method not found" }, + }); + return; + } + // Response to one of our requests. + if (message.id !== undefined) { + const entry = this.pending.get(Number(message.id)); + if (!entry) return; + if (message.error !== undefined) { + const detail = isRecord(message.error) + ? (message.error.message ?? JSON.stringify(message.error)) + : String(message.error); + entry.reject(new Error(`codegraph serve ${String(detail)}`)); + } else { + entry.resolve(message.result); + } + } + // Anything else (server notifications) is ignored. + } + + private request( + method: string, + params: Record, + signal?: AbortSignal, + ): Promise { + if (this.deadFlag || this.closedFlag) { + return Promise.reject(this.deathError()); + } + const stdin = this.child?.stdin; + if (!this.child || !stdin || stdin.destroyed) { + return Promise.reject(new Error("codegraph serve 子进程不可用")); + } + if (signal?.aborted) { + return Promise.reject(new Error("codegraph serve 请求已中止")); + } + const id = this.nextId++; + return new Promise((resolve, reject) => { + let onAbort: (() => void) | undefined; + const finish = (action: () => void): void => { + clearTimeout(timer); + if (onAbort) signal?.removeEventListener("abort", onAbort); + this.pending.delete(id); + action(); + }; + const timer = setTimeout(() => { + finish(() => + reject(new Error(`codegraph serve 请求 ${method} 超时`)), + ); + }, this.timeoutMs); + timer.unref(); + onAbort = () => { + this.notify("notifications/cancelled", { + requestId: id, + reason: "aborted", + }); + finish(() => reject(new Error("codegraph serve 请求已中止"))); + }; + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + this.pending.set(id, { + resolve: (result) => finish(() => resolve(result)), + reject: (error) => finish(() => reject(error)), + }); + this.sendRaw({ jsonrpc: "2.0", id, method, params }); + }); + } + + private notify(method: string, params?: Record): void { + this.sendRaw({ jsonrpc: "2.0", method, params }); + } + + private sendRaw(message: Record): void { + try { + this.child?.stdin?.write(`${JSON.stringify(message)}\n`); + } catch { + // stdin already closed — the exit handler will fail pending calls. + } + } + + private markDead(reason: string): void { + if (this.deathFinalized) return; + this.deathFinalized = true; + this.deadFlag = true; + const tail = this.stderrLines.join("\n").trim(); + this.deathReason = tail ? `${reason};stderr: ${tail}` : reason; + this.failAll(this.deathError()); + } + + private deathError(): Error { + return new Error( + this.closedFlag && !this.deadFlag + ? "codegraph serve 客户端已关闭" + : this.deathReason, + ); + } + + private failAll(error: Error): void { + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const entry of pending) entry.reject(error); + } +} diff --git a/extensions/codegraph.ts b/extensions/codegraph.ts index 0d9b2a9227c255c0f96052cee7c3a07eeffe1e5e..036237abddffdfe1f0a30db52a2b6cd6cc8fcf3a 100644 GIT binary patch literal 10734 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 diff --git a/tests/codegraph.test.ts b/tests/codegraph.test.ts index fbf2cba..c1cace5 100644 --- a/tests/codegraph.test.ts +++ b/tests/codegraph.test.ts @@ -1,49 +1,124 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; +import { + execFileSync, + spawn, + type ChildProcess, +} from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { - buildExploreArgs, + CodegraphRpcClient, + type CodegraphRpcClientOptions, +} from "../extensions/codegraph-client.ts"; +import { createCodegraphExploreHandler, findIndexedRoot, - isNotIndexedOutput, type CodegraphExec, + type CodegraphExploreClient, type ExploreParams, } from "../extensions/codegraph.ts"; -async function makeIndexedProject(): Promise<{ root: string; cleanup: () => Promise }> { +const FIXTURE = fileURLToPath( + new URL("./fixtures/fake-codegraph-mcp.mjs", import.meta.url), +); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ── fixtures ──────────────────────────────────────────────────────────────── + +async function makeIndexedProject(): Promise<{ + root: string; + cleanup: () => Promise; +}> { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); await mkdir(join(root, ".codegraph")); await writeFile(join(root, ".codegraph", "codegraph.db"), ""); return { root, cleanup: () => rm(root, { recursive: true, force: true }) }; } -function mockCtx(cwd: string, confirm?: () => Promise) { +/** Spawn the .mjs fixture under process.execPath with the given env. */ +function fixtureSpawn(extraEnv: Record, children?: ChildProcess[]) { + return (_command: string, _args: string[], options: object) => { + const child = spawn(process.execPath, [FIXTURE], { + ...(options as object), + env: { ...process.env, ...extraEnv }, + }); + children?.push(child); + return child; + }; +} + +function fixtureClient( + root: string, + extraEnv: Record = {}, + overrides: Partial = {}, + children?: ChildProcess[], +): CodegraphRpcClient { + return new CodegraphRpcClient({ + root, + requestTimeoutMs: 5_000, + spawn: fixtureSpawn( + { FAKE_CG_MODE: "ok", ...extraEnv }, + children, + ), + ...overrides, + }); +} + +function mockCtx( + cwd: string, + ui?: { confirm?: () => Promise; notify?: (m: string, t?: string) => void }, +) { return { cwd, - hasUI: confirm !== undefined, - ui: { confirm: confirm ?? (async () => false) }, + hasUI: ui !== undefined, + ui: { + confirm: ui?.confirm ?? (async () => false), + notify: ui?.notify ?? (() => {}), + }, } as never; } -function mockExec(handler: (command: string, args: string[]) => { code?: number; stdout?: string; stderr?: string }) { +function mockExec( + handler: (command: string, args: string[]) => { + code?: number; + stdout?: string; + stderr?: string; + }, +) { const calls: Array<{ command: string; args: string[] }> = []; const exec: CodegraphExec = async (command, args) => { calls.push({ command, args }); const result = handler(command, args); - return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", killed: false }; + return { + code: result.code ?? 0, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + killed: false, + }; }; return { exec, calls }; } -function makeHandler(exec: CodegraphExec) { - return { - handle: createCodegraphExploreHandler(exec, new Map(), new Map()), - }; +function makeHandler( + exec: CodegraphExec, + createClient: (root: string) => CodegraphExploreClient, +) { + return createCodegraphExploreHandler({ exec, createClient }); } +// ── findIndexedRoot (unchanged behavior) ──────────────────────────────────── + test("findIndexedRoot locates the nearest ancestor index", async () => { const { root, cleanup } = await makeIndexedProject(); try { @@ -65,71 +140,229 @@ test("findIndexedRoot returns undefined when no index exists", async () => { } }); -test("buildExploreArgs maps params to CLI argv", () => { - assert.deepEqual(buildExploreArgs({ query: "auth flow" }, "/repo"), ["explore", "-p", "/repo", "auth flow"]); - assert.deepEqual( - buildExploreArgs({ query: "q", maxFiles: 5, projectPath: "/ignored" }, "/repo"), - ["explore", "-p", "/repo", "--max-files", "5", "q"], - ); -}); +// ── CodegraphRpcClient against the fixture ────────────────────────────────── -test("isNotIndexedOutput detects the upstream marker", () => { - assert.equal(isNotIndexedOutput("✗ CodeGraph isn't available here — no .codegraph/ index exists in /x."), true); - assert.equal(isNotIndexedOutput("Found 3 symbols"), false); -}); - -test("explore runs the CLI and caches by database mtime", async () => { +test("client handshake + explore returns upstream content verbatim", async () => { const { root, cleanup } = await makeIndexedProject(); - const { exec, calls } = mockExec(() => ({ stdout: "explore output" })); - const { handle } = makeHandler(exec); + const client = fixtureClient(root); try { - const first = await handle({ query: "auth" }, mockCtx(root)); - assert.equal(first.content[0].text, "explore output"); - const second = await handle({ query: "auth" }, mockCtx(root)); - assert.equal(second.content[0].text, "explore output"); - assert.equal(calls.length, 1, "identical query on unchanged index must hit the cache"); - - // Simulate a reindex: bump the db mtime. - await utimes(join(root, ".codegraph", "codegraph.db"), new Date(), new Date(Date.now() + 5000)); - await handle({ query: "auth" }, mockCtx(root)); - assert.equal(calls.length, 2, "mtime change must invalidate the cached result"); + await client.start(); + const result = await client.callExplore({ + query: "alphaOne", + maxFiles: 5, + projectPath: "/some/dir", + }); + const content = result.content as Array<{ type: string; text: string }>; + const args = JSON.parse(content[0].text.slice("explore:".length)); + assert.deepEqual(args, { + query: "alphaOne", + maxFiles: 5, + projectPath: "/some/dir", + }); } finally { + await client.close(); await cleanup(); } }); -test("uninitialized project asks the user and indexes on approval", async () => { - const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); - const { exec, calls } = mockExec((_command, args) => { - if (args[0] === "init") { - // Emulate codegraph init creating the index. - return { stdout: "indexed" }; - } - return { stdout: "explore output" }; +test("client forwards only properties the upstream schema declares", async () => { + const { root, cleanup } = await makeIndexedProject(); + const client = fixtureClient(root, { + FAKE_CG_SCHEMA: '["query","maxFiles"]', }); - const { handle } = makeHandler(exec); try { - const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); - assert.equal(result.content[0].text, "explore output"); - assert.deepEqual( - calls.map(({ args }) => args[0]), - ["init", "explore"], - ); - assert.deepEqual(calls[0].args, ["init", root]); + await client.start(); + const result = await client.callExplore({ + query: "q", + maxFiles: 3, + projectPath: "/should/not/forward", + }); + const content = result.content as Array<{ text: string }>; + const args = JSON.parse(content[0].text.slice("explore:".length)); + assert.deepEqual(args, { query: "q", maxFiles: 3 }); } finally { + await client.close(); + await cleanup(); + } +}); + +test("client start fails when the server lacks codegraph_explore", async () => { + const { root, cleanup } = await makeIndexedProject(); + const client = fixtureClient(root, { FAKE_CG_MODE: "no-tool" }); + try { + await assert.rejects(client.start(), /未提供 codegraph_explore 工具/); + } finally { + await client.close(); + await cleanup(); + } +}); + +test("client abort sends notifications/cancelled and rejects", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const logFile = join(dir, "rpc.log"); + const client = fixtureClient(dir, { + FAKE_CG_MODE: "hang", + FAKE_CG_LOG: logFile, + }); + try { + await client.start(); + const controller = new AbortController(); + const call = client.callExplore({ query: "q" }, controller.signal); + setTimeout(() => controller.abort(), 50); + await assert.rejects(call); + await client.close(); + const lines = (await readFile(logFile, "utf8")) + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + const cancelled = lines.find( + (m) => m.method === "notifications/cancelled", + ); + assert.ok(cancelled, "server must see notifications/cancelled"); + assert.equal(cancelled.params.requestId, 3); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("client request timeout rejects a hung call", async () => { + const { root, cleanup } = await makeIndexedProject(); + const client = fixtureClient( + root, + { FAKE_CG_MODE: "hang" }, + { requestTimeoutMs: 300 }, + ); + try { + await client.start(); + await assert.rejects(client.callExplore({ query: "q" }), /超时/); + } finally { + await client.close(); + await cleanup(); + } +}); + +test("client exit rejects the pending call with the stderr tail", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const client = fixtureClient(dir, { + FAKE_CG_MODE: "crash-first", + FAKE_CG_MARKER: join(dir, "crash.marker"), + }); + try { + await client.start(); + await assert.rejects( + client.callExplore({ query: "q" }), + /index corrupted/, + ); + assert.equal(client.dead, true); + } finally { + await client.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("client close() terminates the child process", async () => { + const { root, cleanup } = await makeIndexedProject(); + const children: ChildProcess[] = []; + const client = fixtureClient(root, {}, {}, children); + try { + await client.start(); + await client.close(); + assert.equal(children.length, 1); + assert.ok( + children[0].exitCode !== null || children[0].signalCode !== null, + "child must be gone after close()", + ); + } finally { + await client.close(); + await cleanup(); + } +}); + +// ── handler ───────────────────────────────────────────────────────────────── + +test("indexed project explores through the serve child", async () => { + const { root, cleanup } = await makeIndexedProject(); + const { exec, calls } = mockExec(() => ({ stdout: "unreachable" })); + const handler = makeHandler(exec, (r) => fixtureClient(r)); + try { + const result = await handler.handle({ query: "auth" }, mockCtx(root)); + const content = result.content as Array<{ text: string }>; + assert.match(content[0].text, /^explore:\{/); + assert.equal(calls.length, 0, "indexed project never runs codegraph init"); + } finally { + await handler.close(); + await cleanup(); + } +}); + +test("declined init is remembered for the rest of the session", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + let confirmCalls = 0; + const { exec, calls } = mockExec(() => ({ stdout: "unreachable" })); + const handler = makeHandler(exec, (r) => fixtureClient(r)); + const ctx = mockCtx(root, { + confirm: async () => { + confirmCalls += 1; + return false; + }, + }); + try { + const first = await handler.handle({ query: "a" }, ctx); + const second = await handler.handle({ query: "b" }, ctx); + assert.match(first.content[0].text ?? "", /find\/grep\/read\/LSP/); + assert.match(second.content[0].text ?? "", /find\/grep\/read\/LSP/); + assert.equal(confirmCalls, 1, "decline is remembered — no second prompt"); + assert.equal(calls.length, 0, "declined init must not spawn the CLI"); + } finally { + await handler.close(); await rm(root, { recursive: true, force: true }); } }); -test("uninitialized project bounces to built-in tools when declined", async () => { +test("no UI bounces to built-in tools without prompting", async () => { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const { exec, calls } = mockExec(() => ({ stdout: "unreachable" })); - const { handle } = makeHandler(exec); + const handler = makeHandler(exec, (r) => fixtureClient(r)); try { - const result = await handle({ query: "auth" }, mockCtx(root, async () => false)); + const result = await handler.handle({ query: "auth" }, mockCtx(root)); assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/); - assert.equal(calls.length, 0, "declined init must not spawn the CLI at all"); + assert.equal(calls.length, 0); } finally { + await handler.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("uninitialized project inits on approval then explores", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const { exec, calls } = mockExec((_command, args) => { + if (args[0] === "init") return { stdout: "indexed" }; + return { stdout: "unreachable" }; + }); + // Emulate codegraph init materializing the index. + const execWithSideEffect: CodegraphExec = async (command, args, options) => { + const result = await exec(command, args, options); + if (args[0] === "init") { + await mkdir(join(root, ".codegraph")); + await writeFile(join(root, ".codegraph", "codegraph.db"), ""); + } + return result; + }; + const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r)); + try { + const result = await handler.handle( + { query: "auth" }, + mockCtx(root, { confirm: async () => true }), + ); + const content = result.content as Array<{ text: string }>; + assert.match(content[0].text, /^explore:\{/); + assert.deepEqual( + calls.map(({ args }) => args[0]), + ["init"], + ); + assert.deepEqual(calls[0].args, ["init", root]); + } finally { + await handler.close(); await rm(root, { recursive: true, force: true }); } }); @@ -138,21 +371,28 @@ test("concurrent calls on an uninitialized project share one init decision", asy const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); let confirmCalls = 0; const { exec, calls } = mockExec(() => ({ stdout: "ok" })); - const pending = new Map>(); - const handle = createCodegraphExploreHandler(exec, new Map(), pending); + const execWithSideEffect: CodegraphExec = async (command, args, options) => { + const result = await exec(command, args, options); + if (args[0] === "init") { + await mkdir(join(root, ".codegraph")); + await writeFile(join(root, ".codegraph", "codegraph.db"), ""); + } + return result; + }; + const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r)); try { + const ctx = mockCtx(root, { + confirm: async () => { + confirmCalls += 1; + return true; + }, + }); const [a, b] = await Promise.all([ - handle({ query: "one" }, mockCtx(root, async () => { - confirmCalls += 1; - return true; - })), - handle({ query: "two" }, mockCtx(root, async () => { - confirmCalls += 1; - return true; - })), + handler.handle({ query: "one" }, ctx), + handler.handle({ query: "two" }, ctx), ]); - assert.equal(a.content[0].text, "ok"); - assert.equal(b.content[0].text, "ok"); + assert.equal(a.content[0].type, "text"); + assert.equal(b.content[0].type, "text"); assert.equal(confirmCalls, 1, "parallel callers must not double-prompt"); assert.equal( calls.filter(({ args }) => args[0] === "init").length, @@ -160,49 +400,193 @@ test("concurrent calls on an uninitialized project share one init decision", asy "parallel callers must share a single init", ); } finally { + await handler.close(); await rm(root, { recursive: true, force: true }); } }); -test("init failure is reported instead of exploring", async () => { +test("init failure bounces with exit code instead of exploring", async () => { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const { exec } = mockExec((_command, args) => args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" }, ); - const { handle } = makeHandler(exec); + const handler = makeHandler(exec, (r) => fixtureClient(r)); try { - const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); - assert.match(result.content[0].text ?? "", /codegraph init 失败/); + const result = await handler.handle( + { query: "auth" }, + mockCtx(root, { confirm: async () => true }), + ); + assert.match(result.content[0].text ?? "", /codegraph init 失败(退出码 1)/); } finally { + await handler.close(); await rm(root, { recursive: true, force: true }); } }); +test("a dead serve child is transparently replaced once", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + await mkdir(join(dir, ".codegraph")); + await writeFile(join(dir, ".codegraph", "codegraph.db"), ""); + const env = { + FAKE_CG_MODE: "crash-first", + FAKE_CG_MARKER: join(dir, "crash.marker"), + }; + let created = 0; + const { exec } = mockExec(() => ({ stdout: "unreachable" })); + const handler = makeHandler(exec, (r) => { + created += 1; + return fixtureClient(r, env); + }); + try { + const result = await handler.handle({ query: "auth" }, mockCtx(dir)); + const content = result.content as Array<{ text: string }>; + assert.match(content[0].text, /^explore:\{/); + assert.equal(created, 2, "the crashed client is replaced exactly once"); + } finally { + await handler.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a persistently crashing child bounces after one retry", async () => { + const { root, cleanup } = await makeIndexedProject(); + let created = 0; + const { exec } = mockExec(() => ({ stdout: "unreachable" })); + const handler = makeHandler(exec, (r) => { + created += 1; + return fixtureClient(r, { FAKE_CG_MODE: "crash" }); + }); + try { + const result = await handler.handle({ query: "auth" }, mockCtx(root)); + assert.match(result.content[0].text ?? "", /CodeGraph 服务调用失败/); + assert.equal(created, 2, "one retry, then bounce"); + } finally { + await handler.close(); + await cleanup(); + } +}); + test("missing CLI surfaces an install hint instead of throwing", async () => { const { root, cleanup } = await makeIndexedProject(); - const exec: CodegraphExec = async () => { - throw new Error("spawn codegraph ENOENT"); - }; - const { handle } = makeHandler(exec); + const { exec } = mockExec(() => ({ stdout: "unreachable" })); + const handler = makeHandler(exec, () => ({ + dead: false, + start: async () => { + throw new Error("spawn codegraph ENOENT"); + }, + callExplore: async () => ({}), + close: async () => {}, + schemaPropertyNames: () => [], + })); try { - const result = await handle({ query: "auth" }, mockCtx(root)); - assert.match(result.content[0].text ?? "", /无法执行 codegraph CLI/); + const result = await handler.handle({ query: "auth" }, mockCtx(root)); + assert.match(result.content[0].text ?? "", /未找到 codegraph CLI/); + assert.match(result.content[0].text ?? "", /install\.sh/); } finally { + await handler.close(); await cleanup(); } }); -test("projectPath overrides the session cwd", async () => { - const { root, cleanup } = await makeIndexedProject(); - const other = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); - const { exec, calls } = mockExec(() => ({ stdout: "ok" })); - const { handle } = makeHandler(exec); +test("projectPath targets a different indexed root with its own client", async () => { + const main = await makeIndexedProject(); + const other = await makeIndexedProject(); + const roots: string[] = []; + const { exec } = mockExec(() => ({ stdout: "unreachable" })); + const handler = makeHandler(exec, (r) => { + roots.push(r); + return fixtureClient(r); + }); try { - const params: ExploreParams = { query: "auth", projectPath: root }; - await handle(params, mockCtx(other)); - assert.deepEqual(calls[0].args, ["explore", "-p", root, "auth"]); + await handler.handle({ query: "a" }, mockCtx(main.root)); + await handler.handle( + { query: "b", projectPath: other.root } satisfies ExploreParams, + mockCtx(main.root), + ); + assert.deepEqual(roots, [main.root, other.root]); } finally { - await cleanup(); - await rm(other, { recursive: true, force: true }); + await handler.close(); + await main.cleanup(); + await other.cleanup(); } }); + +test("schema drift notifies once per process", async () => { + const { root, cleanup } = await makeIndexedProject(); + const notices: string[] = []; + const { exec } = mockExec(() => ({ stdout: "unreachable" })); + const handler = makeHandler(exec, (r) => + fixtureClient(r, { FAKE_CG_SCHEMA: '["query","maxFiles"]' }), + ); + const ctx = mockCtx(root, { + confirm: async () => true, + notify: (message: string) => { + notices.push(message); + }, + }); + try { + await handler.handle({ query: "a" }, ctx); + await handler.handle({ query: "b" }, ctx); + assert.equal(notices.length, 1); + assert.match(notices[0], /参数已变化/); + } finally { + await handler.close(); + await cleanup(); + } +}); + +// ── real CLI integration ──────────────────────────────────────────────────── + +const codegraphAvailable = (() => { + try { + execFileSync("codegraph", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +})(); + +test( + "real codegraph serve --mcp keeps the index synced via its watcher", + { skip: !codegraphAvailable }, + async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-real-")); + const client = new CodegraphRpcClient({ root: dir }); + try { + execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" }); + await writeFile( + join(dir, "a.ts"), + "export function alphaOne() { return 1; }\n", + ); + execFileSync("codegraph", ["init", dir], { + cwd: dir, + stdio: "ignore", + timeout: 120_000, + }); + + const first = await client.callExplore({ query: "alphaOne" }); + const firstText = (first.content as Array<{ text?: string }>) + .map((c) => c.text ?? "") + .join("\n"); + assert.match(firstText, /alphaOne/); + + await writeFile( + join(dir, "a.ts"), + "export function alphaOne() { return 1; }\nexport function betaTwo() { return 2; }\n", + ); + const deadline = Date.now() + 15_000; + let found = false; + while (Date.now() < deadline && !found) { + const res = await client.callExplore({ query: "betaTwo" }); + found = (res.content as Array<{ text?: string }>).some((c) => + (c.text ?? "").includes("betaTwo"), + ); + if (!found) await sleep(500); + } + assert.ok(found, "watcher should make betaTwo searchable within 15 s"); + } finally { + await client.close(); + await rm(dir, { recursive: true, force: true }); + } + }, +); diff --git a/tests/fixtures/fake-codegraph-mcp.mjs b/tests/fixtures/fake-codegraph-mcp.mjs new file mode 100644 index 0000000..108b8c2 --- /dev/null +++ b/tests/fixtures/fake-codegraph-mcp.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Fake `codegraph serve --mcp`: newline-delimited JSON-RPC 2.0 on stdio, for + * tests of extensions/codegraph-client.ts. + * + * Env: + * FAKE_CG_MODE "ok" (default) | "no-tool" | "crash-first" | "crash" | "hang" + * no-tool: tools/list returns no codegraph_explore + * crash-first: on the first tools/call, write FAKE_CG_MARKER + * and exit(1) — a second spawned process sees + * the marker and serves normally + * crash: exit(1) on every tools/call + * hang: never reply to tools/call + * FAKE_CG_SCHEMA JSON array of inputSchema property names + * (default ["query","maxFiles","projectPath"]) + * FAKE_CG_MARKER marker file path for crash-first + * FAKE_CG_LOG file to append every inbound message as a JSON line + */ + +import fs from "node:fs"; +import readline from "node:readline"; + +const mode = process.env.FAKE_CG_MODE || "ok"; +const marker = process.env.FAKE_CG_MARKER; +const logFile = process.env.FAKE_CG_LOG; +const schemaProps = (() => { + try { + return JSON.parse(process.env.FAKE_CG_SCHEMA || ""); + } catch { + return ["query", "maxFiles", "projectPath"]; + } +})(); + +function log(entry) { + if (logFile) fs.appendFileSync(logFile, JSON.stringify(entry) + "\n"); +} + +function send(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} + +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const text = line.trim(); + if (!text) return; + let msg; + try { + msg = JSON.parse(text); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + log(msg); + if (typeof msg.method !== "string") return; + + switch (msg.method) { + case "initialize": + send({ + jsonrpc: "2.0", + id: msg.id, + result: { + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: { name: "fake-codegraph", version: "0.0.0" }, + }, + }); + return; + case "tools/list": { + const tools = + mode === "no-tool" + ? [] + : [ + { + name: "codegraph_explore", + description: "fake explore", + inputSchema: { + type: "object", + properties: Object.fromEntries( + schemaProps.map((name) => [name, { type: "string" }]), + ), + required: ["query"], + }, + }, + ]; + send({ jsonrpc: "2.0", id: msg.id, result: { tools } }); + return; + } + case "tools/call": { + const shouldCrash = + mode === "crash" || + (mode === "crash-first" && marker && !fs.existsSync(marker)); + if (shouldCrash) { + if (mode === "crash-first") fs.writeFileSync(marker, "crashed"); + // Synchronous write — process.exit() can truncate pending pipe output. + fs.writeSync(2, "fatal: codegraph index corrupted\n"); + process.exit(1); + } + if (mode === "hang") return; // Never reply — timeout/abort test. + const args = (msg.params && msg.params.arguments) || {}; + send({ + jsonrpc: "2.0", + id: msg.id, + result: { + content: [{ type: "text", text: `explore:${JSON.stringify(args)}` }], + }, + }); + return; + } + default: + // Notifications (initialized, cancelled) need no reply; unknown + // requests get -32601. + if (msg.id !== undefined) { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: "Method not found" }, + }); + } + } +});