gitnexus 1.3.11 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +194 -194
  2. package/dist/cli/ai-context.js +87 -105
  3. package/dist/cli/analyze.js +0 -8
  4. package/dist/cli/index.js +25 -15
  5. package/dist/cli/setup.js +19 -17
  6. package/dist/core/augmentation/engine.js +20 -20
  7. package/dist/core/embeddings/embedding-pipeline.js +26 -26
  8. package/dist/core/ingestion/ast-cache.js +2 -3
  9. package/dist/core/ingestion/call-processor.js +5 -7
  10. package/dist/core/ingestion/cluster-enricher.js +16 -16
  11. package/dist/core/ingestion/pipeline.js +2 -23
  12. package/dist/core/ingestion/tree-sitter-queries.js +484 -484
  13. package/dist/core/ingestion/utils.js +5 -1
  14. package/dist/core/ingestion/workers/worker-pool.js +0 -8
  15. package/dist/core/kuzu/kuzu-adapter.js +19 -11
  16. package/dist/core/kuzu/schema.js +287 -287
  17. package/dist/core/search/bm25-index.js +6 -7
  18. package/dist/core/search/hybrid-search.js +3 -3
  19. package/dist/core/wiki/diagrams.d.ts +27 -0
  20. package/dist/core/wiki/diagrams.js +163 -0
  21. package/dist/core/wiki/generator.d.ts +50 -2
  22. package/dist/core/wiki/generator.js +548 -49
  23. package/dist/core/wiki/graph-queries.d.ts +42 -0
  24. package/dist/core/wiki/graph-queries.js +276 -97
  25. package/dist/core/wiki/html-viewer.js +192 -192
  26. package/dist/core/wiki/llm-client.js +73 -11
  27. package/dist/core/wiki/prompts.d.ts +52 -8
  28. package/dist/core/wiki/prompts.js +200 -86
  29. package/dist/mcp/core/kuzu-adapter.d.ts +3 -1
  30. package/dist/mcp/core/kuzu-adapter.js +44 -13
  31. package/dist/mcp/local/local-backend.js +128 -128
  32. package/dist/mcp/resources.js +42 -42
  33. package/dist/mcp/server.js +19 -18
  34. package/dist/mcp/tools.js +103 -93
  35. package/hooks/claude/gitnexus-hook.cjs +155 -238
  36. package/hooks/claude/pre-tool-use.sh +79 -79
  37. package/hooks/claude/session-start.sh +42 -42
  38. package/package.json +96 -96
  39. package/scripts/patch-tree-sitter-swift.cjs +74 -74
  40. package/skills/gitnexus-cli.md +82 -82
  41. package/skills/gitnexus-debugging.md +89 -89
  42. package/skills/gitnexus-exploring.md +78 -78
  43. package/skills/gitnexus-guide.md +64 -64
  44. package/skills/gitnexus-impact-analysis.md +97 -97
  45. package/skills/gitnexus-pr-review.md +163 -163
  46. package/skills/gitnexus-refactoring.md +121 -121
  47. package/vendor/leiden/index.cjs +355 -355
  48. package/vendor/leiden/utils.cjs +392 -392
  49. package/dist/cli/lazy-action.d.ts +0 -6
  50. package/dist/cli/lazy-action.js +0 -18
  51. package/dist/mcp/compatible-stdio-transport.d.ts +0 -25
  52. package/dist/mcp/compatible-stdio-transport.js +0 -200
@@ -29,6 +29,30 @@ export interface ProcessInfo {
29
29
  type: string;
30
30
  }>;
31
31
  }
32
+ export interface CommunityFileGroup {
33
+ communityId: string;
34
+ label: string;
35
+ keywords: string[];
36
+ description: string;
37
+ cohesion: number;
38
+ symbolCount: number;
39
+ files: string[];
40
+ secondaryFiles: string[];
41
+ }
42
+ export interface InterCommunityEdge {
43
+ fromLabel: string;
44
+ toLabel: string;
45
+ callCount: number;
46
+ sampleCalls: Array<{
47
+ fromName: string;
48
+ toName: string;
49
+ }>;
50
+ }
51
+ export interface CrossCommunityProcess {
52
+ label: string;
53
+ communities: string[];
54
+ stepCount: number;
55
+ }
32
56
  /**
33
57
  * Initialize the KuzuDB connection for wiki generation.
34
58
  */
