@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.
- package/README.md +31 -7
- package/dist/cli/ai-context.js +297 -0
- package/dist/cli/index.js +31 -11
- package/dist/cli/tool.js +73 -33
- package/dist/config/ignore-service.js +1 -0
- package/dist/core/cgdb/pool-adapter.js +130 -20
- package/dist/core/ingestion/parsing-processor.js +7 -1
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +4 -0
- package/dist/core/ingestion/workers/parse-worker.js +1 -1
- package/dist/core/ingestion/workers/worker-pool.d.ts +14 -1
- package/dist/core/ingestion/workers/worker-pool.js +33 -17
- package/dist/core/run-analyze.js +28 -5
- package/dist/core/search/bm25-index.d.ts +0 -11
- package/dist/core/search/bm25-index.js +7 -84
- package/dist/mcp/local/local-backend.d.ts +2 -0
- package/dist/mcp/local/local-backend.js +235 -18
- package/hooks/claude/codragraph-hook.cjs +15 -122
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
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
|
|
1177
|
-
|
|
1178
|
-
if (!
|
|
1179
|
-
return
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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 <
|
|
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 (
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
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',
|
|
140
|
+
return spawnSync('cmd', ['/c', 'codragraph', ...args], {
|
|
172
141
|
encoding: 'utf-8',
|
|
173
|
-
timeout
|
|
142
|
+
timeout,
|
|
174
143
|
cwd,
|
|
175
144
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
176
145
|
});
|
|
177
146
|
}
|
|
178
|
-
return spawnSync(
|
|
147
|
+
return spawnSync('codragraph', args, {
|
|
179
148
|
encoding: 'utf-8',
|
|
180
|
-
timeout
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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}\`
|
|
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
|
|