@bragi-gmbh/codebus 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/bin/codebus.js +2 -0
  2. package/dist/binding-guard.js +97 -0
  3. package/dist/binding-guard.js.map +1 -0
  4. package/dist/client.js +94 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands/ack.js +47 -0
  7. package/dist/commands/ack.js.map +1 -0
  8. package/dist/commands/archive.js +21 -0
  9. package/dist/commands/archive.js.map +1 -0
  10. package/dist/commands/as.js +299 -0
  11. package/dist/commands/as.js.map +1 -0
  12. package/dist/commands/ask.js +189 -0
  13. package/dist/commands/ask.js.map +1 -0
  14. package/dist/commands/auth.js +33 -0
  15. package/dist/commands/auth.js.map +1 -0
  16. package/dist/commands/checkin.js +235 -0
  17. package/dist/commands/checkin.js.map +1 -0
  18. package/dist/commands/decision.js +52 -0
  19. package/dist/commands/decision.js.map +1 -0
  20. package/dist/commands/digest.js +117 -0
  21. package/dist/commands/digest.js.map +1 -0
  22. package/dist/commands/doctor.js +106 -0
  23. package/dist/commands/doctor.js.map +1 -0
  24. package/dist/commands/factory.js +191 -0
  25. package/dist/commands/factory.js.map +1 -0
  26. package/dist/commands/github-login.js +205 -0
  27. package/dist/commands/github-login.js.map +1 -0
  28. package/dist/commands/graph.js +88 -0
  29. package/dist/commands/graph.js.map +1 -0
  30. package/dist/commands/health.js +34 -0
  31. package/dist/commands/health.js.map +1 -0
  32. package/dist/commands/inbox.js +83 -0
  33. package/dist/commands/inbox.js.map +1 -0
  34. package/dist/commands/ingest.js +553 -0
  35. package/dist/commands/ingest.js.map +1 -0
  36. package/dist/commands/init.js +97 -0
  37. package/dist/commands/init.js.map +1 -0
  38. package/dist/commands/install.js +520 -0
  39. package/dist/commands/install.js.map +1 -0
  40. package/dist/commands/invite.js +152 -0
  41. package/dist/commands/invite.js.map +1 -0
  42. package/dist/commands/jira.js +237 -0
  43. package/dist/commands/jira.js.map +1 -0
  44. package/dist/commands/join.js +96 -0
  45. package/dist/commands/join.js.map +1 -0
  46. package/dist/commands/links.js +66 -0
  47. package/dist/commands/links.js.map +1 -0
  48. package/dist/commands/login.js +84 -0
  49. package/dist/commands/login.js.map +1 -0
  50. package/dist/commands/morning.js +121 -0
  51. package/dist/commands/morning.js.map +1 -0
  52. package/dist/commands/next.js +66 -0
  53. package/dist/commands/next.js.map +1 -0
  54. package/dist/commands/onboarding.js +109 -0
  55. package/dist/commands/onboarding.js.map +1 -0
  56. package/dist/commands/pack.js +276 -0
  57. package/dist/commands/pack.js.map +1 -0
  58. package/dist/commands/plan-gh.js +425 -0
  59. package/dist/commands/plan-gh.js.map +1 -0
  60. package/dist/commands/plan.js +465 -0
  61. package/dist/commands/plan.js.map +1 -0
  62. package/dist/commands/pr.js +88 -0
  63. package/dist/commands/pr.js.map +1 -0
  64. package/dist/commands/question.js +142 -0
  65. package/dist/commands/question.js.map +1 -0
  66. package/dist/commands/read.js +84 -0
  67. package/dist/commands/read.js.map +1 -0
  68. package/dist/commands/refs.js +357 -0
  69. package/dist/commands/refs.js.map +1 -0
  70. package/dist/commands/reply.js +38 -0
  71. package/dist/commands/reply.js.map +1 -0
  72. package/dist/commands/repo.js +1198 -0
  73. package/dist/commands/repo.js.map +1 -0
  74. package/dist/commands/report.js +41 -0
  75. package/dist/commands/report.js.map +1 -0
  76. package/dist/commands/resolve-refs.js +21 -0
  77. package/dist/commands/resolve-refs.js.map +1 -0
  78. package/dist/commands/resolve.js +93 -0
  79. package/dist/commands/resolve.js.map +1 -0
  80. package/dist/commands/send.js +168 -0
  81. package/dist/commands/send.js.map +1 -0
  82. package/dist/commands/sent.js +22 -0
  83. package/dist/commands/sent.js.map +1 -0
  84. package/dist/commands/session.js +426 -0
  85. package/dist/commands/session.js.map +1 -0
  86. package/dist/commands/statusline.js +43 -0
  87. package/dist/commands/statusline.js.map +1 -0
  88. package/dist/commands/task.js +140 -0
  89. package/dist/commands/task.js.map +1 -0
  90. package/dist/commands/tick.js +233 -0
  91. package/dist/commands/tick.js.map +1 -0
  92. package/dist/commands/upgrade.js +76 -0
  93. package/dist/commands/upgrade.js.map +1 -0
  94. package/dist/commands/watch.js +169 -0
  95. package/dist/commands/watch.js.map +1 -0
  96. package/dist/commands/whoami.js +27 -0
  97. package/dist/commands/whoami.js.map +1 -0
  98. package/dist/config.js +103 -0
  99. package/dist/config.js.map +1 -0
  100. package/dist/directives.js +102 -0
  101. package/dist/directives.js.map +1 -0
  102. package/dist/errors.js +110 -0
  103. package/dist/errors.js.map +1 -0
  104. package/dist/git-hooks.js +116 -0
  105. package/dist/git-hooks.js.map +1 -0
  106. package/dist/graph-bootstrap.js +95 -0
  107. package/dist/graph-bootstrap.js.map +1 -0
  108. package/dist/graph-remote.js +889 -0
  109. package/dist/graph-remote.js.map +1 -0
  110. package/dist/graph.js +335 -0
  111. package/dist/graph.js.map +1 -0
  112. package/dist/index.js +154 -0
  113. package/dist/index.js.map +1 -0
  114. package/dist/kit/graph-publish.mjs +400 -0
  115. package/dist/mail-latency.js +120 -0
  116. package/dist/mail-latency.js.map +1 -0
  117. package/dist/mcp-registry.js +859 -0
  118. package/dist/mcp-registry.js.map +1 -0
  119. package/dist/mcp.js +193 -0
  120. package/dist/mcp.js.map +1 -0
  121. package/dist/output.js +12 -0
  122. package/dist/output.js.map +1 -0
  123. package/dist/positioning.js +21 -0
  124. package/dist/positioning.js.map +1 -0
  125. package/dist/rolestore.js +89 -0
  126. package/dist/rolestore.js.map +1 -0
  127. package/dist/session.js +49 -0
  128. package/dist/session.js.map +1 -0
  129. package/dist/sessionstore.js +736 -0
  130. package/dist/sessionstore.js.map +1 -0
  131. package/dist/version-check.js +97 -0
  132. package/dist/version-check.js.map +1 -0
  133. package/package.json +47 -0