@@ -60,6 +84,11 @@ export declare function getInterModuleCallEdges(filePaths: string[]): Promise<{
60
84
  outgoing: CallEdge[];
61
85
  incoming: CallEdge[];
62
86
  }>;
87
+ /**
88
+ * Get files that are call-graph neighbors of the given files but not in the set.
89
+ * Used to assign new files to existing modules during incremental updates.
90
+ */
91
+ export declare function getCallGraphNeighborFiles(filePaths: string[]): Promise<string[]>;
63
92
  /**
64
93
  * Get processes (execution flows) that pass through a set of files.
65
94
  * Returns top N by step count.
@@ -78,3 +107,16 @@ export declare function getInterModuleEdgesForOverview(moduleFiles: Record<strin
78
107
  to: string;
79
108
  count: number;
80
109
  }>>;
110
+ /**
111
+ * Get community-to-file mapping from Leiden-detected clusters.
112
+ * Each file is assigned to its majority community; secondary memberships tracked separately.
113
+ */
114
+ export declare function getCommunityFileMapping(): Promise<CommunityFileGroup[]>;
115
+ /**
116
+ * Get call edges between communities for inter-module coupling analysis.
117
+ */
118
+ export declare function getInterCommunityCallEdges(): Promise<InterCommunityEdge[]>;
119
+ /**
120
+ * Get cross-community execution flows.
121
+ */
122
+ export declare function getCrossCommunityProcesses(): Promise<CrossCommunityProcess[]>;
@@ -10,7 +10,7 @@ const REPO_ID = '__wiki__';
10
10
  * Initialize the KuzuDB connection for wiki generation.
11
11
  */
12
12
  export async function initWikiDb(kuzuPath) {
13
- await initKuzu(REPO_ID, kuzuPath);
13
+ await initKuzu(REPO_ID, kuzuPath, { pinned: true });
14
14
  }
15
15
  /**
16
16
  * Close the KuzuDB connection.
@@ -22,11 +22,11 @@ export async function closeWikiDb() {
22
22
  * Get all source files with their exported symbol names and types.
23
23
  */
