@codragraph/cli 2.1.4 → 2.1.5

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.
@@ -69,76 +69,6 @@ const FALLBACK_FIELD_WEIGHTS = {
69
69
  content: 2,
70
70
  description: 1,
71
71
  };
72
- /**
73
- * Per-process cache for the MCP pool path: tracks which `(repoId, table)`
74
- * pairs have been ensured. The CLI/pipeline path gets its own cache inside
75
- * `cgdb-adapter.ts` keyed by table/index, scoped to the singleton connection.
76
- *
77
- * IMPORTANT: an entry is added ONLY when the index was confirmed to exist
78
- * (CREATE_FTS_INDEX succeeded, or failed with `'already exists'`). Other
79
- * failures (transient lock errors, missing extension, etc.) leave the key
80
- * unset so the next query retries instead of silently caching the failure.
81
- *
82
- * Entries for a given repoId are invalidated when its pool is closed —
83
- * see the `addPoolCloseListener` registration in `searchFTSFromCgdb`.
84
- */
85
- const ensuredPoolFTS = new Set();
86
- /**
87
- * Drop all ensured-FTS cache entries for a given repoId.
88
- *
89
- * Called from the pool-close listener so that a pool teardown / recreation
90
- * forces the next `searchFTSFromCgdb` call to re-issue `CREATE_FTS_INDEX`
91
- * against the fresh connection rather than trust stale ensure-state from a
92
- * previous pool lifetime.
93
- *
94
- * Exported for tests; the listener wiring is internal.
95
- */
96
- export function invalidateEnsuredFTSForRepo(repoId) {
97
- const prefix = `${repoId}:`;
98
- for (const key of ensuredPoolFTS) {
99
- if (key.startsWith(prefix))
100
- ensuredPoolFTS.delete(key);
101
- }
102
- }
103
- /**
104
- * Tracks whether we've already wired the pool-close listener for this
105
- * process. The pool adapter is dynamically imported, so registration
106
- * happens lazily on the first MCP-pool-backed FTS query.
107
- */
108
- let poolCloseListenerRegistered = false;
109
- function registerPoolCloseListenerOnce(addPoolCloseListener) {
110
- if (poolCloseListenerRegistered)
111
- return;
112
- poolCloseListenerRegistered = true;
113
- addPoolCloseListener((repoId) => invalidateEnsuredFTSForRepo(repoId));
114
- }
115
- async function ensureFTSIndexViaExecutor(executor, repoId, table, indexName, properties) {
116
- const key = `${repoId}:${table}:${indexName}`;
117
- if (ensuredPoolFTS.has(key))
118
- return;
119
- const propList = properties.map((p) => `'${p}'`).join(', ');
120
- try {
121
- await executor(`CALL CREATE_FTS_INDEX('${table}', '${indexName}', [${propList}], stemmer := 'porter')`);
122
- // Index was created successfully — safe to cache.
123
- ensuredPoolFTS.add(key);
124
- }
125
- catch (e) {
126
- // 'already exists' is the happy path (index persists on disk between
127
- // process invocations) — cache it. Anything else is treated as a
128
- // transient failure: surface a one-time warning and leave the key
129
- // unset so the NEXT query retries rather than silently using a
130
- // cached failure (which previously disabled BM25 for the whole
131
- // process for that repo).
132
- const msg = String(e?.message ?? '');
133
- if (msg.includes('already exists')) {
134
- ensuredPoolFTS.add(key);
135
- }
136
- else {
137
- console.warn(`[codragraph] FTS index ensure failed for repo "${repoId}" table "${table}" ` +
138
- `(index "${indexName}"): ${msg || e}. Will retry on next query.`);
139
- }
140
- }
141
- }
142
72
  /**
143
73
  * Execute a single FTS query via a custom executor (for MCP connection pool).
144
74
  * Returns the same shape as core queryFTS (from LadybugDB adapter).
@@ -254,23 +184,16 @@ export const searchFTSFromCgdb = async (query, limit = 20, repoId) => {
254
184
  // IMPORTANT: FTS queries run sequentially to avoid connection contention.
255
185
  // The MCP pool supports multiple connections, but FTS is best run serially.
256
186
  const poolMod = await import('../cgdb/pool-adapter.js');
257
- const { executeQuery, addPoolCloseListener } = poolMod;
258
- // Register the pool-close listener lazily on first use so a teardown of
259
- // the pool entry (LRU eviction, idle timeout, explicit close) drops the
260
- // matching `ensuredPoolFTS` entries. Without this, stale ensure-state
261
- // can outlive the pool that produced it.
262
- registerPoolCloseListenerOnce(addPoolCloseListener);
187
+ const { executeQuery } = poolMod;
263
188
  const executor = (cypher) => executeQuery(repoId, cypher);
264
- // Lazy-create FTS indexes on first query for this repo (analyze no longer
265
- // creates them up-front, so we ensure them here). Cached per-process.
266
- // RFC 0001 Phase 2.5: drop `content` from FTS properties for repos
267
- // analysed with --compress brotli|zstd the column holds encoded
268
- // bytes and would tokenise to garbage.
189
+ // The MCP/LocalBackend pool is opened read-only so it can safely coexist
190
+ // with `codragraph analyze`. Do not issue CREATE_FTS_INDEX here: when
191
+ // persisted FTS indexes are missing, QUERY_FTS_INDEX falls through to the
192
+ // bounded BM25 fallback below without noisy write-failure retries.
193
+ // RFC 0001 Phase 2.5: drop `content` from fallback scoring for repos
194
+ // analysed with --compress brotli|zstd — the column holds encoded bytes.
269
195
  const compress = await getCompressMode(repoId);
270
196
  const properties = ftsPropertiesFor(compress);
271
- for (const { table, indexName } of FTS_TABLES) {
272
- await ensureFTSIndexViaExecutor(executor, repoId, table, indexName, properties);
273
- }
274
197
  fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit);
275
198
  functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit);
276
199
  classResults = await queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit);
@@ -186,7 +186,9 @@ export declare class LocalBackend {
186
186
  */
