From 19cb4062c2d0f8ee5d1b2750ad831ce3f50de0b8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 15 Aug 2026 07:36:58 -0700 Subject: [PATCH] =?UTF-8?q?test:=20final=20coverage=20pass=20=E2=80=94=20C?= =?UTF-8?q?LI=20rendering,=20revert=20traps,=20keychain=20probe,=20gbrain?= =?UTF-8?q?=20doc=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-directed third generation pass closes the audit's remaining tail: the code-intelligence CLI's options/status/suggest surfaces get behavioral coverage through the fake-shim chain; brain-context-load gains an argv-logging trap that goes red if anyone reverts the memoized PATH scan back to the spawn probe (receipt: simulated revert failed exactly these tests); the darwin Keychain auth branch (#1890) gets its first free-tier tests via a PATH-shimmed security binary; and the gbrain add/delete/export ops are pinned (body piped byte-for-byte, receipt sha256, stdin-EOF prompt guard, PROVIDER_UNAVAILABLE degradation) — retiring their TODOS entry. --- TODOS.md | 12 -- test/claude-provider-keychain.test.ts | 151 ++++++++++++++ test/code-intelligence.test.ts | 260 +++++++++++++++++++++++++ test/gstack-brain-context-load.test.ts | 83 +++++++- 4 files changed, 493 insertions(+), 13 deletions(-) create mode 100644 test/claude-provider-keychain.test.ts diff --git a/TODOS.md b/TODOS.md index 73bd810c2..77e712ef1 100644 --- a/TODOS.md +++ b/TODOS.md @@ -84,18 +84,6 @@ debug of what the child's preamble actually echoes. **Why:** every red periodic run costs triage time; two of these have burned three triage passes across two releases. **Effort:** M. **Priority:** P2. -### P3: gbrain-adapter add/delete/export behavioral coverage - -**What:** Extend test/code-intelligence.test.ts's fake-gbrain shim to pin the -three untested capability-advertised ops: add() pipes the body to `put ` -and writes a receipt with the body's sha256, delete() survives the stdin-EOF -prompt guard, export() returns stdout, and each degrades to -PROVIDER_UNAVAILABLE when the CLI is absent. - -**Why:** Shipped public ops with zero exercise; the harness to test them -cheaply already exists. **Effort:** S. **Priority:** P3. - - ### P1: #1882 — portable skill-install prefix (non-`gstack` install dirs break silently) **What:** Every generated SKILL.md hardcodes the literal `~/.claude/skills/gstack/...` diff --git a/test/claude-provider-keychain.test.ts b/test/claude-provider-keychain.test.ts new file mode 100644 index 000000000..9aff4be61 --- /dev/null +++ b/test/claude-provider-keychain.test.ts @@ -0,0 +1,151 @@ +/** + * Free unit tests for the ClaudeAdapter auth sniff in + * test/helpers/providers/claude.ts — specifically the macOS Keychain branch + * (#1890): the default subscription install stores OAuth under the + * generic-password service "Claude Code-credentials" and never writes + * ~/.claude/.credentials.json, so availability must consult + * `security find-generic-password -s "Claude Code-credentials"` when neither + * the creds file nor ANTHROPIC_API_KEY exists. + * + * The `security` spawn is darwin-gated in the adapter (the probe only runs + * when process.platform === 'darwin'), so the keychain-branch tests skip + * elsewhere; the creds-file / env-key / no-auth verdicts run on every + * platform. + * + * Harness note: available() spawns `security` without an explicit `env:`, and + * Bun resolves such spawns against the PROCESS STARTUP env — mutating + * process.env.PATH in-test cannot shadow the real /usr/bin/security (verified; + * same Bun property lib/gbrain-exec.ts documents for DATABASE_URL). So each + * case runs available() in a child `bun -e` whose env (and PATH shim) the test + * fully controls; the fake `security` logs its argv, so the operator's real + * Keychain is never consulted and a logged-in machine can't false-pass the + * "no auth" cases. cwd is the fake home so Bun doesn't autoload the repo .env + * (which defines ANTHROPIC_API_KEY). + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const ADAPTER = path.join(import.meta.dir, "helpers", "providers", "claude.ts"); + +describe("ClaudeAdapter.available() — auth sniff incl. macOS Keychain branch (#1890)", () => { + let fakeHome: string; + let shimDir: string; + let securityLog: string; + + beforeEach(() => { + fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-home-")); + shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-bin-")); + securityLog = path.join(shimDir, "security-argv.log"); + }); + + afterEach(() => { + fs.rmSync(fakeHome, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + }); + + /** Fake `security` that logs its argv and exits with `exitCode`. */ + function writeFakeSecurity(exitCode: number): void { + fs.writeFileSync( + path.join(shimDir, "security"), + `#!/bin/sh +printf '%s\\n' "$*" >> "${securityLog}" +exit ${exitCode} +`, + { mode: 0o755 }, + ); + } + + /** Run `new ClaudeAdapter().available()` in a child bun with a controlled env. */ + function runAvailable(opts: { anthropicKey?: string; bareShimPath?: boolean } = {}): { ok: boolean; reason?: string } { + const env: Record = { + ...process.env, + // Shim dir first so a fake `security` shadows /usr/bin/security; the + // bare variant drops the inherited PATH entirely (no `claude` findable). + PATH: opts.bareShimPath ? shimDir : `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`, + // os.homedir() honors $HOME (POSIX) / %USERPROFILE% (Windows), so the + // adapter's ~/.claude/.credentials.json check reads the fake home. + HOME: fakeHome, + USERPROFILE: fakeHome, + // Absolute-path override satisfies resolveClaudeCommand() without a real + // claude install — available() never spawns it, only resolves it. The + // bare-PATH case clears it to exercise the not-found branch. + GSTACK_CLAUDE_BIN: opts.bareShimPath ? undefined : path.join(shimDir, "claude"), + CLAUDE_BIN: undefined, + GSTACK_CLAUDE_BIN_ARGS: undefined, + CLAUDE_BIN_ARGS: undefined, + ANTHROPIC_API_KEY: opts.anthropicKey, + }; + for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k]; + const driver = `const { ClaudeAdapter } = await import(${JSON.stringify(ADAPTER)}); +const check = await new ClaudeAdapter().available(); +console.log(JSON.stringify(check));`; + // process.execPath (absolute bun binary): the bare-PATH case strips the + // inherited PATH, so a name-based "bun" lookup would ENOENT. + const res = spawnSync(process.execPath, ["-e", driver], { + encoding: "utf-8", + timeout: 30_000, + cwd: fakeHome, // no .env here — the repo root's would inject ANTHROPIC_API_KEY + env: env as Record, + }); + if (res.status !== 0) throw new Error(`driver failed (${res.status}): ${res.stderr}`); + return JSON.parse(res.stdout.trim()) as { ok: boolean; reason?: string }; + } + + test("creds file present → available, and the Keychain is never consulted", () => { + fs.mkdirSync(path.join(fakeHome, ".claude"), { recursive: true }); + fs.writeFileSync(path.join(fakeHome, ".claude", ".credentials.json"), "{}"); + // A fake security that would report NOT FOUND — if the probe ran anyway, + // ok would still be true, so the no-spawn pin is the argv log staying empty. + writeFakeSecurity(1); + const check = runAvailable(); + expect(check.ok).toBe(true); + expect(fs.existsSync(securityLog)).toBe(false); + }); + + test("ANTHROPIC_API_KEY set → available without creds file or Keychain probe", () => { + writeFakeSecurity(1); + const check = runAvailable({ anthropicKey: "sk-ant-test-not-a-real-key" }); + expect(check.ok).toBe(true); + expect(fs.existsSync(securityLog)).toBe(false); + }); + + test("darwin: Keychain hit (security exit 0) → available, probed with the exact service name", () => { + if (process.platform !== "darwin") return; // keychain branch is darwin-gated in the adapter + writeFakeSecurity(0); + const check = runAvailable(); + expect(check.ok).toBe(true); + // Exactly one metadata-only probe, against the service subscription installs use. + const argv = fs.readFileSync(securityLog, "utf-8").trim().split("\n"); + expect(argv).toEqual(["find-generic-password -s Claude Code-credentials"]); + expect(argv[0]).not.toContain("-w"); // never reads the secret itself + }); + + test("darwin: Keychain miss (security exit 1) → not available with the no-auth reason", () => { + if (process.platform !== "darwin") return; + writeFakeSecurity(1); + const check = runAvailable(); + expect(check.ok).toBe(false); + expect(check.reason).toContain("No Claude auth found"); + // The probe DID run (this is the miss path, not a skipped probe). + expect(fs.readFileSync(securityLog, "utf-8")).toContain("find-generic-password"); + }); + + test("non-darwin: no creds file + no env key → not available (no Keychain to consult)", () => { + if (process.platform === "darwin") return; // darwin covered by the shimmed miss case above + const check = runAvailable(); + expect(check.ok).toBe(false); + expect(check.reason).toContain("No Claude auth found"); + }); + + test("claude CLI unresolvable → not-found reason, before any auth sniff", () => { + writeFakeSecurity(0); // even a keychain HIT can't rescue a missing binary + const check = runAvailable({ bareShimPath: true }); + expect(check.ok).toBe(false); + expect(check.reason).toContain("claude CLI not found on PATH"); + expect(fs.existsSync(securityLog)).toBe(false); // returned before the auth sniff + }); +}); diff --git a/test/code-intelligence.test.ts b/test/code-intelligence.test.ts index 255c2d712..aac388f71 100644 --- a/test/code-intelligence.test.ts +++ b/test/code-intelligence.test.ts @@ -33,6 +33,7 @@ import { RECOMMENDED_ORDER, shouldOfferIndexing, trackedFileCount, + LARGE_REPO_FILE_THRESHOLD, } from "../lib/code-intelligence"; describe("capability matrix", () => { @@ -807,6 +808,265 @@ describe("consent CLI requires an explicit yes|no (never defaults to granted)", }); }); +// ── GBrain document ops (add/delete/export) — behavioral pins over the same +// fake-gbrain shim harness. add() must pipe the body to `put ` on stdin +// and receipt the body's sha256 BEFORE the send; delete() must survive a +// confirmation prompt via the stdin-EOF guard; export() must return the CLI's +// stdout verbatim; and each must degrade to PROVIDER_UNAVAILABLE (not a hard +// error) when the CLI is absent. +describe("GBrain document ops (add/delete/export) via fake gbrain shim", () => { + let binDir: string; + let home: string; + let argvLog: string; + let stdinLog: string; + function env(): NodeJS.ProcessEnv { + return { PATH: `${binDir}:${process.env.PATH}`, HOME: home, GSTACK_HOME: home }; + } + function ledgerLines(): Array> { + const p = path.join(home, "security", "egress.jsonl"); + if (!fs.existsSync(p)) return []; + return fs + .readFileSync(p, "utf-8") + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l) as Record); + } + beforeEach(() => { + binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-docops-bin-")); + home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-docops-home-")); + argvLog = path.join(home, "gbrain-argv.log"); + stdinLog = path.join(home, "gbrain-stdin.log"); + // Shim logs every argv (so "refused BEFORE the subprocess" is provable), + // captures put's stdin byte-for-byte, and blocks delete on a confirmation + // read the way a real prompt would — the adapter's stdin-EOF guard is the + // only thing keeping that from hanging until the op timeout. + fs.writeFileSync( + path.join(binDir, "gbrain"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "${argvLog}" +case "$1" in + put) cat > "${stdinLog}"; echo "stored $2"; exit 0;; + delete) read -r _confirm; echo "deleted $2"; exit 0;; + export) printf 'line-one\\nline-two\\n'; exit 0;; +esac +exit 1 +`, + { mode: 0o755 }, + ); + }); + afterEach(() => { + fs.rmSync(binDir, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + + test("add() pipes the document body to `put ` on stdin and receipts the body's sha256", async () => { + const body = "# curated memory\nthe exact body bytes\n"; + const s = await new GbrainProvider().add({ slug: "my-doc", body }, { env: env(), consented: true }); + expect(s).toEqual({ id: "my-doc", state: "ready" }); + expect(fs.readFileSync(argvLog, "utf-8").trim()).toBe("put my-doc"); + // The body reached gbrain via stdin, byte-for-byte (not argv, not a temp file). + expect(fs.readFileSync(stdinLog, "utf-8")).toBe(body); + const lines = ledgerLines(); + expect(lines.length).toBe(1); + expect(lines[0].sink).toBe("gbrain"); + expect(String(lines[0].payload_class)).toContain("document-body"); + expect(String(lines[0].consent)).toContain("consented=true"); + expect(lines[0].sha256).toBe(sha256Hex(body)); + expect(lines[0].bytes).toBe(Buffer.byteLength(body)); + }); + + test("add() asserts consent: unconsented add is refused BEFORE the subprocess runs, no receipt", async () => { + await expect(new GbrainProvider().add({ slug: "s", body: "secret body" }, { env: env() })).rejects.toMatchObject({ + code: "PROVIDER_NOT_CONSENTED", + }); + expect(fs.existsSync(argvLog)).toBe(false); // the body never left the machine + expect(ledgerLines()).toEqual([]); + }); + + test("delete() survives a confirmation prompt via the stdin-EOF guard (never hangs)", async () => { + // Shim blocks on `read` — with the adapter's input:"" the read gets EOF + // instantly. A regression to inherited/open stdin fails here at the 5s + // op timeout (PROVIDER_TIMEOUT) instead of returning state=absent. + const s = await new GbrainProvider().delete("stale-doc", { env: env(), timeout: 5_000 }); + expect(s).toEqual({ id: "stale-doc", state: "absent" }); + expect(fs.readFileSync(argvLog, "utf-8").trim()).toBe("delete stale-doc"); + }); + + test("export() returns the CLI's stdout verbatim (multi-line, unparsed)", async () => { + const body = await new GbrainProvider().export({ id: "code" }, { env: env(), consented: true }); + expect(body).toBe("line-one\nline-two\n"); + expect(fs.readFileSync(argvLog, "utf-8").trim()).toBe("export"); + }); + + test("add/delete/export each degrade to PROVIDER_UNAVAILABLE when the CLI is absent", async () => { + const missing = { PATH: os.tmpdir(), HOME: home, GSTACK_HOME: home }; + const g = new GbrainProvider(); + await expect(g.add({ slug: "s", body: "b" }, { env: missing, consented: true })).rejects.toMatchObject({ + code: "PROVIDER_UNAVAILABLE", + }); + await expect(g.delete("s", { env: missing })).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); + await expect(g.export({ id: "code" }, { env: missing, consented: true })).rejects.toMatchObject({ + code: "PROVIDER_UNAVAILABLE", + }); + }); +}); + +// ── CLI rendering surfaces: options / status / suggest --json ─────────────── +// The human/agent-facing renderers had zero reach: pin that `options` lists +// the three providers with availability rows (GBrain first), `status` renders +// the persisted selection + per-provider availability, and `suggest --json` +// emits the machine-readable offer/reason contract. +describe("CLI rendering (options / status / suggest --json)", () => { + const CLI = path.join(import.meta.dir, "..", "bin", "gstack-code-intelligence"); + let home: string; + let shimDir: string; + let env: NodeJS.ProcessEnv; + + function runCli(...args: string[]) { + return spawnSync("bun", [CLI, ...args], { + encoding: "utf-8", + timeout: 30_000, + env: env as Record, + }); + } + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-render-")); + shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-render-bin-")); + // Fake gbrain satisfies the FULL localEngineStatus probe chain (--version + // resolution + `sources list --json` liveness) so the GBrain row is + // deterministically available; config.json lives in the temp GBRAIN_HOME + // so the operator's real ~/.gbrain is never read. + fs.writeFileSync( + path.join(shimDir, "gbrain"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "gbrain 0.42.0"; exit 0; fi +if [ "$1" = "sources" ]; then echo '{"sources":[]}'; exit 0; fi +exit 1 +`, + { mode: 0o755 }, + ); + const gbrainHome = path.join(home, ".gbrain"); + fs.mkdirSync(gbrainHome, { recursive: true }); + fs.writeFileSync(path.join(gbrainHome, "config.json"), JSON.stringify({ engine: "pglite" })); + env = { + ...process.env, + GSTACK_HOME: home, + HOME: home, + GBRAIN_HOME: gbrainHome, + PATH: `${shimDir}:${process.env.PATH}`, + // Dead loopback port → the Sourcebot probe fails fast + deterministically + // (connection refused) instead of poking whatever operator dev server + // happens to sit on localhost:3000. + SOURCEBOT_URL: "http://127.0.0.1:1", + }; + }); + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + }); + + function makeRepoWithFiles(count: number): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-suggest-")); + Bun.spawnSync(["git", "init", "-q", repo]); + for (let i = 0; i < count; i++) fs.writeFileSync(path.join(repo, `f${i}.ts`), "x\n"); + Bun.spawnSync(["git", "-C", repo, "add", "-A"]); + return repo; + } + + test("options lists the three providers with availability rows, GBrain first", () => { + const res = runCli("options"); + expect(res.status).toBe(0); + expect(res.stdout).toContain("Code-intelligence providers"); + // GBrain row: recommended marker + deterministically available via the shim. + expect(res.stdout).toMatch(/\* GBrain\s+\[available\]/); + expect(res.stdout).toContain("gbrain engine: ok"); + // Sourcebot row: dead loopback port → not available, probe detail rendered. + expect(res.stdout).toMatch(/Sourcebot\s+\[not available\]/); + expect(res.stdout).toContain("unreachable at http://127.0.0.1:1"); + // Graphify row renders an availability mark either way (the operator + // machine may or may not have graphify installed). + expect(res.stdout).toMatch(/Graphify\s+\[(available|not available)\]/); + // Rows come out in recommendation order (GBrain → Sourcebot → Graphify). + const at = ["GBrain", "Sourcebot", "Graphify"].map((s) => res.stdout.indexOf(s)); + expect(at[0]).toBeGreaterThan(-1); + expect(at[0]).toBeLessThan(at[1]); + expect(at[1]).toBeLessThan(at[2]); + expect(res.stdout).toContain("select "); + }); + + test("status renders the provider-OFF selection and per-provider availability", () => { + const res = runCli("status"); + expect(res.status).toBe(0); + expect(res.stdout).toContain("selected: none (grep / file-only fallback)"); + expect(res.stdout).toContain("GBrain: available (gbrain engine: ok)"); + expect(res.stdout).toContain("Sourcebot: unavailable (unreachable at http://127.0.0.1:1)"); + expect(res.stdout).toMatch(/Graphify: (available|unavailable)/); + }); + + test("status renders a persisted selection", () => { + setProvider("gbrain", env); + const res = runCli("status"); + expect(res.status).toBe(0); + expect(res.stdout).toContain("selected: gbrain"); + }); + + test("suggest --json: small repo → machine-readable offer:false / reason:small-repo", () => { + const repo = makeRepoWithFiles(3); + try { + const res = runCli("suggest", repo, "--json"); + expect(res.status).toBe(0); + const parsed = JSON.parse(res.stdout) as Record; + expect(parsed).toEqual({ + offer: false, + reason: "small-repo", + fileCount: 3, + threshold: LARGE_REPO_FILE_THRESHOLD, + repoPath: repo, + }); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test("suggest --json: large repo → offer:true with self-contained provider options", () => { + const repo = makeRepoWithFiles(LARGE_REPO_FILE_THRESHOLD); + try { + const res = runCli("suggest", repo, "--json"); + expect(res.status).toBe(0); + const parsed = JSON.parse(res.stdout) as { + offer: boolean; + reason: string; + fileCount: number; + options: Array<{ id: string; label: string; reason: string; local: boolean; available: boolean; detail: string }>; + }; + expect(parsed.offer).toBe(true); + expect(parsed.reason).toBe("large-repo"); + expect(parsed.fileCount).toBe(LARGE_REPO_FILE_THRESHOLD); + // The offer is self-contained: every provider option carries the fields + // an agent needs to render the question without a second CLI call. + expect(parsed.options.map((o) => o.id)).toEqual(["gbrain", "sourcebot", "graphify"]); + for (const o of parsed.options) { + expect(typeof o.label).toBe("string"); + expect(typeof o.reason).toBe("string"); + expect(typeof o.local).toBe("boolean"); + expect(typeof o.available).toBe("boolean"); + expect(typeof o.detail).toBe("string"); + } + const gbrain = parsed.options[0]; + expect(gbrain.label).toBe("GBrain"); + expect(gbrain.local).toBe(false); + expect(gbrain.available).toBe(true); + expect(gbrain.detail).toContain("gbrain engine:"); + const sourcebot = parsed.options[1]; + expect(sourcebot.local).toBe(true); // loopback SOURCEBOT_URL + expect(sourcebot.available).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); +}); + // ── picker probes: concurrent, short-timeout, never a 30s CLI stall ───────── describe("detectAvailable probes fast even when Sourcebot is a dead non-loopback host", () => { test("rows come back in recommendation order, well under the old 30s stall", async () => { diff --git a/test/gstack-brain-context-load.test.ts b/test/gstack-brain-context-load.test.ts index e452e8d4f..b601cd798 100644 --- a/test/gstack-brain-context-load.test.ts +++ b/test/gstack-brain-context-load.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect } from "bun:test"; -import { chmodSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs"; import { tmpdir } from "os"; import { delimiter, join } from "path"; import { spawnSync } from "child_process"; @@ -52,6 +52,38 @@ fi chmodSync(fakeBin, 0o755); } +/** + * Like writeFakeGbrain, but every invocation appends its argv to `logFile`. + * Still answers `--version` successfully ON PURPOSE: a revert from the + * memoized PATH stat scan back to the old `gbrain --version` spawn probe + * would pass every non-logging test — only the argv log catches it. + */ +function writeLoggingGbrain(binDir: string, logFile: string): void { + if (process.platform === "win32") { + writeFileSync( + join(binDir, "gbrain.cmd"), + `@echo off\r\necho %* >> "${logFile}"\r\nif "%1"=="--version" (\r\n echo gbrain 0.test\r\n) else (\r\n echo fake gbrain %*\r\n)\r\n`, + "utf-8", + ); + return; + } + + const fakeBin = join(binDir, "gbrain"); + writeFileSync( + fakeBin, + `#!/bin/sh +printf '%s\\n' "$*" >> "${logFile}" +if [ "$1" = "--version" ]; then + echo "gbrain 0.test" +else + echo "fake gbrain $*" +fi +`, + "utf-8", + ); + chmodSync(fakeBin, 0o755); +} + function prependPath(binDir: string): Record { const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH"; const currentPath = process.env[pathKey] || ""; @@ -295,6 +327,55 @@ gbrain: } }); + it("gbrain detection never spawns gbrain — stat-based PATH scan, not a `--version` probe (revert trap)", () => { + // The fix replaced a per-query `gbrain --version` spawn probe with a + // memoized PATH stat scan. The plain writeFakeGbrain shim still ANSWERS + // --version, so a revert to the spawn probe passes every other test in + // this file. This fake logs its argv: detection must invoke gbrain zero + // times, so the only invocations are the 3 default-manifest list_pages + // queries — a revert adds `--version` lines (and re-probing adds one per + // query) and fails exactly here. + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const binDir = join(dir, "bin"); + mkdirSync(binDir); + const logFile = join(dir, "gbrain-argv.log"); + writeLoggingGbrain(binDir, logFile); + + try { + const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], prependPath(binDir)); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("queries=3"); + const invocations = readFileSync(logFile, "utf-8").split("\n").filter(Boolean); + expect(invocations.some((argv) => argv.includes("--version"))).toBe(false); + // Exactly the 3 real queries — no extra availability spawns of any shape. + expect(invocations).toHaveLength(3); + for (const argv of invocations) expect(argv.startsWith("list_pages")).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("availability survives a 1ms query budget — detection is not subject to GSTACK_BRAIN_TIMEOUT_MS", () => { + // The spawn probe ran under the same MCP_TIMEOUT_MS budget as the queries, + // so a cold spawn slower than the budget misreported gbrain as MISSING. + // With the stat scan, a 1ms budget kills the queries themselves (SKIP) + // but detection still sees the CLI — "gbrain CLI missing" must not appear. + const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-")); + const binDir = join(dir, "bin"); + mkdirSync(binDir); + writeFakeGbrain(binDir); + + try { + const env = { ...prependPath(binDir), GSTACK_BRAIN_TIMEOUT_MS: "1" }; + const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], env); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("SKIP"); + expect(r.stderr).not.toContain("gbrain CLI missing"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("vector + list queries still complete (with SKIP) when gbrain CLI is missing", () => { // We can't easily un-install gbrain; rely on the helper's own missing-binary // detection. The default manifest uses kind: list which calls gbrain. If