24
24
  export async function getFilesWithExports() {
25
- const rows = await executeQuery(REPO_ID, `
26
- MATCH (f:File)-[:CodeRelation {type: 'DEFINES'}]->(n)
27
- WHERE n.isExported = true
28
- RETURN f.filePath AS filePath, n.name AS name, labels(n)[0] AS type
29
- ORDER BY f.filePath
25
+ const rows = await executeQuery(REPO_ID, `
26
+ MATCH (f:File)-[:CodeRelation {type: 'DEFINES'}]->(n)
27
+ WHERE n.isExported = true
28
+ RETURN f.filePath AS filePath, n.name AS name, labels(n)[0] AS type
29
+ ORDER BY f.filePath
30
30
  `);
31
31
  const fileMap = new Map();
32
32
  for (const row of rows) {
@@ -46,10 +46,10 @@ export async function getFilesWithExports() {
46
46
  * Get all files tracked in the graph (including those with no exports).
47
47
  */
48
48
  export async function getAllFiles() {
49
- const rows = await executeQuery(REPO_ID, `
50
- MATCH (f:File)
51
- RETURN f.filePath AS filePath
52
- ORDER BY f.filePath
49
+ const rows = await executeQuery(REPO_ID, `
50
+ MATCH (f:File)
51
+ RETURN f.filePath AS filePath
52
+ ORDER BY f.filePath
53
53
  `);
54
54
  return rows.map(r => r.filePath || r[0]);
55
55
  }
@@ -57,11 +57,11 @@ export async function getAllFiles() {
57
57
  * Get inter-file call edges (calls between different files).
58
58
  */
59
59
  export async function getInterFileCallEdges() {
60
- const rows = await executeQuery(REPO_ID, `
61
- MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
62
- WHERE a.filePath <> b.filePath
63
- RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
64
- b.filePath AS toFile, b.name AS toName
60
+ const rows = await executeQuery(REPO_ID, `
61
+ MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
62
+ WHERE a.filePath <> b.filePath
63
+ RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
64
+ b.filePath AS toFile, b.name AS toName
65
65
  `);
66
66
  return rows.map(r => ({
67
67
  fromFile: r.fromFile || r[0],
@@ -77,11 +77,11 @@ export async function getIntraModuleCallEdges(filePaths) {
77
77
  if (filePaths.length === 0)
78
78
  return [];
79
79
  const fileList = filePaths.map(f => `'${f.replace(/'/g, "''")}'`).join(', ');
80
- const rows = await executeQuery(REPO_ID, `
81
- MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
82
- WHERE a.filePath IN [${fileList}] AND b.filePath IN [${fileList}]
83
- RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
84
- b.filePath AS toFile, b.name AS toName
80
+ const rows = await executeQuery(REPO_ID, `
81
+ MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
82
+ WHERE a.filePath IN [${fileList}] AND b.filePath IN [${fileList}]
83
+ RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
84
+ b.filePath AS toFile, b.name AS toName
85
85
  `);
86
86
  return rows.map(r => ({
87
87
  fromFile: r.fromFile || r[0],
@@ -97,19 +97,19 @@ export async function getInterModuleCallEdges(filePaths) {
97
97
  if (filePaths.length === 0)
98
98
  return { outgoing: [], incoming: [] };
99
99
  const fileList = filePaths.map(f => `'${f.replace(/'/g, "''")}'`).join(', ');
100
- const outRows = await executeQuery(REPO_ID, `
101
- MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
102
- WHERE a.filePath IN [${fileList}] AND NOT b.filePath IN [${fileList}]
103
- RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
104
- b.filePath AS toFile, b.name AS toName
105
- LIMIT 30
100
+ const outRows = await executeQuery(REPO_ID, `
101
+ MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
102
+ WHERE a.filePath IN [${fileList}] AND NOT b.filePath IN [${fileList}]
103
+ RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
104
+ b.filePath AS toFile, b.name AS toName
105
+ LIMIT 30
106
106
  `);
107
- const inRows = await executeQuery(REPO_ID, `
108
- MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
109
- WHERE NOT a.filePath IN [${fileList}] AND b.filePath IN [${fileList}]
110
- RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
111
- b.filePath AS toFile, b.name AS toName
112
- LIMIT 30
107
+ const inRows = await executeQuery(REPO_ID, `
108
+ MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b)
109
+ WHERE NOT a.filePath IN [${fileList}] AND b.filePath IN [${fileList}]
110
+ RETURN DISTINCT a.filePath AS fromFile, a.name AS fromName,
111
+ b.filePath AS toFile, b.name AS toName
112
+ LIMIT 30
113
113
  `);
114
114
  return {
115
115
  outgoing: outRows.map(r => ({
@@ -126,6 +126,50 @@ export async function getInterModuleCallEdges(filePaths) {
126
126
  })),
127
127
  };
128
128
  }
129
+ /**
130
+ * Get files that are call-graph neighbors of the given files but not in the set.
131
+ * Used to assign new files to existing modules during incremental updates.
132
+ */
133
+ export async function getCallGraphNeighborFiles(filePaths) {
134
+ if (filePaths.length === 0)
135
+ return [];
136
+ const fileList = filePaths.map(f => `'${f.replace(/'/g, "''")}'`).join(', ');
137
+ const rows = await executeQuery(REPO_ID, `
138
+ MATCH (a)-[:CodeRelation {type: 'CALLS'}]-(b)
139
+ WHERE a.filePath IN [${fileList}] AND NOT b.filePath IN [${fileList}]
140
+ RETURN DISTINCT b.filePath AS neighborFile
141
+ `);
142
+ return rows.map(r => r.neighborFile || r[0]);
143
+ }
144
+ /**
145
+ * Shared helper: group step rows by process ID.
146
+ */
147
+ function groupStepsByProcess(procRows, stepRows) {
148
+ const stepsByProc = new Map();
149
+ for (const s of stepRows) {
150
+ const procId = s.processId || s[4];
151
+ if (!stepsByProc.has(procId))
152
+ stepsByProc.set(procId, []);
153
+ stepsByProc.get(procId).push({
154
+ step: s.step || s[3] || 0,
155
+ name: s.name || s[0],
156
+ filePath: s.filePath || s[1],
157
+ type: s.type || s[2],
158
+ });
159
+ }
160
+ return procRows.map(row => {
161
+ const procId = row.id || row[0];
162
+ const steps = stepsByProc.get(procId) || [];
163
+ steps.sort((a, b) => a.step - b.step);
164
+ return {
165
+ id: procId,
166
+ label: row.label || row[1] || procId,
167
+ type: row.type || row[2] || 'unknown',
168
+ stepCount: row.stepCount || row[3] || 0,
169
+ steps,
170
+ };
171
+ });
172
+ }
129
173
  /**
130
174
  * Get processes (execution flows) that pass through a set of files.
131
175
  * Returns top N by step count.
@@ -134,78 +178,57 @@ export async function getProcessesForFiles(filePaths, limit = 5) {
134
178
  if (filePaths.length === 0)
135
179
  return [];
136
180
  const fileList = filePaths.map(f => `'${f.replace(/'/g, "''")}'`).join(', ');
137
- // Find processes that have steps in the given files
138
- const procRows = await executeQuery(REPO_ID, `
139
- MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
140
- WHERE s.filePath IN [${fileList}]
141
- RETURN DISTINCT p.id AS id, p.heuristicLabel AS label,
142
- p.processType AS type, p.stepCount AS stepCount
143
- ORDER BY stepCount DESC
144
- LIMIT ${limit}
181
+ // Query 1: Find processes that have steps in the given files
182
+ const procRows = await executeQuery(REPO_ID, `
183
+ MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
184
+ WHERE s.filePath IN [${fileList}]
185
+ RETURN DISTINCT p.id AS id, p.heuristicLabel AS label,
186
+ p.processType AS type, p.stepCount AS stepCount
187
+ ORDER BY stepCount DESC
188
+ LIMIT ${limit}
145
189
  `);
146
- const processes = [];
147
- for (const row of procRows) {
148
- const procId = row.id || row[0];
149
- const label = row.label || row[1] || procId;
150
- const type = row.type || row[2] || 'unknown';
151
- const stepCount = row.stepCount || row[3] || 0;
152
- // Get the full step trace for this process
153
- const stepRows = await executeQuery(REPO_ID, `
154
- MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${procId.replace(/'/g, "''")}'})
155
- RETURN s.name AS name, s.filePath AS filePath, labels(s)[0] AS type, r.step AS step
156
- ORDER BY r.step
157
- `);
158
- processes.push({
159
- id: procId,
160
- label,
161
- type,
162
- stepCount,
163
- steps: stepRows.map(s => ({
164
- step: s.step || s[3] || 0,
165
- name: s.name || s[0],
166
- filePath: s.filePath || s[1],
167
- type: s.type || s[2],
168
- })),
169
- });
170
- }
171
- return processes;
190
+ if (procRows.length === 0)
191
+ return [];
192
+ // Query 2: Fetch ALL steps for ALL matched processes in one query
193
+ const procIdList = procRows.map(r => {
194
+ const id = r.id || r[0];
195
+ return `'${id.replace(/'/g, "''")}'`;
196
+ }).join(', ');
197
+ const stepRows = await executeQuery(REPO_ID, `
198
+ MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
199
+ WHERE p.id IN [${procIdList}]
200
+ RETURN s.name AS name, s.filePath AS filePath, labels(s)[0] AS type,
201
+ r.step AS step, p.id AS processId
202
+ ORDER BY p.id, r.step
203
+ `);
204
+ return groupStepsByProcess(procRows, stepRows);
172
205
  }
173
206
  /**
174
207
  * Get all processes in the graph (for overview page).
175
208
  */
176
209
  export async function getAllProcesses(limit = 20) {
177
- const procRows = await executeQuery(REPO_ID, `
178
- MATCH (p:Process)
179
- RETURN p.id AS id, p.heuristicLabel AS label,
180
- p.processType AS type, p.stepCount AS stepCount
181
- ORDER BY stepCount DESC
182
- LIMIT ${limit}
210
+ const procRows = await executeQuery(REPO_ID, `
211
+ MATCH (p:Process)
212
+ RETURN p.id AS id, p.heuristicLabel AS label,
213
+ p.processType AS type, p.stepCount AS stepCount
214
+ ORDER BY stepCount DESC
215
+ LIMIT ${limit}
183
216
  `);
184
- const processes = [];
185
- for (const row of procRows) {
186
- const procId = row.id || row[0];
187
- const label = row.label || row[1] || procId;
188
- const type = row.type || row[2] || 'unknown';
189
- const stepCount = row.stepCount || row[3] || 0;
190
- const stepRows = await executeQuery(REPO_ID, `
191
- MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${procId.replace(/'/g, "''")}'})
192
- RETURN s.name AS name, s.filePath AS filePath, labels(s)[0] AS type, r.step AS step
193
- ORDER BY r.step
194
- `);
195
- processes.push({
196
- id: procId,
197
- label,
198
- type,
199
- stepCount,
200
- steps: stepRows.map(s => ({
201
- step: s.step || s[3] || 0,
202
- name: s.name || s[0],
203
- filePath: s.filePath || s[1],
204
- type: s.type || s[2],
205
- })),
206
- });
207
- }
208
- return processes;
217
+ if (procRows.length === 0)
218
+ return [];
219
+ // Batch fetch all steps for all matched processes
220
+ const procIdList = procRows.map(r => {
221
+ const id = r.id || r[0];
222
+ return `'${id.replace(/'/g, "''")}'`;
223
+ }).join(', ');
224
+ const stepRows = await executeQuery(REPO_ID, `
225
+ MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
226
+ WHERE p.id IN [${procIdList}]
227
+ RETURN s.name AS name, s.filePath AS filePath, labels(s)[0] AS type,
228
+ r.step AS step, p.id AS processId
229
+ ORDER BY p.id, r.step
230
+ `);
231
+ return groupStepsByProcess(procRows, stepRows);
209
232
  }