187
187
  private semanticSearch;
188
188
  executeCypher(repoName: string, query: string): Promise<any>;
189
+ private trySimpleGraphstoreNodeScan;
189
190
  private cypher;
191
+ private executeLabellessNodeScan;
190
192
  /**
191
193
  * Format raw Cypher result rows as a markdown table for LLM readability.
192
194
  * Falls back to raw result if rows aren't tabular objects.
@@ -79,6 +79,105 @@ export const VALID_NODE_LABELS = new Set([
79
79
  'Route',
80
80
  'Tool',
81
81
  ]);
82
+ const SIMPLE_LABELLESS_MATCH_RE = /^MATCH\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/i;
83
+ const CYPHER_LIMIT_RE = /\bLIMIT\s+(\d+)\s*;?\s*$/i;
84
+ const CYPHER_RELATION_RE = /--|-\[|\]-|->|<-/;
85
+ const NATIVE_UNSAFE_NODE_LABELS = new Set(['Union']);
86
+ function quoteKnownNodeLabels(query) {
87
+ return query;
88
+ }
89
+ function getNativeUnsafeNodeLabel(query) {
90
+ const labelRe = /\(\s*[A-Za-z_][A-Za-z0-9_]*\s*:\s*`?([A-Za-z_][A-Za-z0-9_]*)`?(?=[\s){])/g;
91
+ for (const match of query.matchAll(labelRe)) {
92
+ const label = match[1];
93
+ if (NATIVE_UNSAFE_NODE_LABELS.has(label))
94
+ return label;
95
+ }
96
+ return null;
97
+ }
98
+ function safeNodeLabelForCypher(typeOrLabel, nodeId) {
99
+ const explicit = typeof typeOrLabel === 'string' ? typeOrLabel.trim() : '';
100
+ if (explicit && VALID_NODE_LABELS.has(explicit) && !NATIVE_UNSAFE_NODE_LABELS.has(explicit)) {
101
+ return explicit;
102
+ }
103
+ const id = typeof nodeId === 'string' ? nodeId : '';
104
+ const fromId = id.includes(':') ? id.slice(0, id.indexOf(':')) : '';
105
+ if (fromId && VALID_NODE_LABELS.has(fromId) && !NATIVE_UNSAFE_NODE_LABELS.has(fromId)) {
106
+ return fromId;
107
+ }
108
+ return null;
109
+ }
110
+ function firstNonEmptyString(...values) {
111
+ for (const value of values) {
112
+ if (typeof value === 'string' && value.trim().length > 0)
113
+ return value.trim();
114
+ }
115
+ return '';
116
+ }
117
+ function parseSimpleNodeScanQuery(query) {
118
+ const match = /^MATCH\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)(?:\s*:\s*`?([A-Za-z_][A-Za-z0-9_]*)`?)?\s*\)\s*RETURN\s+(.+?)\s+LIMIT\s+(\d+)\s*;?\s*$/i.exec(query.trim());
119
+ if (!match)
120
+ return null;
121
+ const limit = Number.parseInt(match[4], 10);
122
+ if (!Number.isFinite(limit) || limit <= 0)
123
+ return null;
124
+ return {
125
+ alias: match[1],
126
+ label: match[2],
127
+ returnClause: match[3].trim(),
128
+ limit: Math.min(Math.trunc(limit), 1000),
129
+ };
130
+ }
131
+ function projectSimpleNodeRow(node, label, scan) {
132
+ const row = {};
133
+ const parts = scan.returnClause.split(/\s*,\s*/).filter(Boolean);
134
+ for (const part of parts) {
135
+ const asMatch = /^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i.exec(part.trim());
136
+ const expr = (asMatch?.[1] ?? part).trim();
137
+ const outKey = asMatch?.[2] ?? expr;
138
+ if (expr === scan.alias) {
139
+ row[outKey] = { ...node, _label: label };
140
+ continue;
141
+ }
142
+ const labelsMatch = new RegExp(`^labels\\(\\s*${scan.alias}\\s*\\)\\[0\\]$`, 'i').exec(expr);
143
+ if (labelsMatch) {
144
+ row[outKey] = label;
145
+ continue;
146
+ }
147
+ const propMatch = new RegExp(`^${scan.alias}\\.([A-Za-z_][A-Za-z0-9_]*)$`).exec(expr);
148
+ if (propMatch) {
149
+ row[outKey] = node[propMatch[1]];
150
+ continue;
151
+ }
152
+ return null;
153
+ }
154
+ return row;
155
+ }
156
+ function getSimpleLabellessNodeAlias(query) {
157
+ const trimmed = query.trim();
158
+ const match = SIMPLE_LABELLESS_MATCH_RE.exec(trimmed);
159
+ if (!match)
160
+ return null;
161
+ if (CYPHER_RELATION_RE.test(trimmed))
162
+ return null;
163
+ if (/\bMATCH\b/i.test(trimmed.slice(match[0].length)))
164
+ return null;
165
+ return match[1];
166
+ }
167
+ function getCypherLimit(query) {
168
+ const match = CYPHER_LIMIT_RE.exec(query);
169
+ if (!match)
170
+ return null;
171
+ const limit = Number.parseInt(match[1], 10);
172
+ return Number.isFinite(limit) && limit > 0 ? limit : null;
173
+ }
174
+ function withCypherLimit(query, limit) {
175
+ const safeLimit = Math.max(1, Math.trunc(limit));
176
+ if (CYPHER_LIMIT_RE.test(query)) {
177
+ return query.replace(CYPHER_LIMIT_RE, `LIMIT ${safeLimit}`);
178
+ }
179
+ return `${query.replace(/;\s*$/, '')} LIMIT ${safeLimit}`;
180
+ }
82
181
  /** Valid relation types for impact analysis filtering */
