From fc83fdbc5e2bb924a764c27cf48d0df7b0856aa0 Mon Sep 17 00:00:00 2001 From: Penelope Ultima Date: Tue, 19 May 2026 12:14:35 -0700 Subject: [PATCH] fix: sourceLocalPath handles wrapped {sources:[...]} shape from gbrain v0.18.0+ `gbrain sources list --json` has returned `{sources: [...]}` since v0.18.0 (commit 90c5d93, "multi-source brains"). `sourceLocalPath()` typed the response as a raw `Array<...>` and called `.find()` directly, crashing `/sync-gbrain --full` with: `list.find is not a function`. The bug only surfaces on full reindex (`runCodeImport` is the only caller) so the cheap incremental path didn't hit it. Patch is defensive against both shapes so the helper works on any gbrain version: wrapped (v0.18.0+) or raw array (older). Test fixture audit: the existing 3 tests for sourceLocalPath all used the raw-array shape, which is why CI didn't catch the bug. Added matching tests for the live wrapped shape and kept the raw-array tests for back-compat coverage. 5 tests pass; pre-fix code would have failed the two new wrapped-shape tests. Closes #1609 Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-gbrain-sync.ts | 13 ++++++++----- test/gstack-gbrain-sync.test.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 61d9e677f7..b8e4d2848a 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -289,11 +289,14 @@ function gbrainSupportsSourcesRename(env?: NodeJS.ProcessEnv): boolean { * helper can be exercised without a real gbrain CLI. */ export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): string | null { - const list = execGbrainJson>( - ["sources", "list", "--json"], - { baseEnv: env }, - ); - if (!list) return null; + const raw = execGbrainJson< + | Array<{ id: string; local_path?: string }> + | { sources?: Array<{ id: string; local_path?: string }> } + >(["sources", "list", "--json"], { baseEnv: env }); + if (!raw) return null; + // gbrain v0.18.0+ returns {sources: [...]}; older versions returned a raw array. + // Defensive against both shapes so this works on any gbrain version. + const list = Array.isArray(raw) ? raw : raw.sources ?? []; const found = list.find((s) => s.id === sourceId); return found?.local_path ?? null; } diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 0f1edec214..0fc710b3a1 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -812,7 +812,24 @@ describe("sourceLocalPath", () => { rmSync(bindir, { recursive: true, force: true }); }); - it("returns local_path when the source exists", () => { + // gbrain v0.18.0+ returns {sources: [...]}; this is the live contract. + it("returns local_path when the source exists (gbrain v0.18.0+ wrapped shape)", () => { + makeShim(bindir, { + "sources list --json": { + stdout: JSON.stringify({ + sources: [ + { id: "other-source", local_path: "/x" }, + { id: "target-id", local_path: "/repo/match" }, + ], + }), + }, + }); + expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe("/repo/match"); + }); + + // Pre-v0.18.0 gbrain returned a raw array. Keep back-compat so the helper + // works regardless of which gbrain the user has on PATH. + it("returns local_path when the source exists (pre-v0.18.0 raw-array shape)", () => { makeShim(bindir, { "sources list --json": { stdout: JSON.stringify([ @@ -824,7 +841,14 @@ describe("sourceLocalPath", () => { expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe("/repo/match"); }); - it("returns null when the source is missing", () => { + it("returns null when the source is missing (wrapped shape)", () => { + makeShim(bindir, { + "sources list --json": { stdout: JSON.stringify({ sources: [] }) }, + }); + expect(sourceLocalPath("missing-id", envWithBindir(bindir))).toBeNull(); + }); + + it("returns null when the source is missing (raw-array shape)", () => { makeShim(bindir, { "sources list --json": { stdout: "[]" }, });