diff --git a/test/code-intelligence.test.ts b/test/code-intelligence.test.ts index 953824c72..4fb3fa376 100644 --- a/test/code-intelligence.test.ts +++ b/test/code-intelligence.test.ts @@ -1,17 +1,18 @@ /** - * Tests for lib/code-intelligence — the OPTIONAL, repo-oriented provider contract. + * Tests for lib/code-intelligence — the OPTIONAL, repo-oriented provider contract + * with three REAL adapters (GBrain CLI, Graphify CLI, Sourcebot HTTP) plus the + * selection store the `gstack-code-intelligence` CLI drives. * * Load-bearing properties: - * - Every provider advertises the four required capabilities; optional ops are - * declined with a typed CAPABILITY_UNSUPPORTED, never a silent no-op. - * - Sourcebot/Graphify are phase-1 capability declarations: they prove contract - * fit and throw PROVIDER_UNAVAILABLE until a host MCP transport is wired. - * - The picker recommends GBrain first and resolves to null (provider-OFF) when - * GBrain is unavailable. - * - Non-local providers refuse to move repo content off the machine without - * explicit per-repo consent (PROVIDER_NOT_CONSENTED). - * - The GBrain adapter works end-to-end against a fake `gbrain` shim on PATH. - * - Missing CLI degrades (PROVIDER_UNAVAILABLE), never crashes. + * - Capability matrix: every provider advertises the four required ops; only + * GBrain advertises the document ops (add/delete/export). + * - Consent: non-local providers refuse to index without per-repo consent; a + * localhost Sourcebot and Graphify are local and need none. + * - GBrain search + status work end-to-end against a fake `gbrain` shim. + * - Graphify index/search/status work end-to-end against a fake `graphify` shim. + * - Sourcebot register (config.json edit) + search work against an injected fetch. + * - Selection persists to $GSTACK_HOME; no selection = provider-OFF (null). + * - Every adapter degrades to PROVIDER_UNAVAILABLE when its tool/server is absent. */ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; @@ -19,149 +20,210 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { - assertRequiredCapabilities, - CodeProviderError, REQUIRED_CAPABILITIES, GbrainProvider, - SourcebotProvider, GraphifyProvider, + SourcebotProvider, parseGbrainSearch, - recommendCodeProvider, - resolveCodeProvider, + parseGraphifyQuery, + parseSourcebotSearch, + readSelection, + setProvider, + setConsent, + hasConsent, + resolveSelectedProvider, RECOMMENDED_ORDER, } from "../lib/code-intelligence"; -describe("capability matrix invariants", () => { +describe("capability matrix", () => { test("every provider advertises the four required capabilities", () => { for (const p of [new GbrainProvider(), new SourcebotProvider(), new GraphifyProvider()]) { for (const cap of REQUIRED_CAPABILITIES) expect(p.has(cap)).toBe(true); } }); - test("GBrain advertises all seven (document axis)", () => { + test("only GBrain advertises the document ops; local flags are right", () => { const g = new GbrainProvider(); - for (const cap of ["add", "delete", "export"] as const) expect(g.has(cap)).toBe(true); expect(g.local).toBe(false); - }); + for (const cap of ["add", "delete", "export"] as const) expect(g.has(cap)).toBe(true); - test("Sourcebot is search-only: declines add/delete/export", () => { - const s = new SourcebotProvider(); + const s = new SourcebotProvider({ baseUrl: "http://localhost:3000" }); + expect(s.local).toBe(true); // loopback → content stays on machine expect(s.has("add")).toBe(false); - expect(s.has("delete")).toBe(false); - expect(s.has("export")).toBe(false); - expect(s.local).toBe(false); + expect(new SourcebotProvider({ baseUrl: "https://sb.example.com" }).local).toBe(false); + + const gf = new GraphifyProvider(); + expect(gf.local).toBe(true); + expect(gf.has("export")).toBe(true); + expect(gf.has("add")).toBe(false); }); - test("Graphify is local, exports, declines add/delete", () => { - const g = new GraphifyProvider(); - expect(g.local).toBe(true); - expect(g.has("export")).toBe(true); - expect(g.has("add")).toBe(false); - expect(g.has("delete")).toBe(false); - }); - - test("assertRequiredCapabilities rejects an incomplete provider", () => { - expect(() => assertRequiredCapabilities("sourcebot", new Set(["search"]))).toThrow(/missing required/); - }); - - test("CodeProviderError rejects an unknown failure code", () => { - // @ts-expect-error deliberately invalid code - expect(() => new CodeProviderError("NOPE", "x")).toThrow(/Unknown code-provider failure/); - }); -}); - -describe("optional ops decline with a typed failure", () => { - test("Sourcebot declines add/delete/export with CAPABILITY_UNSUPPORTED", async () => { - const s = new SourcebotProvider(); - await expect(s.add({ slug: "x", body: "y" })).rejects.toMatchObject({ code: "CAPABILITY_UNSUPPORTED" }); - await expect(s.delete("x")).rejects.toMatchObject({ code: "CAPABILITY_UNSUPPORTED" }); - await expect(s.export({ id: "x" })).rejects.toMatchObject({ code: "CAPABILITY_UNSUPPORTED" }); - }); - - test("Graphify advertises export → not CAPABILITY_UNSUPPORTED, but unwired in phase 1", async () => { - await expect(new GraphifyProvider().export({ id: "x" })).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); - }); -}); - -describe("phase-1 declaration adapters degrade to PROVIDER_UNAVAILABLE", () => { - test("Sourcebot.search is unwired until a transport lands", async () => { - await expect(new SourcebotProvider().search("q")).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); - }); - - test("empty query short-circuits to no hits (no throw)", async () => { - expect(await new SourcebotProvider().search(" ")).toEqual([]); - }); - - test("status is partial + unknown (no live probe yet)", async () => { - const s = await new GraphifyProvider().status({ id: "repo" }); - expect(s).toMatchObject({ id: "repo", state: "unknown", partial: true }); - }); -}); - -describe("egress consent gate", () => { - test("non-local registerSource without consent → PROVIDER_NOT_CONSENTED", async () => { - await expect(new SourcebotProvider().registerSource({ id: "repo", path: "/repo" })).rejects.toMatchObject({ - code: "PROVIDER_NOT_CONSENTED", - }); - }); - - test("local provider (Graphify) skips the egress gate (falls through to unwired)", async () => { - // Local means nothing leaves the machine, so consent is not required — it - // reaches the phase-1 PROVIDER_UNAVAILABLE, NOT a consent rejection. - await expect(new GraphifyProvider().registerSource({ id: "repo", path: "/repo" })).rejects.toMatchObject({ - code: "PROVIDER_UNAVAILABLE", - }); - }); -}); - -describe("parseGbrainSearch (text surface)", () => { - const sample = ["[0.91] slug/a -- snippet one", "banner", "[0.05] slug/b -- below floor"].join("\n"); - test("parses scored lines, applies floor + limit", () => { - expect(parseGbrainSearch(sample, 0.1, 10)).toEqual([ - { ref: "slug/a", score: 0.91, snippet: "snippet one", kind: "document" }, - ]); - expect(parseGbrainSearch(sample, 0.0, 1)).toHaveLength(1); - }); -}); - -describe("picker — recommend GBrain first", () => { test("RECOMMENDED_ORDER puts GBrain first", () => { expect(RECOMMENDED_ORDER[0]).toBe("gbrain"); expect([...RECOMMENDED_ORDER]).toEqual(["gbrain", "sourcebot", "graphify"]); }); +}); - test("GBrain resolves when usable", () => { - expect(recommendCodeProvider({ gbrainStatus: "ok" }).map((p) => p.id)).toEqual(["gbrain"]); - expect(resolveCodeProvider({ gbrainStatus: "ok" })?.id).toBe("gbrain"); +describe("parsers", () => { + test("parseGbrainSearch (text surface)", () => { + const hits = parseGbrainSearch("[0.91] slug/a -- one\nbanner\n[0.05] slug/b -- low", 0.1, 10); + expect(hits).toEqual([{ ref: "slug/a", score: 0.91, snippet: "one", kind: "document" }]); }); - test("timeout status counts as usable (engine slow, not absent)", () => { - expect(resolveCodeProvider({ gbrainStatus: "timeout" })?.id).toBe("gbrain"); + test("parseGraphifyQuery maps path-like lines to refs", () => { + const hits = parseGraphifyQuery("src/a.ts calls foo()\njust prose here", 10); + expect(hits[0]).toMatchObject({ ref: "src/a.ts", kind: "graph-node" }); + expect(hits[1]).toMatchObject({ ref: "graphify" }); }); - test("provider-OFF: GBrain down → resolveCodeProvider null", () => { - expect(recommendCodeProvider({ gbrainStatus: "no-cli" })).toEqual([]); - expect(resolveCodeProvider({ gbrainStatus: "no-cli" })).toBeNull(); - expect(resolveCodeProvider({ gbrainStatus: "missing-config" })).toBeNull(); + test("parseSourcebotSearch maps files to file:line hits, tolerates garbage", () => { + const payload = { + files: [{ fileName: { text: "src/x.ts" }, chunks: [{ content: "hit", matchRanges: [{ start: { lineNumber: 12 } }] }] }], + }; + expect(parseSourcebotSearch(payload, 10)).toEqual([{ ref: "src/x.ts:12", snippet: "hit", kind: "file" }]); + expect(parseSourcebotSearch("nope", 10)).toEqual([]); }); }); -describe("GBrain adapter end-to-end (fake shim on PATH)", () => { +describe("selection store + provider-OFF", () => { + let home: string; + let env: NodeJS.ProcessEnv; + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-home-")); + env = { ...process.env, GSTACK_HOME: home }; + }); + afterEach(() => fs.rmSync(home, { recursive: true, force: true })); + + test("no selection = provider-OFF (null)", () => { + expect(readSelection(env).provider).toBeNull(); + expect(resolveSelectedProvider({ env })).toBeNull(); + }); + + test("select persists and resolves the provider", () => { + setProvider("graphify", env); + expect(readSelection(env).provider).toBe("graphify"); + expect(resolveSelectedProvider({ env })?.id).toBe("graphify"); + }); + + test("consent is per-repo", () => { + const repo = path.join(home, "repoA"); + expect(hasConsent(repo, env)).toBe(false); + setConsent(repo, true, env); + expect(hasConsent(repo, env)).toBe(true); + expect(hasConsent(path.join(home, "repoB"), env)).toBe(false); + }); +}); + +describe("egress consent gate", () => { + test("GBrain (non-local) registerSource without consent → PROVIDER_NOT_CONSENTED", async () => { + await expect(new GbrainProvider().registerSource({ id: "code", path: "/repo" })).rejects.toMatchObject({ + code: "PROVIDER_NOT_CONSENTED", + }); + }); + + test("Graphify (local) is exempt from the egress gate", async () => { + // Local → no consent needed; it reaches the CLI (absent here → UNAVAILABLE), + // NOT a consent rejection. + await expect( + new GraphifyProvider({ env: { PATH: "/nonexistent" } }).registerSource({ id: "r", path: os.tmpdir() }), + ).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); + }); +}); + +describe("Graphify adapter (fake graphify shim on PATH)", () => { + let binDir: string; + let repo: string; + function env(): NodeJS.ProcessEnv { + return { PATH: `${binDir}:${process.env.PATH}` }; + } + beforeEach(() => { + binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gf-bin-")); + repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gf-repo-")); + }); + afterEach(() => { + fs.rmSync(binDir, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + }); + + test("index builds a graph and status reports ready + node count", async () => { + // Shim: `graphify .` writes graphify-out/graph.json in cwd; `graphify query` prints a hit line. + fs.writeFileSync( + path.join(binDir, "graphify"), + `#!/usr/bin/env bash +if [ "$1" = "query" ]; then echo "src/a.ts -> src/b.ts (calls)"; exit 0; fi +mkdir -p "$PWD/graphify-out" +echo '{"nodes":[1,2,3]}' > "$PWD/graphify-out/graph.json" +exit 0 +`, + { mode: 0o755 }, + ); + const gf = new GraphifyProvider({ root: repo, env: env() }); + const reg = await gf.registerSource({ id: "r", path: repo }); + expect(reg.state).toBe("ready"); + expect(reg.itemCount).toBe(3); + + const hits = await gf.search("what calls b", { source: repo }); + expect(hits[0].ref).toBe("src/a.ts"); + }); + + test("missing graphify CLI degrades to PROVIDER_UNAVAILABLE", async () => { + await expect( + new GraphifyProvider({ root: repo, env: { PATH: binDir } }).search("q"), + ).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); + }); +}); + +describe("Sourcebot adapter (injected fetch + temp config)", () => { + test("registerSource writes a local git connection to config.json", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-sb-")); + const configPath = path.join(dir, "config.json"); + const sb = new SourcebotProvider({ baseUrl: "http://localhost:3000", configPath }); + await sb.registerSource({ id: "myrepo", path: "/abs/repo" }); + const written = JSON.parse(fs.readFileSync(configPath, "utf-8")); + expect(written.connections.myrepo).toEqual({ type: "git", url: "file:///abs/repo" }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test("search POSTs /api/search and maps files to hits", async () => { + const calls: Array<{ url: string; body: unknown }> = []; + const fetchStub = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), body: JSON.parse(String(init.body)) }); + return new Response( + JSON.stringify({ files: [{ fileName: { text: "a.ts" }, chunks: [{ content: "x", matchRanges: [{ start: { lineNumber: 3 } }] }] }] }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + const sb = new SourcebotProvider({ baseUrl: "http://localhost:3000", fetch: fetchStub }); + const hits = await sb.search("foo", { limit: 5 }); + expect(calls[0].url).toBe("http://localhost:3000/api/search"); + expect((calls[0].body as { isRegexEnabled: boolean }).isRegexEnabled).toBe(true); + expect(hits).toEqual([{ ref: "a.ts:3", snippet: "x", kind: "file" }]); + }); + + test("unreachable server degrades to PROVIDER_UNAVAILABLE", async () => { + const fetchStub = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + await expect( + new SourcebotProvider({ baseUrl: "http://localhost:3999", fetch: fetchStub }).search("q"), + ).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); + }); + + test("registerSource without SOURCEBOT_CONFIG → PROVIDER_UNAVAILABLE", async () => { + const sb = new SourcebotProvider({ baseUrl: "http://localhost:3000", env: {} }); + await expect(sb.registerSource({ id: "r", path: "/x" })).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); + }); +}); + +describe("GBrain adapter end-to-end (fake gbrain shim on PATH)", () => { let binDir: string; let homeDir: string; - - function writeShim(body: string): void { - const p = path.join(binDir, "gbrain"); - fs.writeFileSync(p, body, { mode: 0o755 }); - fs.chmodSync(p, 0o755); - } function env(): NodeJS.ProcessEnv { return { PATH: `${binDir}:${process.env.PATH}`, HOME: homeDir }; } - beforeEach(() => { - binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gbrain-shim-")); - homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gbrain-home-")); + binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-bin-")); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-home-")); }); afterEach(() => { fs.rmSync(binDir, { recursive: true, force: true }); @@ -169,50 +231,25 @@ describe("GBrain adapter end-to-end (fake shim on PATH)", () => { }); test("search scopes to source and parses hits", async () => { - writeShim(`#!/usr/bin/env bash + fs.writeFileSync( + path.join(binDir, "gbrain"), + `#!/usr/bin/env bash if [ "$1" = "search" ]; then - if printf '%s ' "$@" | grep -q -- "--source code"; then - echo "[0.88] src/x.ts -- match in code source" - else - echo "[0.10] wrong -- unscoped" - fi + if printf '%s ' "$@" | grep -q -- "--source code"; then echo "[0.88] src/x.ts -- match"; else echo "[0.10] wrong -- unscoped"; fi exit 0 fi exit 1 -`); +`, + { mode: 0o755 }, + ); const hits = await new GbrainProvider().search("where", { env: env(), source: "code" }); expect(hits).toHaveLength(1); expect(hits[0].ref).toBe("src/x.ts"); }); - test("status(source) reports ready + page_count", async () => { - writeShim(`#!/usr/bin/env bash -if [ "$1" = "sources" ]; then echo '{"sources":[{"id":"code","local_path":"/repo","page_count":42}]}'; exit 0; fi -exit 1 -`); - const s = await new GbrainProvider().status({ id: "code" }, { env: env() }); - expect(s.state).toBe("ready"); - expect(s.itemCount).toBe(42); - }); - - test("status(source) reports absent for an unregistered id", async () => { - writeShim(`#!/usr/bin/env bash -if [ "$1" = "sources" ]; then echo '{"sources":[]}'; exit 0; fi -exit 1 -`); - expect((await new GbrainProvider().status({ id: "nope" }, { env: env() })).state).toBe("absent"); - }); - test("missing CLI degrades to PROVIDER_UNAVAILABLE", async () => { - // No shim written; PATH points only at an empty dir. await expect( new GbrainProvider().search("q", { env: { PATH: binDir, HOME: homeDir } }), ).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); }); - - test("registerSource requires egress consent (GBrain is non-local)", async () => { - await expect( - new GbrainProvider().registerSource({ id: "code", path: "/repo" }, { env: env() }), - ).rejects.toMatchObject({ code: "PROVIDER_NOT_CONSENTED" }); - }); });