210
233
  /**
211
234
  * Get inter-module edges for overview architecture diagram.
@@ -236,3 +259,159 @@ export async function getInterModuleEdgesForOverview(moduleFiles) {
236
259
  })
237
260
  .sort((a, b) => b.count - a.count);
238
261
  }
262
+ /**
263
+ * Get community-to-file mapping from Leiden-detected clusters.
264
+ * Each file is assigned to its majority community; secondary memberships tracked separately.
265
+ */
266
+ export async function getCommunityFileMapping() {
267
+ // Query 1: Fetch all Community nodes
268
+ const communityRows = await executeQuery(REPO_ID, `
269
+ MATCH (c:Community)
270
+ RETURN c.id AS id, c.heuristicLabel AS label, c.keywords AS keywords,
271
+ c.description AS description, c.cohesion AS cohesion,
272
+ c.symbolCount AS symbolCount
273
+ ORDER BY c.symbolCount DESC
274
+ `);
275
+ if (communityRows.length === 0)
276
+ return [];
277
+ // Query 2: Fetch all MEMBER_OF edges with file paths
278
+ const memberRows = await executeQuery(REPO_ID, `
279
+ MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
280
+ WHERE n.filePath IS NOT NULL
281
+ RETURN n.filePath AS filePath, c.id AS communityId
282
+ `);
283
+ // Build file -> community count map
284
+ const fileCommunityCount = new Map();
285
+ for (const row of memberRows) {
286
+ const fp = row.filePath || row[0];
287
+ const cId = row.communityId || row[1];
288
+ if (!fileCommunityCount.has(fp))
289
+ fileCommunityCount.set(fp, new Map());
290
+ const counts = fileCommunityCount.get(fp);
291
+ counts.set(cId, (counts.get(cId) || 0) + 1);
292
+ }
293
+ // Assign each file to majority community
294
+ const communityFiles = new Map();
295
+ for (const row of communityRows) {
296
+ const cId = row.id || row[0];
297
+ communityFiles.set(cId, { primary: [], secondary: [] });
298
+ }
299
+ for (const [fp, counts] of fileCommunityCount) {
300
+ let maxCount = 0;
301
+ let maxCommunity = '';
302
+ for (const [cId, count] of counts) {
303
+ if (count > maxCount) {
304
+ maxCount = count;
305
+ maxCommunity = cId;
306
+ }
307
+ }
308
+ if (maxCommunity && communityFiles.has(maxCommunity)) {
309
+ communityFiles.get(maxCommunity).primary.push(fp);
310
+ }
311
+ // Track secondary memberships
312
+ for (const [cId] of counts) {
313
+ if (cId !== maxCommunity && communityFiles.has(cId)) {
314
+ communityFiles.get(cId).secondary.push(fp);
315
+ }
316
+ }
317
+ }
318
+ // Build result, merge tiny communities into "Other"
319
+ const results = [];
320
+ const otherFiles = [];
321
+ const otherSecondary = [];
322
+ for (const row of communityRows) {
323
+ const cId = row.id || row[0];
324
+ const entry = communityFiles.get(cId);
325
+ if (!entry)
326
+ continue;
327
+ if (entry.primary.length < 2) {
328
+ otherFiles.push(...entry.primary);
329
+ otherSecondary.push(...entry.secondary);
330
+ continue;
331
+ }
332
+ const keywords = row.keywords || row[2];
333
+ results.push({
334
+ communityId: cId,
335
+ label: row.label || row[1] || cId,
336
+ keywords: Array.isArray(keywords) ? keywords : typeof keywords === 'string' ? keywords.split(',').map((k) => k.trim()) : [],
337
+ description: row.description || row[3] || '',
338
+ cohesion: row.cohesion || row[4] || 0,
339
+ symbolCount: row.symbolCount || row[5] || 0,
340
+ files: entry.primary,
341
+ secondaryFiles: entry.secondary,
342
+ });
343
+ }
344
+ // Add "Other" bucket if non-empty
345
+ if (otherFiles.length > 0) {
346
+ results.push({
347
+ communityId: '__other__',
348
+ label: 'Other small clusters',
349
+ keywords: [],
350
+ description: 'Files from small communities merged together',
351
+ cohesion: 0,
352
+ symbolCount: 0,
353
+ files: otherFiles,
354
+ secondaryFiles: otherSecondary,
355
+ });
356
+ }
357
+ // Cap at 30 communities
358
+ return results.slice(0, 30);
359
+ }
360
+ /**
361
+ * Get call edges between communities for inter-module coupling analysis.
362
+ */
363
+ export async function getInterCommunityCallEdges() {
364
+ const rows = await executeQuery(REPO_ID, `
365
+ MATCH (a)-[:CodeRelation {type: 'CALLS'}]->(b),
366
+ (a)-[:CodeRelation {type: 'MEMBER_OF'}]->(ca:Community),
367
+ (b)-[:CodeRelation {type: 'MEMBER_OF'}]->(cb:Community)
368
+ WHERE ca.id <> cb.id
369
+ RETURN ca.heuristicLabel AS fromLabel, cb.heuristicLabel AS toLabel,
370
+ a.name AS fromName, b.name AS toName
371
+ `);
372
+ // Aggregate in JS
373
+ const edgeMap = new Map();
374
+ for (const row of rows) {
375
+ const from = row.fromLabel || row[0];
376
+ const to = row.toLabel || row[1];
377
+ const key = `${from}|||${to}`;
378
+ if (!edgeMap.has(key))
379
+ edgeMap.set(key, { count: 0, samples: [] });
380
+ const entry = edgeMap.get(key);
381
+ entry.count++;
382
+ if (entry.samples.length < 3) {
383
+ entry.samples.push({
384
+ fromName: row.fromName || row[2],
385
+ toName: row.toName || row[3],
386
+ });
387
+ }
388
+ }
389
+ const results = [];
390
+ for (const [key, val] of edgeMap) {
391
+ const [fromLabel, toLabel] = key.split('|||');
392
+ results.push({ fromLabel, toLabel, callCount: val.count, sampleCalls: val.samples });
393
+ }
394
+ return results
395
+ .sort((a, b) => b.callCount - a.callCount)
396
+ .slice(0, 40);
397
+ }
398
+ /**
399
+ * Get cross-community execution flows.
400
+ */
401
+ export async function getCrossCommunityProcesses() {
402
+ const rows = await executeQuery(REPO_ID, `
403
+ MATCH (p:Process)
404
+ WHERE p.processType = 'cross_community'
405
+ RETURN p.heuristicLabel AS label, p.communities AS communities, p.stepCount AS stepCount
406
+ ORDER BY p.stepCount DESC
407
+ LIMIT 15
408
+ `);
409
+ return rows.map(r => {
410
+ const communities = r.communities || r[1];
411
+ return {
412
+ label: r.label || r[0] || 'Unknown',
413
+ communities: Array.isArray(communities) ? communities : typeof communities === 'string' ? communities.split(',').map((c) => c.trim()) : [],
414
+ stepCount: r.stepCount || r[2] || 0,
415
+ };
416
+ });
417
+ }