83
182
  export const VALID_RELATION_TYPES = new Set([
84
183
  'CALLS',
@@ -1173,25 +1272,119 @@ export class LocalBackend {
1173
1272
  const repo = await this.resolveRepo(repoName);
1174
1273
  return this.cypher(repo, { query });
1175
1274
  }
1176
- async cypher(repo, params) {
1177
- await this.ensureInitialized(repo.id);
1178
- if (!isCgdbReady(repo.id)) {
1179
- return { error: 'LadybugDB not ready. Index may be corrupted.' };
1275
+ async trySimpleGraphstoreNodeScan(repo, query) {
1276
+ const scan = parseSimpleNodeScanQuery(query);
1277
+ if (!scan)
1278
+ return null;
1279
+ if (scan.label && NATIVE_UNSAFE_NODE_LABELS.has(scan.label))
1280
+ return [];
1281
+ const objectsRoot = path.join(repo.storagePath, 'graphstore', 'objects');
1282
+ try {
1283
+ const stat = await fs.stat(objectsRoot);
1284
+ if (!stat.isDirectory())
1285
+ return null;
1180
1286
  }
1287
+ catch {
1288
+ return null;
1289
+ }
1290
+ const rows = [];
1291
+ const visitDir = async (dir) => {
1292
+ if (rows.length >= scan.limit)
1293
+ return;
1294
+ let entries;
1295
+ try {
1296
+ entries = await fs.readdir(dir, { withFileTypes: true });
1297
+ }
1298
+ catch {
1299
+ return;
1300
+ }
1301
+ for (const entry of entries) {
1302
+ if (rows.length >= scan.limit)
1303
+ return;
1304
+ const entryPath = path.join(dir, entry.name);
1305
+ if (entry.isDirectory()) {
1306
+ await visitDir(entryPath);
1307
+ continue;
1308
+ }
1309
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
1310
+ continue;
1311
+ try {
1312
+ const raw = await fs.readFile(entryPath, 'utf-8');
1313
+ const obj = JSON.parse(raw);
1314
+ const id = typeof obj.id === 'string' ? obj.id : '';
1315
+ if (!id || typeof obj.from === 'string' || typeof obj.to === 'string')
1316
+ continue;
1317
+ const label = id.includes(':') ? id.slice(0, id.indexOf(':')) : '';
1318
+ if (!label || (scan.label && label !== scan.label))
1319
+ continue;
1320
+ if (NATIVE_UNSAFE_NODE_LABELS.has(label))
1321
+ continue;
1322
+ const row = projectSimpleNodeRow(obj, label, scan);
1323
+ if (row)
1324
+ rows.push(row);
1325
+ }
1326
+ catch {
1327
+ // Ignore malformed graphstore objects; the native path remains available.
1328
+ }
1329
+ }
1330
+ };
1331
+ await visitDir(objectsRoot);
1332
+ return rows;
1333
+ }
1334
+ async cypher(repo, params) {
1181
1335
  // Block write operations (defense-in-depth — DB is already read-only)
1182
1336
  if (isWriteQuery(params.query)) {
1183
1337
  return {
1184
1338
  error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
1185
1339
  };
1186
1340
  }
1341
+ const query = quoteKnownNodeLabels(params.query);
1342
+ const unsafeLabel = getNativeUnsafeNodeLabel(query);
1343
+ if (unsafeLabel) {
1344
+ return [];
1345
+ }
1346
+ const graphstoreRows = await this.trySimpleGraphstoreNodeScan(repo, query);
1347
+ if (graphstoreRows) {
1348
+ return graphstoreRows;
1349
+ }
1350
+ await this.ensureInitialized(repo.id);
1351
+ if (!isCgdbReady(repo.id)) {
1352
+ return { error: 'LadybugDB not ready. Index may be corrupted.' };
1353
+ }
1354
+ const labellessAlias = getSimpleLabellessNodeAlias(query);
1187
1355
  try {
1188
- const result = await executeQuery(repo.id, params.query);
1356
+ const result = labellessAlias
1357
+ ? await this.executeLabellessNodeScan(repo.id, query, labellessAlias)
1358
+ : await executeQuery(repo.id, query);
1189
1359
  return result;
1190
1360
  }
1191
1361
  catch (err) {
1192
1362
  return { error: err.message || 'Query failed' };
1193
1363
  }
1194
1364
  }
1365
+ async executeLabellessNodeScan(repoId, query, alias) {
1366
+ const limit = getCypherLimit(query) ?? 100;
1367
+ const rows = [];
1368
+ let lastError = null;
1369
+ for (const label of VALID_NODE_LABELS) {
1370
+ if (NATIVE_UNSAFE_NODE_LABELS.has(label))
1371
+ continue;
1372
+ if (rows.length >= limit)
1373
+ break;
1374
+ const labelQuery = withCypherLimit(query.replace(SIMPLE_LABELLESS_MATCH_RE, `MATCH (${alias}:\`${label}\`)`), limit - rows.length);
1375
+ try {
1376
+ const labelRows = await executeQuery(repoId, labelQuery);
1377
+ rows.push(...labelRows);
1378
+ }
1379
+ catch (err) {
1380
+ lastError = err instanceof Error ? err : new Error(String(err));
1381
+ }
1382
+ }
1383
+ if (rows.length === 0 && lastError) {
1384
+ throw lastError;
1385
+ }
1386
+ return rows.slice(0, limit);
1387
+ }
1195
1388
  /**
1196
1389
  * Format raw Cypher result rows as a markdown table for LLM readability.
1197
1390
  * Falls back to raw result if rows aren't tabular objects.
@@ -1436,7 +1629,7 @@ export class LocalBackend {
1436
1629
  const symbol = {
1437
1630
  id: (r.id ?? r[0]),
1438
1631
  name: (r.name ?? r[1]),
1439
- type: (r.type ?? r[2] ?? ''),
1632
+ type: firstNonEmptyString(r.type, r.__cgLabel, r[2]),
1440
1633
  filePath: (r.filePath ?? r[3]),
1441
1634
  startLine: (r.startLine ?? r[4]),
1442
1635
  endLine: (r.endLine ?? r[5]),
@@ -1476,7 +1669,7 @@ export class LocalBackend {
1476
1669
  const normalized = rows.map((r) => ({
1477
1670
  id: (r.id ?? r[0]),
1478
1671
  name: (r.name ?? r[1]),
1479
- type: (r.type ?? r[2] ?? ''),
1672
+ type: firstNonEmptyString(r.type, r.__cgLabel, r[2]),
1480
1673
  filePath: (r.filePath ?? r[3]),
1481
1674
  startLine: (r.startLine ?? r[4]),
1482
1675
  endLine: (r.endLine ?? r[5]),
@@ -2266,6 +2459,10 @@ export class LocalBackend {
2266
2459
  const impacted = [];
2267
2460
  const visited = new Set([symId]);
2268
2461
  let frontier = [symId];
2462
+ const frontierTypes = new Map();
2463
+ const symLabel = safeNodeLabelForCypher(symType, symId);
2464
+ if (symLabel)
2465
+ frontierTypes.set(symId, symLabel);
2269
2466
  let traversalComplete = true;
2270
2467
  // Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
2271
2468
  // point to Constructor nodes and IMPORTS edges point to File nodes — not
@@ -2279,16 +2476,16 @@ export class LocalBackend {
2279
2476
  // Run both seed queries in parallel — they are independent.
2280
2477
  const [ctorRows, fileRows] = await Promise.all([
2281
2478
  executeParameterized(repo.id, `
2282
- MATCH (n)-[hm:CodeRelation]->(c:Constructor)
2479
+ MATCH (n:${symLabel ?? symType})-[hm:CodeRelation]->(c:Constructor)
2283
2480
  WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
2284
- RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath
2481
+ RETURN c.id AS id, c.name AS name, 'Constructor' AS type, c.filePath AS filePath
2285
2482
  `, { symId }),
2286
2483
  // Restrict to DEFINES edges only — other File->Class edge types (if
2287
2484
  // any) should not be treated as the owning file relationship.
2288
2485
  executeParameterized(repo.id, `
2289
- MATCH (f:File)-[rel:CodeRelation]->(n)
2486
+ MATCH (f:File)-[rel:CodeRelation]->(n:${symLabel ?? symType})
2290
2487
  WHERE n.id = $symId AND rel.type = 'DEFINES'
2291
- RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath
2488
+ RETURN f.id AS id, f.name AS name, 'File' AS type, f.filePath AS filePath
2292
2489
  `, { symId }),
2293
2490
  ]);
2294
2491
  for (const r of ctorRows) {
@@ -2296,6 +2493,9 @@ export class LocalBackend {
2296
2493
  if (rid && !visited.has(rid)) {
2297
2494
  visited.add(rid);
2298
2495
  frontier.push(rid);
2496
+ const label = safeNodeLabelForCypher(r.type ?? r[2], rid);
2497
+ if (label)
2498
+ frontierTypes.set(rid, label);
2299
2499
  }
2300
2500
  }
2301
2501
  for (const r of fileRows) {
@@ -2303,6 +2503,9 @@ export class LocalBackend {
2303
2503
  if (rid && !visited.has(rid)) {
2304
2504
  visited.add(rid);
2305
2505
  frontier.push(rid);
2506
+ const label = safeNodeLabelForCypher(r.type ?? r[2], rid);
2507
+ if (label)
2508
+ frontierTypes.set(rid, label);
2306
2509
  }
2307
2510
  }
2308
2511
  }
@@ -2312,13 +2515,23 @@ export class LocalBackend {
2312
2515
  }
2313
2516
  for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
2314
2517
  const nextFrontier = [];
2315
- // Batch frontier nodes into a single Cypher query per depth level
2316
- const idList = frontier.map((id) => `'${id.replace(/'/g, "''")}'`).join(', ');
2317
- const query = direction === 'upstream'
2318
- ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
2319
- : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
2320
2518
  try {
2321
- const related = await executeQuery(repo.id, query);
2519
+ const frontierByLabel = new Map();
2520
+ for (const id of frontier) {
2521
+ const label = frontierTypes.get(id) ?? safeNodeLabelForCypher(undefined, id) ?? '';
2522
+ const ids = frontierByLabel.get(label) ?? [];
2523
+ ids.push(id);
2524
+ frontierByLabel.set(label, ids);
2525
+ }
2526
+ const related = [];
2527
+ for (const [label, ids] of frontierByLabel) {
2528
+ const idList = ids.map((id) => `'${id.replace(/'/g, "''")}'`).join(', ');
2529
+ const targetPattern = label ? `(n:${label})` : '(n)';
2530
+ const query = direction === 'upstream'
2531
+ ? `MATCH (caller)-[r:CodeRelation]->${targetPattern} WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
2532
+ : `MATCH ${targetPattern}-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
2533
+ related.push(...(await executeQuery(repo.id, query)));
2534
+ }
2322
2535
  for (const rel of related) {
2323
2536
  const relId = rel.id || rel[1];
2324
2537
  const filePath = rel.filePath || rel[4] || '';
@@ -2327,6 +2540,10 @@ export class LocalBackend {
2327
2540
  if (!visited.has(relId)) {
2328
2541
  visited.add(relId);
2329
2542
  nextFrontier.push(relId);
2543
+ const relTypeLabel = firstNonEmptyString(rel.type, rel.__cgLabel, rel[3]);
2544
+ const relLabel = safeNodeLabelForCypher(relTypeLabel, relId);
2545
+ if (relLabel)
2546
+ frontierTypes.set(relId, relLabel);
2330
2547
  const storedConfidence = rel.confidence ?? rel[6];
2331
2548
  const relationType = rel.relType || rel[5];
2332
2549
  // Prefer the stored confidence from the graph (set at analysis time);
@@ -2338,7 +2555,7 @@ export class LocalBackend {
2338
2555
  depth,
2339
2556
  id: relId,
2340
2557
  name: rel.name || rel[2],
2341
- type: rel.type || rel[3],
2558
+ type: relTypeLabel,
2342
2559
  filePath,
2343
2560
  relationType,
2344
2561
  confidence: effectiveConfidence,
@@ -12,27 +12,8 @@
12
12
  */
13
13
 
14
14
  const fs = require('fs');
15
- const os = require('os');
16
15
  const path = require('path');
17
- const { spawnSync, spawn } = require('child_process');
18
-
19
- /**
20
- * Decide whether background auto-reindex is opted in. Two equivalent signals:
21
- * 1. CODRAGRAPH_AUTO_REINDEX=1 in env (good for shells, CI)
22
- * 2. `{ "autoReindex": true }` in ~/.codragraph/config.json (good for GUI
23
- * editor launches on Windows, where shell env doesn't propagate to
24
- * hook child processes reliably)
25
- */
26
- function isAutoReindexEnabled() {
27
- if (process.env.CODRAGRAPH_AUTO_REINDEX === '1') return true;
28
- try {
29
- const configPath = path.join(os.homedir(), '.codragraph', 'config.json');
30
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
31
- return config && config.autoReindex === true;
32
- } catch {
33
- return false;
34
- }
35
- }
16
+ const { spawnSync } = require('child_process');
36
17
 
37
18
  /**
38
19
  * Read JSON input from stdin synchronously.
@@ -52,7 +33,7 @@ function readInput() {
52
33
  */
53
34
  function findCodraGraphDir(startDir) {
54
35
  let dir = startDir || process.cwd();
55
- for (let i = 0; i < 5; i++) {
36
+ for (let i = 0; i < 8; i++) {
56
37
  const candidate = path.join(dir, '.codragraph');
57
38
  if (fs.existsSync(candidate)) return candidate;
58
39
  const parent = path.dirname(dir);
@@ -138,21 +119,9 @@ function resolveCliPath() {
138
119
  return cliPath;
139
120
  }
140
121
 
141
- function isRunningUnderBun() {
142
- const userAgent = (process.env.npm_config_user_agent || '').toLowerCase();
143
- const execBase = path.basename(process.env.npm_execpath || '').toLowerCase();
144
- return userAgent.startsWith('bun/') || execBase === 'bun' || execBase === 'bun.exe';
145
- }
146
-
147
- function getPackageRunnerArgs(args) {
148
- const useBun = isRunningUnderBun();
149
- if (useBun) return { bin: 'bunx', args: ['@codragraph/cli', ...args] };
150
- return { bin: 'npx', args: ['-y', '@codragraph/cli', ...args] };
151
- }
152
-
153
122
  /**
154
123
  * Spawn a codragraph CLI command synchronously.
155
- * Returns the stderr output (KuzuDB captures stdout at OS level).
124
+ * Returns the stderr output (native graph bindings may capture stdout).
156
125
  */
157
126
  function runCodraGraphCli(cliPath, args, cwd, timeout) {
158
127
  const isWin = process.platform === 'win32';
@@ -164,20 +133,20 @@ function runCodraGraphCli(cliPath, args, cwd, timeout) {
164
133
  stdio: ['pipe', 'pipe', 'pipe'],
165
134
  });
166
135
  }
167
- // Package-runner fallback: on Windows, Node 22's spawn refuses to launch
168
- // `.cmd` shims directly, so route through `cmd /c`. POSIX direct-spawn is fine.
169
- const runner = getPackageRunnerArgs(args);
136
+ // Hot-path hook fallback: try an already-installed binary only. Never invoke
137
+ // npx/bunx from a hook because that can fetch/install packages and make the
138
+ // agent appear hung.
170
139
  if (isWin) {
171
- return spawnSync('cmd', ['/c', runner.bin, ...runner.args], {
140
+ return spawnSync('cmd', ['/c', 'codragraph', ...args], {
172
141
  encoding: 'utf-8',
173
- timeout: timeout + 5000,
142
+ timeout,
174
143
  cwd,
175
144
  stdio: ['pipe', 'pipe', 'pipe'],
176
145
  });
177
146
  }
178
- return spawnSync(runner.bin, runner.args, {
147
+ return spawnSync('codragraph', args, {
179
148
  encoding: 'utf-8',
180
- timeout: timeout + 5000,
149
+ timeout,
181
150
  cwd,
182
151
  stdio: ['pipe', 'pipe', 'pipe'],
183
152
  });
@@ -229,11 +198,10 @@ function sendHookResponse(hookEventName, message) {
229
198
  /**
230
199
  * PostToolUse handler — detect index staleness after git mutations.
231
200
  *
232
- * Instead of spawning a full `codragraph analyze` synchronously (which blocks
233
- * the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
234
- * lightweight staleness check: compare `git rev-parse HEAD` against the
235
- * lastCommit stored in `.codragraph/meta.json`. If they differ, notify the
236
- * agent so it can decide when to reindex.
201
+ * Hooks must not own writes to `.codragraph`. They run inside the agent's hot
202
+ * path, so starting analyze from here can contend with MCP/LadybugDB and make
203
+ * normal edits feel hung. Keep this to a cheap metadata comparison and let the
204
+ * user or agent run the CLI explicitly when fresh graph context is required.
237
205
  */
238
206
  function handlePostToolUse(input) {
239
207
  const toolName = input.tool_name || '';
@@ -283,85 +251,10 @@ function handlePostToolUse(input) {
283
251
  const analyzeArgs = `analyze${hadEmbeddings ? ' --embeddings' : ''}`;
284
252
  const analyzeCmd = `npx @codragraph/cli ${analyzeArgs} (or bunx @codragraph/cli ${analyzeArgs})`;
285
253
 
286
- // Opt-in background auto-reindex.
287
- // Default stays as notification-only because spawning analyze while an MCP
288
- // server holds LadybugDB will fail with a database-busy error — the
289
- // notification path lets the agent reindex at a quiet moment instead.
290
- // Power users who run MCP outside Claude Code's lifecycle can opt in via
291
- // CODRAGRAPH_AUTO_REINDEX=1 or `{ "autoReindex": true }` in
292
- // ~/.codragraph/config.json.
293
- if (isAutoReindexEnabled()) {
294
- // The "coalesce" file is a single-process gate: it exists only while a
295
- // reindex is in flight. The spawned analyze removes it on exit (success or
296
- // failure) via CODRAGRAPH_REINDEX_LOCK_PATH; the 10-min mtime fallback
297
- // catches the rare crash that bypasses analyze's exit handler.
298
- const coalescePath = path.join(codragraphDir, '.reindex.coalesce');
299
- const crashSafetyTtlMs = 10 * 60 * 1000;
300
- let inFlight = false;
301
- try {
302
- const stat = fs.statSync(coalescePath);
303
- if (Date.now() - stat.mtimeMs < crashSafetyTtlMs) inFlight = true;
304
- } catch {
305
- /* no coalesce file — no reindex in flight */
306
- }
307
-
308
- if (!inFlight) {
309
- try {
310
- fs.writeFileSync(coalescePath, String(process.pid));
311
- } catch {
312
- /* best-effort — gate is for coalescing, not correctness */
313
- }
314
-
315
- const cliPath = resolveCliPath();
316
- const reindexArgs = hadEmbeddings
317
- ? ['analyze', '--embeddings', '--no-setup']
318
- : ['analyze', '--no-setup'];
319
- const spawnEnv = { ...process.env, CODRAGRAPH_REINDEX_LOCK_PATH: coalescePath };
320
- const spawnOpts = {
321
- cwd,
322
- detached: true,
323
- stdio: 'ignore',
324
- windowsHide: true,
325
- env: spawnEnv,
326
- };
327
- try {
328
- let child;
329
- if (cliPath) {
330
- child = spawn(process.execPath, [cliPath, ...reindexArgs], spawnOpts);
331
- } else if (process.platform === 'win32') {
332
- const runner = getPackageRunnerArgs(reindexArgs);
333
- child = spawn('cmd', ['/c', runner.bin, ...runner.args], spawnOpts);
334
- } else {
335
- const runner = getPackageRunnerArgs(reindexArgs);
336
- child = spawn(runner.bin, runner.args, spawnOpts);
337
- }
338
- child.unref();
339
- } catch {
340
- /* spawn failed — fall through to notification */
341
- }
342
-
343
- sendHookResponse(
344
- 'PostToolUse',
345
- `CodraGraph: auto-reindex started in background ` +
346
- `(HEAD ${lastCommit ? lastCommit.slice(0, 7) : 'never'} → ${currentHead.slice(0, 7)}). ` +
347
- `If an MCP server is currently holding the database, the reindex will fail silently — ` +
348
- `run \`${analyzeCmd}\` manually after closing the agent session.`,
349
- );
350
- return;
351
- }
352
-
353
- sendHookResponse(
354
- 'PostToolUse',
355
- `CodraGraph: auto-reindex coalesced — another reindex is in flight (will pick up your latest commit when it finishes).`,
356
- );
357
- return;
358
- }
359
-
360
254
  sendHookResponse(
361
255
  'PostToolUse',
362
256
  `CodraGraph index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
363
- `Run \`${analyzeCmd}\` to update the knowledge graph. ` +
364
- `Set CODRAGRAPH_AUTO_REINDEX=1 (or autoReindex: true in ~/.codragraph/config.json) for background auto-reindex.`,
257
+ `Run \`${analyzeCmd}\` when you need fresh graph context. Hooks never start analyze in the background.`,
365
258
  );
366
259
  }
367
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codragraph/cli",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": {
6
6
  "name": "Thinqmesh Technologies",