@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.
- package/bin/codebus.js +2 -0
- package/dist/binding-guard.js +97 -0
- package/dist/binding-guard.js.map +1 -0
- package/dist/client.js +94 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/ack.js +47 -0
- package/dist/commands/ack.js.map +1 -0
- package/dist/commands/archive.js +21 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/as.js +299 -0
- package/dist/commands/as.js.map +1 -0
- package/dist/commands/ask.js +189 -0
- package/dist/commands/ask.js.map +1 -0
- package/dist/commands/auth.js +33 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/checkin.js +235 -0
- package/dist/commands/checkin.js.map +1 -0
- package/dist/commands/decision.js +52 -0
- package/dist/commands/decision.js.map +1 -0
- package/dist/commands/digest.js +117 -0
- package/dist/commands/digest.js.map +1 -0
- package/dist/commands/doctor.js +106 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/factory.js +191 -0
- package/dist/commands/factory.js.map +1 -0
- package/dist/commands/github-login.js +205 -0
- package/dist/commands/github-login.js.map +1 -0
- package/dist/commands/graph.js +88 -0
- package/dist/commands/graph.js.map +1 -0
- package/dist/commands/health.js +34 -0
- package/dist/commands/health.js.map +1 -0
- package/dist/commands/inbox.js +83 -0
- package/dist/commands/inbox.js.map +1 -0
- package/dist/commands/ingest.js +553 -0
- package/dist/commands/ingest.js.map +1 -0
- package/dist/commands/init.js +97 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.js +520 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/invite.js +152 -0
- package/dist/commands/invite.js.map +1 -0
- package/dist/commands/jira.js +237 -0
- package/dist/commands/jira.js.map +1 -0
- package/dist/commands/join.js +96 -0
- package/dist/commands/join.js.map +1 -0
- package/dist/commands/links.js +66 -0
- package/dist/commands/links.js.map +1 -0
- package/dist/commands/login.js +84 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/morning.js +121 -0
- package/dist/commands/morning.js.map +1 -0
- package/dist/commands/next.js +66 -0
- package/dist/commands/next.js.map +1 -0
- package/dist/commands/onboarding.js +109 -0
- package/dist/commands/onboarding.js.map +1 -0
- package/dist/commands/pack.js +276 -0
- package/dist/commands/pack.js.map +1 -0
- package/dist/commands/plan-gh.js +425 -0
- package/dist/commands/plan-gh.js.map +1 -0
- package/dist/commands/plan.js +465 -0
- package/dist/commands/plan.js.map +1 -0
- package/dist/commands/pr.js +88 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/question.js +142 -0
- package/dist/commands/question.js.map +1 -0
- package/dist/commands/read.js +84 -0
- package/dist/commands/read.js.map +1 -0
- package/dist/commands/refs.js +357 -0
- package/dist/commands/refs.js.map +1 -0
- package/dist/commands/reply.js +38 -0
- package/dist/commands/reply.js.map +1 -0
- package/dist/commands/repo.js +1198 -0
- package/dist/commands/repo.js.map +1 -0
- package/dist/commands/report.js +41 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/resolve-refs.js +21 -0
- package/dist/commands/resolve-refs.js.map +1 -0
- package/dist/commands/resolve.js +93 -0
- package/dist/commands/resolve.js.map +1 -0
- package/dist/commands/send.js +168 -0
- package/dist/commands/send.js.map +1 -0
- package/dist/commands/sent.js +22 -0
- package/dist/commands/sent.js.map +1 -0
- package/dist/commands/session.js +426 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/commands/statusline.js +43 -0
- package/dist/commands/statusline.js.map +1 -0
- package/dist/commands/task.js +140 -0
- package/dist/commands/task.js.map +1 -0
- package/dist/commands/tick.js +233 -0
- package/dist/commands/tick.js.map +1 -0
- package/dist/commands/upgrade.js +76 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/watch.js +169 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/commands/whoami.js +27 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/config.js +103 -0
- package/dist/config.js.map +1 -0
- package/dist/directives.js +102 -0
- package/dist/directives.js.map +1 -0
- package/dist/errors.js +110 -0
- package/dist/errors.js.map +1 -0
- package/dist/git-hooks.js +116 -0
- package/dist/git-hooks.js.map +1 -0
- package/dist/graph-bootstrap.js +95 -0
- package/dist/graph-bootstrap.js.map +1 -0
- package/dist/graph-remote.js +889 -0
- package/dist/graph-remote.js.map +1 -0
- package/dist/graph.js +335 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/graph-publish.mjs +400 -0
- package/dist/mail-latency.js +120 -0
- package/dist/mail-latency.js.map +1 -0
- package/dist/mcp-registry.js +859 -0
- package/dist/mcp-registry.js.map +1 -0
- package/dist/mcp.js +193 -0
- package/dist/mcp.js.map +1 -0
- package/dist/output.js +12 -0
- package/dist/output.js.map +1 -0
- package/dist/positioning.js +21 -0
- package/dist/positioning.js.map +1 -0
- package/dist/rolestore.js +89 -0
- package/dist/rolestore.js.map +1 -0
- package/dist/session.js +49 -0
- package/dist/session.js.map +1 -0
- package/dist/sessionstore.js +736 -0
- package/dist/sessionstore.js.map +1 -0
- package/dist/version-check.js +97 -0
- package/dist/version-check.js.map +1 -0
- 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
|