From 3eb4a1b23fb3219327eab29c8f87d61502172177 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 17:16:54 -0700 Subject: [PATCH] feat: code-intelligence contract with real GBrain/Graphify/Sourcebot adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contract.ts: repo-oriented interface (four required ops, three optional), typed CodeProviderError, egress + capability consent guards. Three real, runtime-drivable adapters: - GbrainProvider: gbrain CLI (reuses lib/gbrain-exec + lib/gbrain-sources), all seven capabilities. - GraphifyProvider: graphify CLI — `graphify ` builds the local graph, `graphify query` searches it, export reads graphify-out/graph.json. Fully local; never auto-installed. - SourcebotProvider: self-hosted server over HTTP — register writes a local git connection to config.json, search is POST /api/search, status is a liveness probe. Loopback base URL = local (no egress); remote = consent. selection.ts persists the chosen provider + per-repo indexing consent under $GSTACK_HOME. picker.ts recommends GBrain first, resolves the selected provider or null (provider-OFF), and probes live availability. Every adapter degrades to PROVIDER_UNAVAILABLE when its tool/server is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/code-intelligence/graphify-adapter.ts | 156 ++++++++++++++++ lib/code-intelligence/index.ts | 16 +- lib/code-intelligence/picker.ts | 98 +++++++--- lib/code-intelligence/selection.ts | 68 +++++++ lib/code-intelligence/sourcebot-adapter.ts | 197 +++++++++++++++++++++ 5 files changed, 508 insertions(+), 27 deletions(-) create mode 100644 lib/code-intelligence/graphify-adapter.ts create mode 100644 lib/code-intelligence/selection.ts create mode 100644 lib/code-intelligence/sourcebot-adapter.ts diff --git a/lib/code-intelligence/graphify-adapter.ts b/lib/code-intelligence/graphify-adapter.ts new file mode 100644 index 000000000..3cbcf492c --- /dev/null +++ b/lib/code-intelligence/graphify-adapter.ts @@ -0,0 +1,156 @@ +/** + * Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify). + * + * Graphify is a LOCAL tree-sitter knowledge graph: `graphify ` builds a + * `graphify-out/` (graph.json + report) in that dir, `graphify query ""` + * queries it, and nothing leaves the machine (no embeddings, no network). So it + * needs no egress consent and no MCP — the runtime just shells out to the CLI, + * the same shape as the GBrain adapter. + * + * Never auto-installed: install is `pip install graphifyy && graphify install`, + * a user action the picker gates on. When the CLI is absent every op throws + * PROVIDER_UNAVAILABLE and callers degrade to file-only. + * + * Path-based, not id-based: for Graphify a source "id" IS the absolute repo path + * (that is where `graphify-out/` lives), unlike GBrain's short source ids. + */ + +import { spawnSync } from "child_process"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { + assertCapability, + assertRequiredCapabilities, + CodeProviderError, + type CodeProvider, + type CodeProviderCapability, + type CodeSearchHit, + type OpOptions, + type RepoRef, + type SearchOptions, + type SourceRef, + type SourceStatus, +} from "./contract"; + +const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status", "export"]; +const OUT_DIR = "graphify-out"; +const GRAPH_JSON = "graph.json"; +const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while +const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows + +export interface GraphifyOptions { + /** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */ + root?: string; + env?: NodeJS.ProcessEnv; +} + +export class GraphifyProvider implements CodeProvider { + readonly id = "graphify" as const; + readonly label = "Graphify"; + readonly capabilities = new Set(CAPABILITIES); + /** Fully local — no repo content leaves the machine. */ + readonly local = true; + readonly #root: string; + readonly #env?: NodeJS.ProcessEnv; + + constructor(opts: GraphifyOptions = {}) { + this.#root = opts.root ?? process.cwd(); + this.#env = opts.env; + assertRequiredCapabilities(this.id, this.capabilities); + } + + has(capability: CodeProviderCapability): boolean { + return this.capabilities.has(capability); + } + + #run(args: string[], cwd: string, timeout: number) { + return spawnSync("graphify", args, { + cwd, + encoding: "utf-8", + timeout, + stdio: ["ignore", "pipe", "pipe"], + env: this.#env, + shell: NEEDS_SHELL_ON_WINDOWS, + }); + } + + #assertOk(r: { status: number | null; stderr?: string; error?: Error & { code?: string }; signal?: NodeJS.Signals | null }): void { + if (r.status === 0) return; + const stderr = (r.stderr || "").trim(); + if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) { + throw new CodeProviderError("PROVIDER_UNAVAILABLE", "graphify CLI not on PATH (install: pip install graphifyy && graphify install)", this.id); + } + if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") { + throw new CodeProviderError("PROVIDER_TIMEOUT", "graphify timed out", this.id); + } + throw new CodeProviderError("PROVIDER_ERROR", stderr || `graphify exited ${r.status}`, this.id); + } + + /** Build the graph over repo.path (local; no egress consent needed). */ + async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise { + this.#assertOk(this.#run(["."], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS)); + return this.status({ id: repo.path }, opts); + } + + /** Re-parse and merge changes into the existing graph. */ + async refresh(source: SourceRef, opts: OpOptions = {}): Promise { + this.#assertOk(this.#run([".", "--update"], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS)); + return this.status(source, opts); + } + + /** + * `graphify query ""` returns a traced answer over the graph. Its stdout is + * an answer, not a fixed hit schema (the CLI reference documents the command + * but not a machine format), so we map non-empty output lines to hits + * tolerantly: a leading path-like token becomes the ref, the line the snippet. + * Reconcile against a live graphify if a stricter schema is needed. + */ + async search(query: string, opts: SearchOptions = {}): Promise { + if (!query.trim()) return []; + const cwd = opts.source ?? this.#root; + const r = this.#run(["query", query], cwd, opts.timeout ?? DEFAULT_TIMEOUT_MS); + this.#assertOk(r); + return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10); + } + + async status(source?: SourceRef, _opts: OpOptions = {}): Promise { + const dir = source?.id ?? this.#root; + const graphPath = join(dir, OUT_DIR, GRAPH_JSON); + if (!existsSync(graphPath)) return { id: dir, state: "absent" }; + let itemCount: number | undefined; + try { + const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] }; + if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length; + } catch { + // graph.json present but unparseable — still ready, just no count. + } + return { id: dir, state: "ready", itemCount, detail: graphPath }; + } + + async export(source: SourceRef, _opts: OpOptions = {}): Promise { + assertCapability(this, "export"); + const graphPath = join(source.id, OUT_DIR, GRAPH_JSON); + if (!existsSync(graphPath)) { + throw new CodeProviderError("SOURCE_NOT_REGISTERED", `no graph at ${graphPath}; index it first`, this.id); + } + return readFileSync(graphPath, "utf-8"); + } +} + +/** + * Map `graphify query` stdout to hits. Tolerant: each non-empty line becomes a + * hit; a leading `path` or `path:line` token becomes the ref, else the whole + * line is the snippet. Exported for deterministic unit testing. + */ +export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] { + const hits: CodeSearchHit[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const token = line.split(/\s+/)[0]; + const looksPath = /[/\\.]/.test(token) && !token.includes(" "); + hits.push({ ref: looksPath ? token : "graphify", snippet: line, kind: "graph-node" }); + if (hits.length >= limit) break; + } + return hits; +} diff --git a/lib/code-intelligence/index.ts b/lib/code-intelligence/index.ts index 48a58f0c3..0c69719c8 100644 --- a/lib/code-intelligence/index.ts +++ b/lib/code-intelligence/index.ts @@ -5,10 +5,20 @@ export * from "./contract"; export { GbrainProvider, parseGbrainSearch } from "./gbrain-adapter"; -export { SourcebotProvider, GraphifyProvider } from "./mcp-adapters"; +export { GraphifyProvider, parseGraphifyQuery, type GraphifyOptions } from "./graphify-adapter"; +export { SourcebotProvider, parseSourcebotSearch, type SourcebotOptions } from "./sourcebot-adapter"; +export { + readSelection, + setProvider, + setConsent, + hasConsent, + type Selection, +} from "./selection"; export { - recommendCodeProvider, - resolveCodeProvider, RECOMMENDED_ORDER, + providerById, + resolveSelectedProvider, + detectAvailable, type PickerOptions, + type Availability, } from "./picker"; diff --git a/lib/code-intelligence/picker.ts b/lib/code-intelligence/picker.ts index 774cb59e7..0d31d1c28 100644 --- a/lib/code-intelligence/picker.ts +++ b/lib/code-intelligence/picker.ts @@ -1,43 +1,93 @@ /** - * Picker — recommends a code-intelligence provider, GBrain first. + * Picker — constructs the code-intelligence provider the user selected, and + * offers the recommendation order (GBrain first) for the selection UX. * - * RECOMMENDED_ORDER is the static "GBrain first" fact the options UX and phase-2 - * resolution filter against. In phase 1 only GBrain is drivable from the runtime - * (it has a CLI the runtime can spawn); Sourcebot and Graphify are MCP tools in - * the host session and become resolvable in phase 2 once a host transport is - * wired. So recommendCodeProvider returns GBrain-or-nothing today, and - * resolveCodeProvider degrades to null — the provider-OFF path — when GBrain is - * unavailable. Callers then use grep / the file-only decision store. + * `resolveSelectedProvider()` reads the persisted selection and constructs that + * provider, or returns null when nothing is selected — the provider-OFF path, + * where callers degrade to grep / the file-only decision store. Availability is + * proven at call time: a selected provider whose tool/server is absent throws + * PROVIDER_UNAVAILABLE from its ops, which callers catch and degrade on. The + * `detectAvailable()` probe drives the `options`/`status` display. * - * GBrain availability uses the real detector, localEngineStatus() ("ok"/"timeout" - * are usable). Graphify is NEVER auto-installed or auto-offered. + * GBrain is recommended first. Graphify is NEVER auto-installed — it appears in + * the options only once its CLI is present (a user install). */ -import { localEngineStatus, type LocalEngineStatus } from "../gbrain-local-status"; +import { localEngineStatus } from "../gbrain-local-status"; import { GbrainProvider } from "./gbrain-adapter"; +import { GraphifyProvider, type GraphifyOptions } from "./graphify-adapter"; +import { SourcebotProvider, type SourcebotOptions } from "./sourcebot-adapter"; +import { readSelection } from "./selection"; import type { CodeProvider, CodeProviderId } from "./contract"; -/** Recommendation order — GBrain first. Sourcebot/Graphify join in phase 2. */ +/** Recommendation order — GBrain first. */ export const RECOMMENDED_ORDER: readonly CodeProviderId[] = ["gbrain", "sourcebot", "graphify"]; export interface PickerOptions { env?: NodeJS.ProcessEnv; - /** Inject GBrain status for tests; otherwise probed via localEngineStatus(). */ - gbrainStatus?: LocalEngineStatus; + graphify?: GraphifyOptions; + sourcebot?: SourcebotOptions; } -const GBRAIN_USABLE: ReadonlySet = new Set(["ok", "timeout"]); - -/** Drivable providers, in recommendation order (GBrain first). */ -export function recommendCodeProvider(opts: PickerOptions = {}): CodeProvider[] { - const status = opts.gbrainStatus ?? localEngineStatus({ env: opts.env }); - return GBRAIN_USABLE.has(status) ? [new GbrainProvider()] : []; +/** Construct a provider by id (no availability check — ops degrade at call time). */ +export function providerById(id: CodeProviderId, opts: PickerOptions = {}): CodeProvider { + switch (id) { + case "gbrain": + return new GbrainProvider(); + case "graphify": + return new GraphifyProvider({ env: opts.env, ...opts.graphify }); + case "sourcebot": + return new SourcebotProvider({ env: opts.env, ...opts.sourcebot }); + } } /** - * The single recommended provider, or null when none is drivable. Null is the - * provider-OFF path: callers MUST degrade to grep / file-only, never fail. + * The provider the user selected, constructed, or null when none is selected. + * Null is the provider-OFF path: callers MUST degrade to grep / file-only. */ -export function resolveCodeProvider(opts: PickerOptions = {}): CodeProvider | null { - return recommendCodeProvider(opts)[0] ?? null; +export function resolveSelectedProvider(opts: PickerOptions = {}): CodeProvider | null { + const { provider } = readSelection(opts.env); + return provider ? providerById(provider, opts) : null; +} + +export interface Availability { + id: CodeProviderId; + available: boolean; + detail: string; +} + +/** + * Probe which providers are usable right now, in recommendation order. Used by + * the `options`/`status` display. GBrain via the real localEngineStatus(); + * Graphify via its CLI status; Sourcebot via an HTTP liveness probe. + */ +export async function detectAvailable(opts: PickerOptions = {}): Promise { + const gbrainStatus = localEngineStatus({ env: opts.env }); + const gbrainOk = gbrainStatus === "ok" || gbrainStatus === "timeout"; + + let graphifyOk = false; + let graphifyDetail = "graphify CLI not installed"; + try { + const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status(); + graphifyOk = s.state === "ready"; + graphifyDetail = graphifyOk ? "graph indexed in this repo" : "installed; no graph in this repo yet"; + } catch { + graphifyOk = false; + } + + let sourcebotOk = false; + let sourcebotDetail = "server unreachable"; + try { + const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status(); + sourcebotOk = s.state === "ready"; + sourcebotDetail = s.detail ?? ""; + } catch { + sourcebotOk = false; + } + + return [ + { id: "gbrain", available: gbrainOk, detail: `gbrain engine: ${gbrainStatus}` }, + { id: "sourcebot", available: sourcebotOk, detail: sourcebotDetail }, + { id: "graphify", available: graphifyOk, detail: graphifyDetail }, + ]; } diff --git a/lib/code-intelligence/selection.ts b/lib/code-intelligence/selection.ts new file mode 100644 index 000000000..c6657aa06 --- /dev/null +++ b/lib/code-intelligence/selection.ts @@ -0,0 +1,68 @@ +/** + * selection — persists the user's chosen code-intelligence provider and their + * per-repo indexing consent. Stored at `$GSTACK_HOME/code-intelligence.json` + * (default `~/.gstack/`), the same home the rest of gstack uses. + * + * Consent is per-repo (keyed by absolute repo path), because indexing consent + * is "may THIS repo's content be indexed by the selected provider" — a decision + * a user makes per project, not once for the machine. No selection at all is the + * provider-OFF default: callers degrade to grep / the file-only decision store. + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { dirname, join, resolve } from "path"; +import type { CodeProviderId } from "./contract"; + +export interface Selection { + provider: CodeProviderId | null; + /** Absolute repo path → consented. */ + consents: Record; +} + +const EMPTY: Selection = { provider: null, consents: {} }; + +function storePath(env: NodeJS.ProcessEnv = process.env): string { + const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack"); + return join(home, "code-intelligence.json"); +} + +export function readSelection(env: NodeJS.ProcessEnv = process.env): Selection { + const p = storePath(env); + if (!existsSync(p)) return { ...EMPTY }; + try { + const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial; + return { + provider: raw.provider ?? null, + consents: raw.consents && typeof raw.consents === "object" ? raw.consents : {}, + }; + } catch { + return { ...EMPTY }; + } +} + +function write(selection: Selection, env: NodeJS.ProcessEnv = process.env): void { + const p = storePath(env); + mkdirSync(dirname(p), { recursive: true }); + const tmp = `${p}.tmp.${process.pid}`; + writeFileSync(tmp, JSON.stringify(selection, null, 2), "utf-8"); + renameSync(tmp, p); +} + +export function setProvider(provider: CodeProviderId | null, env: NodeJS.ProcessEnv = process.env): Selection { + const next = { ...readSelection(env), provider }; + write(next, env); + return next; +} + +/** Record per-repo indexing consent (repo path resolved to absolute). */ +export function setConsent(repoPath: string, consented: boolean, env: NodeJS.ProcessEnv = process.env): Selection { + const current = readSelection(env); + const next: Selection = { ...current, consents: { ...current.consents, [resolve(repoPath)]: consented } }; + write(next, env); + return next; +} + +export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean { + return readSelection(env).consents[resolve(repoPath)] === true; +} diff --git a/lib/code-intelligence/sourcebot-adapter.ts b/lib/code-intelligence/sourcebot-adapter.ts new file mode 100644 index 000000000..b6d7d9fc0 --- /dev/null +++ b/lib/code-intelligence/sourcebot-adapter.ts @@ -0,0 +1,197 @@ +/** + * Sourcebot adapter — real HTTP + config integration + * (github.com/sourcebot-dev/sourcebot, YC F2025). + * + * Sourcebot is a self-hosted server that indexes repos declared in its + * config.json and serves regex code search over `POST /api/search` (zoekt). So + * the runtime drives it with plain HTTP + a config-file edit — no MCP: + * - register_source: add `{ "type": "git", "url": "file:///abs/path" }` to the + * server's config.json (it re-indexes automatically on config change). + * - refresh: Sourcebot re-indexes on config change and on reindexIntervalMs; + * there is no per-source trigger endpoint, so refresh reports current status. + * - search: POST /api/search with a regex query, map files[] to hits. + * - status: liveness GET against the base URL. + * Declines the document ops (add/delete/export) — it is a whole-repo search index. + * + * Egress: a loopback base URL means the index runs on this machine, so no repo + * content leaves it (local=true). A non-loopback base URL means content reaches + * another host, so egress consent is required (local=false). + */ + +import { existsSync, readFileSync, writeFileSync, renameSync } from "fs"; +import { + assertCapability, + assertEgressConsent, + assertRequiredCapabilities, + CodeProviderError, + type CodeProvider, + type CodeProviderCapability, + type CodeSearchHit, + type OpOptions, + type RepoRef, + type SearchOptions, + type SourceRef, + type SourceStatus, +} from "./contract"; + +const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status"]; +const DEFAULT_URL = "http://localhost:3000"; +const DEFAULT_TIMEOUT_MS = 30_000; + +type FetchLike = typeof globalThis.fetch; + +export interface SourcebotOptions { + /** Base URL of the Sourcebot server. Defaults to SOURCEBOT_URL or http://localhost:3000. */ + baseUrl?: string; + /** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */ + configPath?: string; + /** Injectable fetch for tests. */ + fetch?: FetchLike; + env?: NodeJS.ProcessEnv; +} + +function isLoopback(url: string): boolean { + try { + const h = new URL(url).hostname.toLowerCase(); + return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost"); + } catch { + return false; + } +} + +export class SourcebotProvider implements CodeProvider { + readonly id = "sourcebot" as const; + readonly label = "Sourcebot"; + readonly capabilities = new Set(CAPABILITIES); + readonly local: boolean; + readonly #baseUrl: string; + readonly #configPath?: string; + readonly #fetch: FetchLike; + + constructor(opts: SourcebotOptions = {}) { + const env = opts.env ?? process.env; + this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, ""); + this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG; + this.#fetch = opts.fetch ?? globalThis.fetch; + this.local = isLoopback(this.#baseUrl); + assertRequiredCapabilities(this.id, this.capabilities); + } + + has(capability: CodeProviderCapability): boolean { + return this.capabilities.has(capability); + } + + /** Add the repo as a local `git` connection in Sourcebot's config.json. */ + async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise { + assertEgressConsent(this, opts); // no-op when the server is loopback (local) + if (!this.#configPath) { + throw new CodeProviderError( + "PROVIDER_UNAVAILABLE", + "set SOURCEBOT_CONFIG to the server's config.json path to register sources", + this.id, + ); + } + let config: { connections?: Record }; + try { + config = existsSync(this.#configPath) + ? (JSON.parse(readFileSync(this.#configPath, "utf-8")) as typeof config) + : {}; + } catch (err) { + throw new CodeProviderError("PROVIDER_ERROR", `unreadable Sourcebot config: ${(err as Error).message}`, this.id); + } + config.connections = config.connections ?? {}; + config.connections[repo.id] = { type: "git", url: `file://${repo.path}` }; + // Atomic write so a running Sourcebot never reads a half-written config. + const tmp = `${this.#configPath}.tmp.${process.pid}`; + writeFileSync(tmp, JSON.stringify(config, null, 2), "utf-8"); + renameSync(tmp, this.#configPath); + return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" }; + } + + /** Sourcebot re-indexes automatically; report current liveness. */ + async refresh(source: SourceRef, opts: OpOptions = {}): Promise { + const live = await this.status(source, opts); + return { ...live, detail: "Sourcebot re-indexes automatically (config change + reindexIntervalMs)" }; + } + + async search(query: string, opts: SearchOptions = {}): Promise { + if (!query.trim()) return []; + const body = { + query: opts.source ? `repo:${opts.source} ${query}` : query, + matches: opts.limit ?? 20, + isRegexEnabled: true, + isCaseSensitivityEnabled: false, + }; + const payload = await this.#post("/api/search", body, opts.timeout ?? DEFAULT_TIMEOUT_MS); + return parseSourcebotSearch(payload, opts.limit ?? 20); + } + + async status(_source?: SourceRef, opts: OpOptions = {}): Promise { + try { + const res = await this.#fetchWithTimeout(this.#baseUrl, { method: "GET" }, opts.timeout ?? DEFAULT_TIMEOUT_MS); + return { id: "*", state: res.ok ? "ready" : "unknown", partial: true, detail: `HTTP ${res.status}` }; + } catch { + return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` }; + } + } + + async #post(path: string, body: unknown, timeout: number): Promise { + let res: Response; + try { + res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }, timeout); + } catch (err) { + throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id); + } + if (!res.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id); + try { + return await res.json(); + } catch (err) { + throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot returned non-JSON: ${(err as Error).message}`, this.id); + } + } + + async #fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await this.#fetch(url, { ...init, signal: controller.signal }); + } catch (err) { + if ((err as Error)?.name === "AbortError") throw new CodeProviderError("PROVIDER_TIMEOUT", "Sourcebot request timed out", this.id); + throw err; + } finally { + clearTimeout(timer); + } + } +} + +interface SourcebotMatchRange { start?: { lineNumber?: number } } +interface SourcebotChunk { content?: string; matchRanges?: SourcebotMatchRange[] } +interface SourcebotFile { fileName?: { text?: string }; repository?: string; chunks?: SourcebotChunk[] } + +/** + * Map a `POST /api/search` response `{ files: [...] }` to hits: one hit per file, + * ref = file path, snippet = first matching chunk, kind = "file". Tolerant of a + * missing/garbage payload (returns []). Exported for deterministic testing. + */ +export function parseSourcebotSearch(payload: unknown, limit: number): CodeSearchHit[] { + const files = (payload as { files?: unknown })?.files; + if (!Array.isArray(files)) return []; + const hits: CodeSearchHit[] = []; + for (const f of files as SourcebotFile[]) { + const ref = f?.fileName?.text; + if (typeof ref !== "string") continue; + const chunk = f.chunks?.[0]; + const line = chunk?.matchRanges?.[0]?.start?.lineNumber; + hits.push({ + ref: typeof line === "number" ? `${ref}:${line}` : ref, + snippet: typeof chunk?.content === "string" ? chunk.content.trim() : undefined, + kind: "file", + }); + if (hits.length >= limit) break; + } + return hits; +}