@@ -0,0 +1,889 @@
1
+ /**
2
+ * A6 — remote mode for ask/MCP.
3
+ *
4
+ * graph.ts stays offline-pure (no supabase import); this file is the ONLY
5
+ * place the CLI's graph-consuming commands touch the network. It adapts the
6
+ * hub's query RPCs (graph_search/graph_subgraph/graph_blast_radius/
7
+ * graph_gaps, 0063_graph_content.sql) into the exact GraphSnapshot shape
8
+ * graph.ts's rankByRelevance/formatNodeCitation already consume, so those
9
+ * two functions are reused UNCHANGED for a remote slice.
10
+ *
11
+ * Every one of the four query RPCs takes an additive p_workspace_slug
12
+ * parameter (Known Defect #2's fix, 0063). Round-2 review finding 8
13
+ * (CONFIRMED) requires this repo pass it EXPLICITLY on every single call —
14
+ * ctx.workspaceSlug is always available and always passed. Leaving it
15
+ * omitted still fails safe (a strict `into strict` raises on a genuine
16
+ * cross-workspace slug collision rather than answering wrong) but fails
17
+ * LOUD for the ordinary, non-buggy case of a caller who belongs to two
18
+ * workspaces that each connected a same-slug repo — passing p_workspace_slug
19
+ * turns that recurring hard failure into the correct, disambiguated answer
20
+ * the parameter exists to support.
21
+ */
22
+ import { execSync } from "node:child_process";
23
+ import { makeCtx, workspaceId } from "./client.js";
24
+ import { resolveConfig } from "./config.js";
25
+ import { CodebusError } from "./errors.js";
26
+ import { parseOwnerRepo } from "./commands/plan-gh.js";
27
+ import { checkStaleness, loadLastGoodSnapshot, loadLocalSnapshot, } from "./graph.js";
28
+ // ── git remote discovery (local-only, no network) ──────────────────────────
29
+ /** Reads `git remote get-url origin` in repoRoot. Returns null (never
30
+ * throws) when not a git repo, no `origin` remote, or git is unavailable —
31
+ * the same "genuinely unknown, not an error" posture graph.ts's checkStaleness
32
+ * already uses for git failures. */
33
+ export function getGitRemoteUrl(repoRoot) {
34
+ try {
35
+ const cwd = repoRoot || process.cwd();
36
+ const url = execSync("git remote get-url origin", { cwd, encoding: "utf-8" }).trim();
37
+ return url || null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function localHeadSha(repoRoot) {
44
+ try {
45
+ const cwd = repoRoot || process.cwd();
46
+ return execSync("git rev-parse HEAD", { cwd, encoding: "utf-8" }).trim();
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ // ── credential resolution (must degrade silently) ───────────────────────────
53
+ /** Attempts to build an authenticated Ctx from ambient config (flags are not
54
+ * available here — this is the lazy, best-effort path used only when local
55
+ * data is unavailable or an explicit --repo asks for something else). Never
56
+ * throws: missing token/url, or a failed auth handshake, both come back as
57
+ * null so callers can fall through to the next precedence tier instead of
58
+ * surfacing a raw credentials error. */
59
+ export async function tryMakeCtx() {
60
+ try {
61
+ const cfg = resolveConfig({});
62
+ return await makeCtx(cfg);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Matches a git remote URL against repos.remote_url within ctx's OWN
70
+ * workspace — sidesteps Known Defect #2 client-side by never looking outside
71
+ * ctx.workspaceSlug in the first place (rather than relying solely on a
72
+ * server-side disambiguation parameter). Reuses parseOwnerRepo
73
+ * (commands/plan-gh.ts) for normalization on both sides rather than calling
74
+ * 0049's plan_repo_from_remote_url — that SQL helper carries no grant to
75
+ * `authenticated` (internal-only, invoked from inside other SECURITY DEFINER
76
+ * functions).
77
+ *
78
+ * A null/unparseable remote_url is a non-match, never an error — A4 (which
79
+ * populates remote_url via `codebus repo connect`) has not shipped yet, so
80
+ * today most repos rows simply have no remote_url at all.
81
+ */
82
+ export async function matchRepoByRemote(ctx, remoteUrl) {
83
+ const normalized = parseOwnerRepo(remoteUrl);
84
+ if (!normalized)
85
+ return null;
86
+ const ws = await workspaceId(ctx);
87
+ const { data, error } = await ctx.supabase
88
+ .from("repos")
89
+ .select("id, slug, remote_url")
90
+ .eq("workspace_id", ws);
91
+ if (error || !data)
92
+ return null;
93
+ for (const row of data) {
94
+ if (!row.remote_url)
95
+ continue; // null remote_url is a non-match, not an error
96
+ if (parseOwnerRepo(row.remote_url) === normalized) {
97
+ return { id: row.id, slug: row.slug, remoteUrl: row.remote_url };
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ /** Resolves the repo slug a remote call should target: the explicit repo if
103
+ * given, else whatever this directory's git origin auto-matches to within
104
+ * ctx's own workspace. Throws (caller decides how to surface) when neither
105
+ * is available. */
106
+ export async function resolveRemoteRepoSlug(ctx, repoRoot, explicitRepo) {
107
+ if (explicitRepo)
108
+ return explicitRepo;
109
+ const remoteUrl = getGitRemoteUrl(repoRoot);
110
+ const match = remoteUrl ? await matchRepoByRemote(ctx, remoteUrl) : null;
111
+ if (!match) {
112
+ throw new CodebusError("could not determine which connected repo to query (no --repo/repo given and this directory's git remote doesn't auto-match a connected repo) — pass repo explicitly");
113
+ }
114
+ return match.slug;
115
+ }
116
+ // ── query tokenization (mirrors rankByRelevance's own term selection) ─────
117
+ /** graph_search takes ONE string, but questions are sentences — tokenize the
118
+ * same way rankByRelevance does (longest terms first, ≥3 chars) and fan out
119
+ * one graph_search call per term. Falls back to the whole trimmed query as a
120
+ * single term when nothing survives the length filter (a bare 1-2 char
121
+ * query), since graph_search rejects an empty p_q. */
122
+ export function tokenizeForRemoteSearch(query, max = 4) {
123
+ const terms = query
124
+ .toLowerCase()
125
+ .split(/\s+/)
126
+ .filter((t) => t.length >= 3);
127
+ const unique = [...new Set(terms)].sort((a, b) => b.length - a.length).slice(0, max);
128
+ if (unique.length > 0)
129
+ return unique;
130
+ const trimmed = query.trim();
131
+ return trimmed ? [trimmed] : [];
132
+ }
133
+ /** Best-effort read of the graph_status view (0050 B.2.5a) for one repo id.
134
+ * Returns null on any failure — this is a freshness/status AMENITY, never
135
+ * allowed to fail the underlying search/subgraph/status call it accompanies. */
136
+ async function readGraphStatus(ctx, repoId) {
137
+ try {
138
+ const { data, error } = await ctx.supabase
139
+ .from("graph_status")
140
+ .select("attachment_state, grade, freshness, stale, no_graph, serving_sha, head_sha")
141
+ .eq("project_id", repoId)
142
+ .maybeSingle();
143
+ if (error || !data)
144
+ return null;
145
+ return data;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ /**
152
+ * Best-effort remote attachment-state + freshness lookup for `codebus graph
153
+ * status`. Degrades to null (never throws) on any failure — missing
154
+ * credentials, no git remote to auto-match, an unconnected repo, or an RPC
155
+ * error are all treated the same way: "nothing remote to report", not an
156
+ * error the command should fail on.
157
+ */
158
+ export async function fetchRemoteStatus(repoRoot, explicitRepo, ctxOverride) {
159
+ try {
160
+ let ctx = ctxOverride ?? null;
161
+ let repoSlug = explicitRepo;
162
+ if (!repoSlug) {
163
+ const remoteUrl = getGitRemoteUrl(repoRoot);
164
+ if (!remoteUrl)
165
+ return null;
166
+ ctx = ctx ?? (await tryMakeCtx());
167
+ if (!ctx)
168
+ return null;
169
+ const match = await matchRepoByRemote(ctx, remoteUrl);
170
+ if (!match)
171
+ return null;
172
+ repoSlug = match.slug;
173
+ }
174
+ else {
175
+ ctx = ctx ?? (await tryMakeCtx());
176
+ if (!ctx)
177
+ return null;
178
+ }
179
+ const { data: repoId, error: ridError } = await ctx.supabase.rpc("graph_resolve_repo_id", {
180
+ p_caller: "codebus-graph-status",
181
+ p_repo_slug: repoSlug,
182
+ p_workspace_slug: ctx.workspaceSlug,
183
+ });
184
+ if (ridError || !repoId)
185
+ return null;
186
+ const row = await readGraphStatus(ctx, repoId);
187
+ if (!row)
188
+ return null;
189
+ return {
190
+ repoSlug,
191
+ repoId: repoId,
192
+ attachmentState: row.attachment_state,
193
+ grade: row.grade,
194
+ freshness: row.freshness,
195
+ stale: row.stale,
196
+ noGraph: row.no_graph,
197
+ servingSha7: row.serving_sha ? String(row.serving_sha).slice(0, 7) : null,
198
+ headSha7: row.head_sha ? String(row.head_sha).slice(0, 7) : null,
199
+ };
200
+ }
201
+ catch {
202
+ return null;
203
+ }
204
+ }
205
+ export async function fetchRemoteSlice(ctx, repoSlug, query, opts = {}) {
206
+ const terms = tokenizeForRemoteSearch(query);
207
+ const limit = opts.limit ?? 20;
208
+ const seedCap = opts.seedCap ?? 20;
209
+ // Resolve the repo id the SAME way the RPCs themselves do (graph_resolve_repo_id,
210
+ // 0063) rather than a separate client-side lookup — this is the one path that
211
+ // gets the exact ambiguous-slug error the RPCs raise, and is already granted
212
+ // to `authenticated`.
213
+ const { data: repoId, error: ridError } = await ctx.supabase.rpc("graph_resolve_repo_id", {
214
+ p_caller: "codebus-cli",
215
+ p_repo_slug: repoSlug,
216
+ p_workspace_slug: ctx.workspaceSlug,
217
+ });
218
+ if (ridError || !repoId) {
219
+ throw new CodebusError(`could not resolve repo '${repoSlug}': ${ridError?.message ?? "not found"}`);
220
+ }
221
+ const searchResults = terms.length > 0
222
+ ? await Promise.all(terms.map((term) => ctx.supabase.rpc("graph_search", {
223
+ p_repo_slug: repoSlug,
224
+ p_q: term,
225
+ p_limit: limit,
226
+ p_workspace_slug: ctx.workspaceSlug,
227
+ })))
228
+ : [];
229
+ for (const r of searchResults) {
230
+ if (r.error)
231
+ throw new CodebusError(`graph_search failed: ${r.error.message}`);
232
+ }
233
+ // Union by node_id, keeping the max similarity seen across terms.
234
+ const bestByNode = new Map();
235
+ for (const r of searchResults) {
236
+ for (const row of (r.data ?? [])) {
237
+ const existing = bestByNode.get(row.node_id);
238
+ if (!existing || row.similarity > existing.similarity)
239
+ bestByNode.set(row.node_id, row);
240
+ }
241
+ }
242
+ const searchHitRows = [...bestByNode.values()].sort((a, b) => b.similarity - a.similarity);
243
+ const searchHits = searchHitRows.map((r) => ({
244
+ id: r.node_id,
245
+ label: r.label,
246
+ source_file: r.source_file ?? "",
247
+ file_type: r.file_type ?? undefined,
248
+ }));
249
+ const seeds = searchHitRows.slice(0, seedCap).map((r) => r.node_id);
250
+ let nodes = [];
251
+ let links = [];
252
+ let hyperedges = [];
253
+ let truncated = false;
254
+ if (seeds.length > 0) {
255
+ const { data: subgraph, error: subErr } = await ctx.supabase.rpc("graph_subgraph", {
256
+ p_repo_slug: repoSlug,
257
+ p_seed_node_ids: seeds,
258
+ p_depth: opts.depth ?? 2,
259
+ p_max_nodes: opts.maxNodes ?? 200,
260
+ p_workspace_slug: ctx.workspaceSlug,
261
+ });
262
+ if (subErr)
263
+ throw new CodebusError(`graph_subgraph failed: ${subErr.message}`);
264
+ const subgraphNodes = (subgraph?.nodes ?? []);
265
+ const subgraphEdges = (subgraph?.edges ?? []);
266
+ nodes = subgraphNodes.map((n) => ({
267
+ id: n.node_id,
268
+ label: n.label,
269
+ source_file: n.source_file ?? "",
270
+ file_type: n.file_type ?? undefined,
271
+ }));
272
+ links = subgraphEdges.map((e) => ({ source: e.src, target: e.dst, relation: e.relation }));
273
+ hyperedges = subgraphEdges.map((e) => ({
274
+ id: `${e.src}->${e.dst}:${e.relation}`,
275
+ label: e.relation,
276
+ nodes: [e.src, e.dst],
277
+ relation: e.relation,
278
+ confidence: e.confidence,
279
+ }));
280
+ truncated = subgraph?.truncated === true;
281
+ }
282
+ // Optional graph_status read for the citation sha (best-effort; never fails the call).
283
+ const status = await readGraphStatus(ctx, repoId);
284
+ const remoteSha7 = status?.serving_sha ? String(status.serving_sha).slice(0, 7) : undefined;
285
+ const snapshot = {
286
+ built_at_commit: status?.serving_sha ?? "",
287
+ nodes,
288
+ hyperedges,
289
+ links,
290
+ };
291
+ return { snapshot, searchHits, truncated, repoId: repoId, repoSlug, remoteSha7 };
292
+ }
293
+ export async function graphSearchRpc(ctx, repoSlug, q, limit = 20) {
294
+ const { data, error } = await ctx.supabase.rpc("graph_search", {
295
+ p_repo_slug: repoSlug,
296
+ p_q: q,
297
+ p_limit: limit,
298
+ p_workspace_slug: ctx.workspaceSlug,
299
+ });
300
+ if (error)
301
+ throw new CodebusError(`graph_search failed: ${error.message}`);
302
+ return (data ?? []);
303
+ }
304
+ /** Truncates to ~200 rows client-side, per spec — graph_gaps itself has no
305
+ * server-side row cap. */
306
+ export async function graphGapsRpc(ctx, repoSlug, limit = 200) {
307
+ const { data, error } = await ctx.supabase.rpc("graph_gaps", {
308
+ p_repo_slug: repoSlug,
309
+ p_workspace_slug: ctx.workspaceSlug,
310
+ });
311
+ if (error)
312
+ throw new CodebusError(`graph_gaps failed: ${error.message}`);
313
+ return (data ?? []).slice(0, limit);
314
+ }
315
+ /**
316
+ * remoteStalenessNote: reuses local checkStaleness when repoRoot is itself a
317
+ * checkout of the SAME repo (git origin matches repoSlug within ctx's
318
+ * workspace) — the precise SHA-identity comparison graph.ts already has —
319
+ * even when that checkout has no graphify-out/ snapshot of its own. Falls
320
+ * back to the hub's own freshness verdict (graph_status) otherwise. Never
321
+ * reports "fresh" when both sides are unknown (0050's 3-state contract:
322
+ * no_graph -> stale -> head_differs_within_grace -> no_head_report -> current).
323
+ */
324
+ export async function remoteStalenessNote(ctx, repoId, repoSlug, repoRoot) {
325
+ const status = await readGraphStatus(ctx, repoId);
326
+ // Is repoRoot itself a checkout of this same repo? If so, compare its real
327
+ // HEAD to the hub's serving sha directly — more precise than trusting the
328
+ // hub's own (possibly stale) head-report.
329
+ const remoteUrl = getGitRemoteUrl(repoRoot);
330
+ const sameCheckout = remoteUrl ? (await matchRepoByRemote(ctx, remoteUrl))?.slug === repoSlug : false;
331
+ if (sameCheckout) {
332
+ const head = localHeadSha(repoRoot);
333
+ if (head && status?.serving_sha) {
334
+ const fakeSnapshot = { built_at_commit: status.serving_sha, nodes: [] };
335
+ // Reuse graph.ts's own SHA-identity + dirty-tree comparison verbatim.
336
+ return await checkStaleness(fakeSnapshot, repoRoot, "dirty-only");
337
+ }
338
+ }
339
+ // No usable local checkout signal — fall back to the hub's own verdict.
340
+ if (!status) {
341
+ return { isStale: false, isDirty: false, unknown: true, note: "(staleness unknown: no graph_status row for this repo)" };
342
+ }
343
+ if (status.no_graph) {
344
+ return { isStale: true, isDirty: false, note: "(remote: no graph attached to this repo yet)" };
345
+ }
346
+ if (status.freshness === "no_head_report") {
347
+ return {
348
+ isStale: false,
349
+ isDirty: false,
350
+ unknown: true,
351
+ note: "(staleness unknown: hub has no HEAD report for this repo)",
352
+ };
353
+ }
354
+ const sha7 = status.serving_sha ? String(status.serving_sha).slice(0, 7) : "unknown";
355
+ if (status.stale) {
356
+ return { isStale: true, isDirty: false, note: `(remote graph at ${sha7}; hub reports stale — ${status.freshness})` };
357
+ }
358
+ return { isStale: false, isDirty: false, note: `(remote graph at ${sha7}, hub reports ${status.freshness})` };
359
+ }
360
+ /**
361
+ * F2 fix: mirrors graph_blast_radius's own `p_relations` default verbatim
362
+ * (0063_graph_content.sql:1108 — `default array['imports','imports_from',
363
+ * 'calls','contains','references','implements']`). MUST stay in sync with
364
+ * that SQL default so local and remote blast radius agree on which edges
365
+ * count when the caller doesn't specify relations explicitly — previously
366
+ * localBlastRadius followed EVERY relation by default, giving local and
367
+ * remote different answers for the identical query.
368
+ */
369
+ export const DEFAULT_BLAST_RADIUS_RELATIONS = [
370
+ "imports",
371
+ "imports_from",
372
+ "calls",
373
+ "contains",
374
+ "references",
375
+ "implements",
376
+ ];
377
+ // ── trigram similarity (approximates pg_trgm's `similarity()`) ─────────────
378
+ /**
379
+ * Character trigrams of `s`, case-folded to match pg_trgm's typical
380
+ * case-insensitive usage here.
381
+ *
382
+ * F3 fix: pg_trgm tokenizes per WORD, not per whole string — it splits the
383
+ * input into maximal runs of alphanumeric characters, pads EACH word with
384
+ * two leading spaces + one trailing space, and trigrams each padded word
385
+ * independently, so no trigram ever spans a word boundary. The previous
386
+ * implementation padded the entire string once (two spaces on both ends)
387
+ * and trigrammed straight through, retaining cross-word trigrams pg_trgm
388
+ * never produces (e.g. it invented an "o o" trigram for "fo o" — no such
389
+ * trigram exists for real pg_trgm, which only ever sees "fo" and "o" as
390
+ * two separate, independently-padded words). That divergence is not
391
+ * cosmetic: it can flip which of two candidate labels ranks as more
392
+ * similar to a seed. Worked example (see also graph-remote.test.ts):
393
+ * for seed "foo", real pg_trgm's similarity() ranks "foobar" above
394
+ * "fo o" (trigram overlap {" f"," fo","foo"} vs {" f"," fo"} — "foobar"
395
+ * shares the true 3-gram "foo", which "fo o" — split into "fo" and "o" —
396
+ * never can). The previous whole-string padding reversed this ranking by
397
+ * manufacturing a fake shared "o " trigram between "foo" and "fo o".
398
+ */
399
+ function trigramsOf(s) {
400
+ const grams = new Set();
401
+ const words = s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
402
+ for (const word of words) {
403
+ const padded = ` ${word} `;
404
+ for (let i = 0; i + 3 <= padded.length; i++)
405
+ grams.add(padded.slice(i, i + 3));
406
+ }
407
+ return grams;
408
+ }
409
+ /**
410
+ * Approximates PostgreSQL pg_trgm's `similarity(a, b)` — Jaccard similarity
411
+ * over each string's set of character trigrams (|A ∩ B| / |A ∪ B|) — closely
412
+ * enough to rank graph_blast_radius's label-fallback seed candidates the
413
+ * same way the SQL function does in the common case.
414
+ *
415
+ * Documented divergence from the real pg_trgm implementation (called out
416
+ * rather than silently assumed away): pg_trgm's trigram extraction has its
417
+ * own locale-specific edge cases around non-alphanumeric runs, and the SQL
418
+ * callsite (`order by similarity(...) desc limit 1`, no secondary ORDER BY)
419
+ * makes Postgres's own tie-break plan-dependent when two labels score
420
+ * exactly equally — this function breaks such ties deterministically by
421
+ * lowest node id instead, so our result is stable but is not guaranteed to
422
+ * match whichever row Postgres's planner happens to return that day. This
423
+ * only matters when two candidate labels are EQUALLY similar to the seed,
424
+ * which is rare for the intended use (resolving a human-typed --seed label
425
+ * to the closest node).
426
+ */
427
+ function trigramSimilarity(a, b) {
428
+ const ta = trigramsOf(a);
429
+ const tb = trigramsOf(b);
430
+ if (ta.size === 0 && tb.size === 0)
431
+ return 1;
432
+ if (ta.size === 0 || tb.size === 0)
433
+ return 0;
434
+ let intersection = 0;
435
+ for (const g of ta)
436
+ if (tb.has(g))
437
+ intersection++;
438
+ const union = ta.size + tb.size - intersection;
439
+ return union === 0 ? 0 : intersection / union;
440
+ }
441
+ /**
442
+ * The local counterpart to graph_blast_radius (0063_graph_content.sql):
443
+ * same direction contract ('out' = what the seed depends on, following
444
+ * src->dst; 'in' = what depends on the seed / "what breaks if I change
445
+ * this", following dst->src in reverse; 'both' = the union), same seed
446
+ * resolution priority (exact node_id -> exact source_file -> best label
447
+ * match), same via_relation convention (a 'seed:*' tag on the distance-0
448
+ * row instead of an edge relation). Runs over an in-memory GraphSnapshot's
449
+ * `links` array — pure, offline, and the SAME traversal blast_radius (MCP)
450
+ * uses when serving a local snapshot instead of an RPC call.
451
+ */
452
+ export function localBlastRadius(snapshot, seed, opts = {}) {
453
+ const direction = opts.direction ?? "in";
454
+ const depth = Math.max(0, Math.min(opts.depth ?? 3, 6));
455
+ // F2 fix: default to the SAME relation set graph_blast_radius defaults to
456
+ // (DEFAULT_BLAST_RADIUS_RELATIONS) rather than following every relation —
457
+ // "parameterized like the RPC" means an explicit override still wins. The
458
+ // SQL only substitutes its default when p_relations is OMITTED — an
459
+ // explicit `relations: []` means "follow no edges" and must stay empty,
460
+ // not silently widen back out to the default set. So this distinguishes
461
+ // undefined (apply the default) from an explicit, possibly-empty array
462
+ // (use exactly what was given) instead of collapsing both to "falsy or
463
+ // empty -> default".
464
+ const relations = opts.relations !== undefined ? opts.relations : DEFAULT_BLAST_RADIUS_RELATIONS;
465
+ const relationsFilter = new Set(relations);
466
+ const nodes = snapshot.nodes || [];
467
+ const nodesById = new Map();
468
+ for (const n of nodes)
469
+ nodesById.set(n.id, n);
470
+ let seedId = null;
471
+ let seedVia = "seed:exact_node_id";
472
+ if (nodesById.has(seed)) {
473
+ seedId = seed;
474
+ }
475
+ else {
476
+ // F3 fix: SQL step 2 (`order by gn.node_id limit 1`) breaks a
477
+ // source_file tie by the LOWEST node id, not by whichever node happens
478
+ // to appear first in the snapshot's own array order.
479
+ const sourceMatches = nodes.filter((n) => n.source_file === seed);
480
+ if (sourceMatches.length > 0) {
481
+ const bySourceFile = [...sourceMatches].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0];
482
+ seedId = bySourceFile.id;
483
+ seedVia = "seed:source_file";
484
+ }
485
+ else if (nodes.length > 0) {
486
+ // F3 fix: SQL step 3 is `order by similarity(gn.label, p_seed) desc
487
+ // limit 1` — pg_trgm's best TRIGRAM match over every node, always
488
+ // resolving to something as long as the repo has any nodes at all.
489
+ // The previous `.includes()` substring check could match nothing
490
+ // (returning no seed at all) where the RPC would still pick a node.
491
+ // trigramSimilarity below approximates pg_trgm's scoring; see its own
492
+ // comment for the acknowledged, documented divergences.
493
+ const ranked = [...nodes].sort((a, b) => {
494
+ const diff = trigramSimilarity(b.label, seed) - trigramSimilarity(a.label, seed);
495
+ if (diff !== 0)
496
+ return diff;
497
+ // Not a real SQL guarantee (a bare `similarity() desc` with no
498
+ // secondary ORDER BY is plan-dependent on ties) — deterministic
499
+ // tie-break by lowest node id so at least OUR result is stable.
500
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
501
+ });
502
+ seedId = ranked[0].id;
503
+ seedVia = "seed:trigram_label";
504
+ }
505
+ }
506
+ if (!seedId)
507
+ return [];
508
+ const links = (Array.isArray(snapshot.links) ? snapshot.links : [])
509
+ .filter((l) => !!l && typeof l === "object" && "source" in l && "target" in l);
510
+ const distances = new Map([[seedId, 0]]);
511
+ const via = new Map([[seedId, seedVia]]);
512
+ let frontier = [seedId];
513
+ for (let level = 0; level < depth && frontier.length > 0; level++) {
514
+ const frontierSet = new Set(frontier);
515
+ const next = [];
516
+ for (const link of links) {
517
+ if (link.relation && !relationsFilter.has(link.relation))
518
+ continue;
519
+ const rel = link.relation ?? "link";
520
+ if ((direction === "out" || direction === "both") && frontierSet.has(link.source) && !distances.has(link.target)) {
521
+ distances.set(link.target, level + 1);
522
+ via.set(link.target, rel);
523
+ next.push(link.target);
524
+ }
525
+ if ((direction === "in" || direction === "both") && frontierSet.has(link.target) && !distances.has(link.source)) {
526
+ distances.set(link.source, level + 1);
527
+ via.set(link.source, rel);
528
+ next.push(link.source);
529
+ }
530
+ }
531
+ frontier = [...new Set(next)];
532
+ }
533
+ const rows = [];
534
+ for (const [nodeId, distance] of distances) {
535
+ const node = nodesById.get(nodeId);
536
+ rows.push({
537
+ node_id: nodeId,
538
+ label: node?.label ?? nodeId,
539
+ file_type: node?.file_type,
540
+ source_file: node?.source_file,
541
+ distance,
542
+ via_relation: via.get(nodeId) ?? "link",
543
+ });
544
+ }
545
+ return rows.sort((a, b) => a.distance - b.distance || a.node_id.localeCompare(b.node_id));
546
+ }
547
+ /**
548
+ * Same source precedence as resolveGraphSource (local wins ONLY when no
549
+ * explicit --repo is given, or an explicit --repo is POSITIVELY confirmed to
550
+ * name the SAME repo as this checkout -> otherwise remote is attempted, and
551
+ * a remote failure throws rather than silently falling back to local -> none
552
+ * — blast radius has no "last-good" tier since a stale cached slice for the
553
+ * WRONG seed would be actively misleading rather than merely stale), but
554
+ * dispatches to localBlastRadius instead of rankByRelevance/fetchRemoteSlice:
555
+ * this is the "shared local traversal, RPC when remote" split the MCP
556
+ * blast_radius tool and (in principle) any future local-blast-radius CLI
557
+ * surface both use.
558
+ */
559
+ export async function resolveBlastRadius(seed, opts = {}) {
560
+ const repoRoot = opts.repoRoot || process.cwd();
561
+ const explicitRepo = opts.repo;
562
+ const local = loadLocalSnapshot(repoRoot);
563
+ const localOpts = { direction: opts.direction, depth: opts.depth, relations: opts.relations };
564
+ let ctx = opts.ctx ?? null;
565
+ let useRemote = !!explicitRepo;
566
+ // Set whenever an explicit --repo is paired with a local snapshot and the
567
+ // local checkout could NOT be POSITIVELY confirmed to be the SAME repo —
568
+ // this covers both a confirmed DIFFERENT repo and a merely unconfirmed
569
+ // match (no remote_url row, an unparseable origin, or credentials that
570
+ // were unavailable to even ask). In every one of those cases the local
571
+ // snapshot must not silently answer an explicit --repo query it can't be
572
+ // vouched for: remote must be attempted, and a remote failure must fail
573
+ // honestly rather than fall back to a local graph that might be wrong.
574
+ let mustNotFallBackToLocal = false;
575
+ // True only for the strict subset of the above where matchRepoByRemote
576
+ // positively proved local names a DIFFERENT repo — used to sharpen the
577
+ // thrown error message when we actually have that proof.
578
+ let confirmedDifferentRepo = false;
579
+ let confirmedLocalSlug;
580
+ if (explicitRepo && local) {
581
+ let localMatch = null;
582
+ try {
583
+ ctx = ctx ?? (await tryMakeCtx());
584
+ if (ctx) {
585
+ const remoteUrl = getGitRemoteUrl(repoRoot);
586
+ localMatch = remoteUrl ? await matchRepoByRemote(ctx, remoteUrl) : null;
587
+ }
588
+ }
589
+ catch {
590
+ localMatch = null;
591
+ }
592
+ if (localMatch && localMatch.slug === explicitRepo) {
593
+ // Positively confirmed same repo as this checkout — local-first is honest.
594
+ useRemote = false;
595
+ }
596
+ else {
597
+ // F1/round-4 fix: an unknown/null match (no repo row's remote_url
598
+ // could be confirmed against this checkout's origin — the common case
599
+ // before A4 populates remote_url — or credentials were unavailable to
600
+ // even try) must NOT silently keep serving local for an explicit
601
+ // --repo. Only a POSITIVELY confirmed match whose slug agrees with
602
+ // --repo may do that; every other outcome here — including a
603
+ // positively confirmed DIFFERENT repo — must attempt remote and, on
604
+ // failure, fail loudly instead of serving the unverified local graph.
605
+ useRemote = true;
606
+ mustNotFallBackToLocal = true;
607
+ confirmedLocalSlug = localMatch?.slug;
608
+ if (localMatch)
609
+ confirmedDifferentRepo = true;
610
+ }
611
+ }
612
+ if (local && !useRemote) {
613
+ return { rows: localBlastRadius(local, seed, localOpts), source: "local" };
614
+ }
615
+ let repoSlug = explicitRepo;
616
+ if (!repoSlug && !local) {
617
+ const remoteUrl = getGitRemoteUrl(repoRoot);
618
+ if (remoteUrl) {
619
+ try {
620
+ ctx = ctx ?? (await tryMakeCtx());
621
+ if (ctx) {
622
+ const match = await matchRepoByRemote(ctx, remoteUrl);
623
+ repoSlug = match?.slug;
624
+ }
625
+ }
626
+ catch {
627
+ // degrade silently
628
+ }
629
+ }
630
+ }
631
+ if (repoSlug) {
632
+ ctx = ctx ?? (await tryMakeCtx());
633
+ if (!ctx) {
634
+ if (mustNotFallBackToLocal) {
635
+ throw new CodebusError(`--repo '${repoSlug}' could not be confirmed to match this checkout's own repo` +
636
+ (confirmedLocalSlug ? ` (this checkout resolves to '${confirmedLocalSlug}')` : "") +
637
+ `, and no hub credentials are available to query '${repoSlug}' directly. Refusing to silently serve ` +
638
+ `the local blast radius, which may belong to a different repo. Configure credentials (e.g. run ` +
639
+ `'codebus login') or drop --repo to use this checkout's own local graph.`);
640
+ }
641
+ // Remote unavailable — fall through to local (if any) below.
642
+ }
643
+ else {
644
+ try {
645
+ const params = {
646
+ p_repo_slug: repoSlug,
647
+ p_seed: seed,
648
+ p_direction: opts.direction ?? "in",
649
+ p_depth: opts.depth ?? 3,
650
+ p_workspace_slug: ctx.workspaceSlug,
651
+ };
652
+ // Omit p_relations entirely when opts.relations is undefined so the
653
+ // SQL default applies. F2 fix: an explicit `relations: []` is NOT
654
+ // "absent" — the SQL only substitutes its default when p_relations
655
+ // is omitted from the call altogether, so a caller-supplied empty
656
+ // array must still be forwarded (as [] — "follow no edges"), not
657
+ // collapsed into the same case as omission the way a truthiness/
658
+ // length check would.
659
+ if (opts.relations !== undefined)
660
+ params.p_relations = opts.relations;
661
+ const { data, error } = await ctx.supabase.rpc("graph_blast_radius", params);
662
+ if (error)
663
+ throw new CodebusError(`graph_blast_radius failed: ${error.message}`);
664
+ const rows = (data ?? []);
665
+ const truncated = rows.some((r) => r.truncated === true);
666
+ return { rows, source: "remote", repoSlug, truncated };
667
+ }
668
+ catch (err) {
669
+ // Local could not be vouched for as the repo --repo asked for
670
+ // (mustNotFallBackToLocal — either proven different, or simply
671
+ // unconfirmed). Falling back to it here would silently serve
672
+ // (possibly) the wrong repo's blast radius as if it answered the
673
+ // question asked. Fail honestly instead of degrading to local in
674
+ // this case. (This block is unreachable when local was positively
675
+ // confirmed to be the SAME repo, since that case never sets
676
+ // useRemote and returns local well before reaching here.)
677
+ if (mustNotFallBackToLocal) {
678
+ const detail = err instanceof Error ? err.message : String(err);
679
+ throw new CodebusError(confirmedDifferentRepo
680
+ ? `--repo '${repoSlug}' was confirmed different from this checkout's own repo ` +
681
+ `('${confirmedLocalSlug}'), and the remote fetch for '${repoSlug}' failed (${detail}). ` +
682
+ `Refusing to fall back to the local graph of a different repo.`
683
+ : `--repo '${repoSlug}' could not be confirmed to match this checkout's own repo, and the remote ` +
684
+ `fetch for '${repoSlug}' failed (${detail}). Refusing to silently fall back to the local graph, ` +
685
+ `which may belong to a different repo.`);
686
+ }
687
+ // Remote unavailable for any other reason (no local snapshot to
688
+ // protect, so nothing to silently mis-serve) — fall through to
689
+ // local (if any) below.
690
+ }
691
+ }
692
+ }
693
+ if (local)
694
+ return { rows: localBlastRadius(local, seed, localOpts), source: "local" };
695
+ return { rows: [], source: "none" };
696
+ }
697
+ /**
698
+ * Precedence: local wins when no explicit `repo` is given (implicit path,
699
+ * unchanged) -> remote -> last-good -> none. When an explicit `repo` IS
700
+ * given alongside a local snapshot, local wins ONLY if `repo` is POSITIVELY
701
+ * confirmed (via matchRepoByRemote) to name this checkout's own repo;
702
+ * otherwise remote is attempted, and if the remote fetch can't even be
703
+ * attempted (no credentials) or fails outright, this throws rather than
704
+ * silently substituting the local snapshot for a repo it was never
705
+ * confirmed to belong to. Credentials-missing still degrades silently on the
706
+ * IMPLICIT (no --repo) path (never a raw error there) — ask_graph is the
707
+ * only zero-credential MCP tool today and must stay that way when local data
708
+ * is available. Never calls saveLastGoodSnapshot for remote data: that cache
709
+ * is machine-wide, not per-repo, and a partial remote slice would poison it
710
+ * for every other repo's local-only usage.
711
+ */
712
+ export async function resolveGraphSource(query, opts = {}) {
713
+ const repoRoot = opts.repoRoot || process.cwd();
714
+ const explicitRepo = opts.repo;
715
+ const local = loadLocalSnapshot(repoRoot);
716
+ let ctx = opts.ctx ?? null;
717
+ let remoteRepoSlug = explicitRepo;
718
+ let attemptRemote = false;
719
+ // Set whenever an explicit --repo is paired with a local snapshot and the
720
+ // local checkout could NOT be POSITIVELY confirmed to be the SAME repo —
721
+ // covers both a confirmed DIFFERENT repo and a merely unconfirmed match
722
+ // (no remote_url row, an unparseable origin, or credentials that were
723
+ // unavailable to even ask). In every one of those cases the local
724
+ // snapshot must not silently answer an explicit --repo query it can't be
725
+ // vouched for: remote must be attempted, and a remote failure must fail
726
+ // honestly rather than fall back to a local graph that might be wrong.
727
+ let mustNotFallBackToLocal = false;
728
+ // True only for the strict subset of the above where matchRepoByRemote
729
+ // positively proved local names a DIFFERENT repo — used to sharpen the
730
+ // thrown error message when we actually have that proof.
731
+ let confirmedDifferentRepo = false;
732
+ let confirmedLocalSlug;
733
+ if (explicitRepo) {
734
+ attemptRemote = true;
735
+ if (local) {
736
+ // Local wins ONLY when the explicit repo is positively confirmed to
737
+ // name the SAME repo as this checkout's own — every other outcome
738
+ // (confirmed different, or simply unconfirmed) must attempt remote.
739
+ let localMatch = null;
740
+ try {
741
+ ctx = ctx ?? (await tryMakeCtx());
742
+ if (ctx) {
743
+ const remoteUrl = getGitRemoteUrl(repoRoot);
744
+ localMatch = remoteUrl ? await matchRepoByRemote(ctx, remoteUrl) : null;
745
+ }
746
+ }
747
+ catch {
748
+ localMatch = null;
749
+ }
750
+ if (localMatch && localMatch.slug === explicitRepo) {
751
+ // Positively confirmed same repo as this checkout — local-first is honest.
752
+ attemptRemote = false;
753
+ }
754
+ else {
755
+ // F1/round-4 fix: an unknown/null match (no repo row's remote_url
756
+ // could be confirmed against this checkout's origin — the common
757
+ // case before A4 populates remote_url — or credentials were
758
+ // unavailable to even try) must NOT silently keep serving local for
759
+ // an explicit --repo. Only a POSITIVELY confirmed match whose slug
760
+ // agrees with --repo may do that; every other outcome here —
761
+ // including a positively confirmed DIFFERENT repo — must attempt
762
+ // remote and, on failure, fail loudly instead of serving the
763
+ // unverified local snapshot.
764
+ attemptRemote = true;
765
+ mustNotFallBackToLocal = true;
766
+ confirmedLocalSlug = localMatch?.slug;
767
+ if (localMatch)
768
+ confirmedDifferentRepo = true;
769
+ }
770
+ }
771
+ }
772
+ else if (!local) {
773
+ // No explicit repo and nothing local: only worth reaching for credentials
774
+ // at all if this directory even LOOKS like a connected repo (has an
775
+ // origin remote) — avoids an auth round-trip for every plain "no graph
776
+ // here" case, which is by far the common one before A4 populates
777
+ // remote_url for anyone.
778
+ const remoteUrl = getGitRemoteUrl(repoRoot);
779
+ if (remoteUrl) {
780
+ try {
781
+ ctx = ctx ?? (await tryMakeCtx());
782
+ if (ctx) {
783
+ const match = await matchRepoByRemote(ctx, remoteUrl);
784
+ if (match) {
785
+ remoteRepoSlug = match.slug;
786
+ attemptRemote = true;
787
+ }
788
+ }
789
+ }
790
+ catch {
791
+ // degrade silently
792
+ }
793
+ }
794
+ }
795
+ if (attemptRemote && remoteRepoSlug) {
796
+ ctx = ctx ?? (await tryMakeCtx());
797
+ if (!ctx) {
798
+ if (mustNotFallBackToLocal) {
799
+ throw new CodebusError(`--repo '${remoteRepoSlug}' could not be confirmed to match this checkout's own repo` +
800
+ (confirmedLocalSlug ? ` (this checkout resolves to '${confirmedLocalSlug}')` : "") +
801
+ `, and no hub credentials are available to query '${remoteRepoSlug}' directly. Refusing to silently ` +
802
+ `serve the local graph, which may belong to a different repo. Configure credentials (e.g. run ` +
803
+ `'codebus login') or drop --repo to use this checkout's own local graph.`);
804
+ }
805
+ if (explicitRepo) {
806
+ // No local snapshot to protect (mustNotFallBackToLocal is false here),
807
+ // but --repo was still requested explicitly and there are no
808
+ // credentials to even attempt the remote query. Falling through to
809
+ // the machine-wide last-good cache below would risk silently
810
+ // answering from a totally different repo's stale snapshot — that
811
+ // cache carries no repo identity to verify against. Fail honestly
812
+ // instead (round-5 review finding).
813
+ throw new CodebusError(`--repo '${remoteRepoSlug}' was requested explicitly, but no hub credentials are available to query ` +
814
+ `it directly, and this checkout has no local graph to fall back to. Refusing to silently answer from ` +
815
+ `the machine-wide last-good cache, which is not scoped to any repo and may belong to a different ` +
816
+ `one. Configure credentials (e.g. run 'codebus login').`);
817
+ }
818
+ // Remote unavailable — degrade silently and fall through below (the
819
+ // implicit no-repo path only).
820
+ }
821
+ else {
822
+ try {
823
+ const slice = await fetchRemoteSlice(ctx, remoteRepoSlug, query);
824
+ const staleness = await remoteStalenessNote(ctx, slice.repoId, remoteRepoSlug, repoRoot).catch(() => undefined);
825
+ return {
826
+ snapshot: slice.snapshot,
827
+ source: "remote",
828
+ repoSlug: remoteRepoSlug,
829
+ repoId: slice.repoId,
830
+ remoteSha7: slice.remoteSha7,
831
+ truncated: slice.truncated,
832
+ searchHits: slice.searchHits,
833
+ staleness,
834
+ };
835
+ }
836
+ catch (err) {
837
+ // Local could not be vouched for as the repo --repo asked for
838
+ // (mustNotFallBackToLocal — either proven different, or simply
839
+ // unconfirmed). Falling back to it here would silently answer with
840
+ // a possibly-different repo's graph as if it answered the question
841
+ // asked. Fail honestly instead of degrading to local in this case.
842
+ // (This block is unreachable when local was positively confirmed to
843
+ // be the SAME repo, since that case never sets attemptRemote and
844
+ // returns local well before reaching here.)
845
+ if (mustNotFallBackToLocal) {
846
+ const detail = err instanceof Error ? err.message : String(err);
847
+ throw new CodebusError(confirmedDifferentRepo
848
+ ? `--repo '${remoteRepoSlug}' was confirmed different from this checkout's own repo ` +
849
+ `('${confirmedLocalSlug}'), and the remote fetch for '${remoteRepoSlug}' failed (${detail}). ` +
850
+ `Refusing to fall back to the local graph of a different repo.`
851
+ : `--repo '${remoteRepoSlug}' could not be confirmed to match this checkout's own repo, and the ` +
852
+ `remote fetch for '${remoteRepoSlug}' failed (${detail}). Refusing to silently fall back to the ` +
853
+ `local graph, which may belong to a different repo.`);
854
+ }
855
+ if (explicitRepo) {
856
+ // No local snapshot to protect, but --repo was still requested
857
+ // explicitly and the remote fetch failed. Falling through to the
858
+ // machine-wide last-good cache below would risk silently
859
+ // answering from a totally different repo's stale snapshot — that
860
+ // cache carries no repo identity to verify against (round-5
861
+ // review finding). Fail honestly instead.
862
+ const detail = err instanceof Error ? err.message : String(err);
863
+ throw new CodebusError(`--repo '${remoteRepoSlug}' was requested explicitly, and the remote fetch failed (${detail}). This ` +
864
+ `checkout has no local graph to fall back to. Refusing to silently answer from the machine-wide ` +
865
+ `last-good cache, which is not scoped to any repo and may belong to a different one.`);
866
+ }
867
+ // Remote unavailable for any other reason (implicit no-repo path,
868
+ // nothing to silently mis-serve) — degrade silently and fall
869
+ // through below.
870
+ }
871
+ }
872
+ }
873
+ if (local)
874
+ return { snapshot: local, source: "local" };
875
+ // The last-good cache (~/.codebus/last-good-graph.json) is machine-wide
876
+ // and carries no repo identity of its own (GraphSnapshot has no repo/slug
877
+ // field) — it is only a safe fallback for the IMPLICIT (no --repo) path.
878
+ // Every explicit --repo failure above already throws before reaching
879
+ // here; this guard is a defense-in-depth backstop against ever silently
880
+ // answering an explicit --repo request from a possibly-different repo's
881
+ // cached snapshot (round-5 review finding).
882
+ if (!explicitRepo) {
883
+ const lastGood = loadLastGoodSnapshot();
884
+ if (lastGood)
885
+ return { snapshot: lastGood, source: "last-good" };
886
+ }
887
+ return { snapshot: null, source: "none" };
888
+ }
889
+ //# sourceMappingURL=graph-remote.js.map