@optave/codegraph 3.11.0 → 3.11.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 (223) hide show
  1. package/README.md +38 -31
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +91 -60
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/visitor-utils.d.ts +3 -0
  6. package/dist/ast-analysis/visitor-utils.d.ts.map +1 -1
  7. package/dist/ast-analysis/visitor-utils.js +83 -49
  8. package/dist/ast-analysis/visitor-utils.js.map +1 -1
  9. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  10. package/dist/ast-analysis/visitors/ast-store-visitor.js +78 -62
  11. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  12. package/dist/ast-analysis/visitors/dataflow-visitor.d.ts.map +1 -1
  13. package/dist/ast-analysis/visitors/dataflow-visitor.js +61 -42
  14. package/dist/ast-analysis/visitors/dataflow-visitor.js.map +1 -1
  15. package/dist/cli/commands/embed.d.ts.map +1 -1
  16. package/dist/cli/commands/embed.js +49 -4
  17. package/dist/cli/commands/embed.js.map +1 -1
  18. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  19. package/dist/domain/analysis/dependencies.js +106 -80
  20. package/dist/domain/analysis/dependencies.js.map +1 -1
  21. package/dist/domain/analysis/fn-impact.d.ts.map +1 -1
  22. package/dist/domain/analysis/fn-impact.js +77 -52
  23. package/dist/domain/analysis/fn-impact.js.map +1 -1
  24. package/dist/domain/analysis/module-map.d.ts.map +1 -1
  25. package/dist/domain/analysis/module-map.js +132 -121
  26. package/dist/domain/analysis/module-map.js.map +1 -1
  27. package/dist/domain/graph/builder/helpers.d.ts +4 -4
  28. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  29. package/dist/domain/graph/builder/helpers.js +47 -33
  30. package/dist/domain/graph/builder/helpers.js.map +1 -1
  31. package/dist/domain/graph/builder/incremental.d.ts +6 -0
  32. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/incremental.js +142 -76
  34. package/dist/domain/graph/builder/incremental.js.map +1 -1
  35. package/dist/domain/graph/builder/pipeline.d.ts +1 -44
  36. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  37. package/dist/domain/graph/builder/pipeline.js +10 -766
  38. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  39. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  40. package/dist/domain/graph/builder/stages/build-edges.js +133 -96
  41. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  42. package/dist/domain/graph/builder/stages/build-structure.d.ts.map +1 -1
  43. package/dist/domain/graph/builder/stages/build-structure.js +82 -65
  44. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  45. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  46. package/dist/domain/graph/builder/stages/detect-changes.js +84 -56
  47. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  48. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  49. package/dist/domain/graph/builder/stages/finalize.js +60 -51
  50. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  51. package/dist/domain/graph/builder/stages/insert-nodes.d.ts +8 -6
  52. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/insert-nodes.js +107 -122
  54. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  55. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts +14 -0
  56. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts.map +1 -0
  57. package/dist/domain/graph/builder/stages/native-db-lifecycle.js +77 -0
  58. package/dist/domain/graph/builder/stages/native-db-lifecycle.js.map +1 -0
  59. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts +62 -0
  60. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -0
  61. package/dist/domain/graph/builder/stages/native-orchestrator.js +747 -0
  62. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -0
  63. package/dist/domain/graph/cycles.d.ts +6 -4
  64. package/dist/domain/graph/cycles.d.ts.map +1 -1
  65. package/dist/domain/graph/cycles.js +50 -55
  66. package/dist/domain/graph/cycles.js.map +1 -1
  67. package/dist/domain/graph/journal.d.ts.map +1 -1
  68. package/dist/domain/graph/journal.js +89 -70
  69. package/dist/domain/graph/journal.js.map +1 -1
  70. package/dist/domain/graph/watcher.d.ts.map +1 -1
  71. package/dist/domain/graph/watcher.js +5 -2
  72. package/dist/domain/graph/watcher.js.map +1 -1
  73. package/dist/domain/parser.d.ts +12 -23
  74. package/dist/domain/parser.d.ts.map +1 -1
  75. package/dist/domain/parser.js +126 -79
  76. package/dist/domain/parser.js.map +1 -1
  77. package/dist/domain/search/generator.d.ts +3 -1
  78. package/dist/domain/search/generator.d.ts.map +1 -1
  79. package/dist/domain/search/generator.js +68 -45
  80. package/dist/domain/search/generator.js.map +1 -1
  81. package/dist/domain/search/models.d.ts +2 -0
  82. package/dist/domain/search/models.d.ts.map +1 -1
  83. package/dist/domain/search/models.js +37 -3
  84. package/dist/domain/search/models.js.map +1 -1
  85. package/dist/domain/search/search/hybrid.d.ts.map +1 -1
  86. package/dist/domain/search/search/hybrid.js +49 -40
  87. package/dist/domain/search/search/hybrid.js.map +1 -1
  88. package/dist/domain/search/search/semantic.d.ts.map +1 -1
  89. package/dist/domain/search/search/semantic.js +69 -49
  90. package/dist/domain/search/search/semantic.js.map +1 -1
  91. package/dist/domain/wasm-worker-entry.js +201 -136
  92. package/dist/domain/wasm-worker-entry.js.map +1 -1
  93. package/dist/extractors/elixir.js +95 -71
  94. package/dist/extractors/elixir.js.map +1 -1
  95. package/dist/extractors/gleam.d.ts.map +1 -1
  96. package/dist/extractors/gleam.js +23 -31
  97. package/dist/extractors/gleam.js.map +1 -1
  98. package/dist/extractors/helpers.d.ts +79 -1
  99. package/dist/extractors/helpers.d.ts.map +1 -1
  100. package/dist/extractors/helpers.js +137 -0
  101. package/dist/extractors/helpers.js.map +1 -1
  102. package/dist/extractors/java.d.ts.map +1 -1
  103. package/dist/extractors/java.js +37 -49
  104. package/dist/extractors/java.js.map +1 -1
  105. package/dist/extractors/javascript.d.ts.map +1 -1
  106. package/dist/extractors/javascript.js +44 -44
  107. package/dist/extractors/javascript.js.map +1 -1
  108. package/dist/extractors/julia.js +27 -34
  109. package/dist/extractors/julia.js.map +1 -1
  110. package/dist/extractors/r.d.ts.map +1 -1
  111. package/dist/extractors/r.js +33 -58
  112. package/dist/extractors/r.js.map +1 -1
  113. package/dist/extractors/solidity.d.ts.map +1 -1
  114. package/dist/extractors/solidity.js +38 -61
  115. package/dist/extractors/solidity.js.map +1 -1
  116. package/dist/features/boundaries.d.ts.map +1 -1
  117. package/dist/features/boundaries.js +49 -39
  118. package/dist/features/boundaries.js.map +1 -1
  119. package/dist/features/cfg.d.ts.map +1 -1
  120. package/dist/features/cfg.js +90 -63
  121. package/dist/features/cfg.js.map +1 -1
  122. package/dist/features/check.d.ts.map +1 -1
  123. package/dist/features/check.js +43 -34
  124. package/dist/features/check.js.map +1 -1
  125. package/dist/features/cochange.d.ts.map +1 -1
  126. package/dist/features/cochange.js +68 -56
  127. package/dist/features/cochange.js.map +1 -1
  128. package/dist/features/complexity.d.ts.map +1 -1
  129. package/dist/features/complexity.js +105 -75
  130. package/dist/features/complexity.js.map +1 -1
  131. package/dist/features/dataflow.d.ts.map +1 -1
  132. package/dist/features/dataflow.js +37 -29
  133. package/dist/features/dataflow.js.map +1 -1
  134. package/dist/features/flow.d.ts.map +1 -1
  135. package/dist/features/flow.js +31 -22
  136. package/dist/features/flow.js.map +1 -1
  137. package/dist/features/graph-enrichment.d.ts.map +1 -1
  138. package/dist/features/graph-enrichment.js +77 -70
  139. package/dist/features/graph-enrichment.js.map +1 -1
  140. package/dist/features/owners.d.ts +17 -26
  141. package/dist/features/owners.d.ts.map +1 -1
  142. package/dist/features/owners.js +120 -109
  143. package/dist/features/owners.js.map +1 -1
  144. package/dist/features/sequence.d.ts.map +1 -1
  145. package/dist/features/sequence.js +59 -54
  146. package/dist/features/sequence.js.map +1 -1
  147. package/dist/features/structure-query.d.ts.map +1 -1
  148. package/dist/features/structure-query.js +60 -60
  149. package/dist/features/structure-query.js.map +1 -1
  150. package/dist/features/structure.js +28 -36
  151. package/dist/features/structure.js.map +1 -1
  152. package/dist/graph/algorithms/leiden/optimiser.d.ts.map +1 -1
  153. package/dist/graph/algorithms/leiden/optimiser.js +100 -69
  154. package/dist/graph/algorithms/leiden/optimiser.js.map +1 -1
  155. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  156. package/dist/graph/classifiers/roles.js +63 -59
  157. package/dist/graph/classifiers/roles.js.map +1 -1
  158. package/dist/infrastructure/config.d.ts +1 -1
  159. package/dist/infrastructure/config.d.ts.map +1 -1
  160. package/dist/infrastructure/config.js +1 -1
  161. package/dist/infrastructure/config.js.map +1 -1
  162. package/dist/presentation/cfg.d.ts.map +1 -1
  163. package/dist/presentation/cfg.js +44 -29
  164. package/dist/presentation/cfg.js.map +1 -1
  165. package/dist/presentation/flow.d.ts.map +1 -1
  166. package/dist/presentation/flow.js +58 -38
  167. package/dist/presentation/flow.js.map +1 -1
  168. package/dist/types.d.ts +1 -1
  169. package/dist/types.d.ts.map +1 -1
  170. package/package.json +7 -7
  171. package/src/ast-analysis/engine.ts +145 -61
  172. package/src/ast-analysis/visitor-utils.ts +86 -46
  173. package/src/ast-analysis/visitors/ast-store-visitor.ts +104 -69
  174. package/src/ast-analysis/visitors/dataflow-visitor.ts +86 -47
  175. package/src/cli/commands/embed.ts +54 -4
  176. package/src/domain/analysis/dependencies.ts +166 -85
  177. package/src/domain/analysis/fn-impact.ts +120 -50
  178. package/src/domain/analysis/module-map.ts +175 -140
  179. package/src/domain/graph/builder/helpers.ts +85 -76
  180. package/src/domain/graph/builder/incremental.ts +217 -90
  181. package/src/domain/graph/builder/pipeline.ts +19 -957
  182. package/src/domain/graph/builder/stages/build-edges.ts +198 -140
  183. package/src/domain/graph/builder/stages/build-structure.ts +115 -82
  184. package/src/domain/graph/builder/stages/detect-changes.ts +107 -64
  185. package/src/domain/graph/builder/stages/finalize.ts +72 -70
  186. package/src/domain/graph/builder/stages/insert-nodes.ts +154 -120
  187. package/src/domain/graph/builder/stages/native-db-lifecycle.ts +74 -0
  188. package/src/domain/graph/builder/stages/native-orchestrator.ts +942 -0
  189. package/src/domain/graph/cycles.ts +51 -49
  190. package/src/domain/graph/journal.ts +84 -69
  191. package/src/domain/graph/watcher.ts +8 -2
  192. package/src/domain/parser.ts +143 -66
  193. package/src/domain/search/generator.ts +132 -74
  194. package/src/domain/search/models.ts +39 -3
  195. package/src/domain/search/search/hybrid.ts +53 -42
  196. package/src/domain/search/search/semantic.ts +105 -65
  197. package/src/domain/wasm-worker-entry.ts +235 -152
  198. package/src/extractors/elixir.ts +91 -64
  199. package/src/extractors/gleam.ts +33 -37
  200. package/src/extractors/helpers.ts +205 -1
  201. package/src/extractors/java.ts +42 -45
  202. package/src/extractors/javascript.ts +44 -43
  203. package/src/extractors/julia.ts +28 -35
  204. package/src/extractors/r.ts +38 -56
  205. package/src/extractors/solidity.ts +43 -71
  206. package/src/features/boundaries.ts +64 -46
  207. package/src/features/cfg.ts +145 -74
  208. package/src/features/check.ts +60 -43
  209. package/src/features/cochange.ts +95 -72
  210. package/src/features/complexity.ts +134 -79
  211. package/src/features/dataflow.ts +57 -34
  212. package/src/features/flow.ts +48 -24
  213. package/src/features/graph-enrichment.ts +105 -70
  214. package/src/features/owners.ts +186 -146
  215. package/src/features/sequence.ts +99 -69
  216. package/src/features/structure-query.ts +94 -79
  217. package/src/features/structure.ts +56 -56
  218. package/src/graph/algorithms/leiden/optimiser.ts +142 -87
  219. package/src/graph/classifiers/roles.ts +64 -54
  220. package/src/infrastructure/config.ts +1 -1
  221. package/src/presentation/cfg.ts +48 -32
  222. package/src/presentation/flow.ts +100 -52
  223. package/src/types.ts +1 -1
@@ -227,6 +227,96 @@ interface HotspotsDataOpts {
227
227
  noTests?: boolean;
228
228
  }
229
229
 
230
+ type HotspotEntry = {
231
+ name: string;
232
+ kind: string;
233
+ lineCount: number | null;
234
+ symbolCount: number | null;
235
+ importCount: number | null;
236
+ exportCount: number | null;
237
+ fanIn: number | null;
238
+ fanOut: number | null;
239
+ cohesion: number | null;
240
+ fileCount: number | null;
241
+ density: number;
242
+ coupling: number;
243
+ };
244
+
245
+ /** Compute density from either fileCount/symbolCount or lineCount/symbolCount. */
246
+ function computeHotspotDensity(
247
+ symbolCount: number | null,
248
+ fileCount: number | null,
249
+ lineCount: number | null,
250
+ ): number {
251
+ if ((fileCount ?? 0) > 0) return (symbolCount || 0) / (fileCount ?? 1);
252
+ if ((lineCount ?? 0) > 0) return (symbolCount || 0) / (lineCount ?? 1);
253
+ return 0;
254
+ }
255
+
256
+ /** Map a native-engine hotspot row (camelCase keys) to the public HotspotEntry shape. */
257
+ function mapNativeHotspotRow(r: {
258
+ name: string;
259
+ kind: string;
260
+ lineCount: number | null;
261
+ symbolCount: number | null;
262
+ importCount: number | null;
263
+ exportCount: number | null;
264
+ fanIn: number | null;
265
+ fanOut: number | null;
266
+ cohesion: number | null;
267
+ fileCount: number | null;
268
+ }): HotspotEntry {
269
+ return {
270
+ name: r.name,
271
+ kind: r.kind,
272
+ lineCount: r.lineCount,
273
+ symbolCount: r.symbolCount,
274
+ importCount: r.importCount,
275
+ exportCount: r.exportCount,
276
+ fanIn: r.fanIn,
277
+ fanOut: r.fanOut,
278
+ cohesion: r.cohesion,
279
+ fileCount: r.fileCount,
280
+ density: computeHotspotDensity(r.symbolCount, r.fileCount, r.lineCount),
281
+ coupling: (r.fanIn || 0) + (r.fanOut || 0),
282
+ };
283
+ }
284
+
285
+ /** Map a JS-path hotspot row (snake_case keys from SQLite) to the public HotspotEntry shape. */
286
+ function mapJsHotspotRow(r: HotspotRow): HotspotEntry {
287
+ return {
288
+ name: r.name,
289
+ kind: r.kind,
290
+ lineCount: r.line_count,
291
+ symbolCount: r.symbol_count,
292
+ importCount: r.import_count,
293
+ exportCount: r.export_count,
294
+ fanIn: r.fan_in,
295
+ fanOut: r.fan_out,
296
+ cohesion: r.cohesion,
297
+ fileCount: r.file_count,
298
+ density: computeHotspotDensity(r.symbol_count, r.file_count, r.line_count),
299
+ coupling: (r.fan_in || 0) + (r.fan_out || 0),
300
+ };
301
+ }
302
+
303
+ /** ORDER BY clause for each ranking dimension (strategy pattern). */
304
+ const HOTSPOT_ORDER_BY: Record<string, string> = {
305
+ 'fan-in': 'nm.fan_in DESC NULLS LAST',
306
+ 'fan-out': 'nm.fan_out DESC NULLS LAST',
307
+ density: 'nm.symbol_count DESC NULLS LAST',
308
+ coupling: '(COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST',
309
+ };
310
+
311
+ /** Build the JS-path SQL query for a given metric and test filter. */
312
+ function buildHotspotQuery(metric: string, testFilter: string): string {
313
+ const orderBy = HOTSPOT_ORDER_BY[metric] ?? HOTSPOT_ORDER_BY['fan-in'];
314
+ return `SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
315
+ nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
316
+ FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
317
+ WHERE n.kind = ? ${testFilter} ORDER BY ${orderBy} LIMIT ?`;
318
+ }
319
+
230
320
  export function hotspotsData(
231
321
  customDbPath?: string,
232
322
  opts: HotspotsDataOpts = {},
@@ -242,96 +332,21 @@ export function hotspotsData(
242
332
  const level = opts.level || 'file';
243
333
  const limit = opts.limit || 10;
244
334
  const noTests = opts.noTests || false;
245
-
246
335
  const kind = level === 'directory' ? 'directory' : 'file';
247
336
 
248
- const mapRow = (r: {
249
- name: string;
250
- kind: string;
251
- lineCount: number | null;
252
- symbolCount: number | null;
253
- importCount: number | null;
254
- exportCount: number | null;
255
- fanIn: number | null;
256
- fanOut: number | null;
257
- cohesion: number | null;
258
- fileCount: number | null;
259
- }) => ({
260
- name: r.name,
261
- kind: r.kind,
262
- lineCount: r.lineCount,
263
- symbolCount: r.symbolCount,
264
- importCount: r.importCount,
265
- exportCount: r.exportCount,
266
- fanIn: r.fanIn,
267
- fanOut: r.fanOut,
268
- cohesion: r.cohesion,
269
- fileCount: r.fileCount,
270
- density:
271
- (r.fileCount ?? 0) > 0
272
- ? (r.symbolCount || 0) / (r.fileCount ?? 1)
273
- : (r.lineCount ?? 0) > 0
274
- ? (r.symbolCount || 0) / (r.lineCount ?? 1)
275
- : 0,
276
- coupling: (r.fanIn || 0) + (r.fanOut || 0),
277
- });
278
-
279
337
  // ── Native fast path: single query instead of 4 eagerly prepared ──
280
338
  if (nativeDb?.getHotspots) {
281
339
  const rows = nativeDb.getHotspots(kind, metric, noTests, limit);
282
- const hotspots = rows.map(mapRow);
340
+ const hotspots = rows.map(mapNativeHotspotRow);
283
341
  const base = { metric, level, limit, hotspots };
284
342
  return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset });
285
343
  }
286
344
 
287
345
  // ── JS fallback ───────────────────────────────────────────────────
288
346
  const testFilter = testFilterSQL('n.name', noTests && kind === 'file');
289
-
290
- const HOTSPOT_QUERIES: Record<string, { all(...params: unknown[]): HotspotRow[] }> = {
291
- 'fan-in': db.prepare(`
292
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
293
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
294
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
295
- WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_in DESC NULLS LAST LIMIT ?`),
296
- 'fan-out': db.prepare(`
297
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
298
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
299
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
300
- WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_out DESC NULLS LAST LIMIT ?`),
301
- density: db.prepare(`
302
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
303
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
304
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
305
- WHERE n.kind = ? ${testFilter} ORDER BY nm.symbol_count DESC NULLS LAST LIMIT ?`),
306
- coupling: db.prepare(`
307
- SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count,
308
- nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count
309
- FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id
310
- WHERE n.kind = ? ${testFilter} ORDER BY (COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST LIMIT ?`),
311
- };
312
-
313
- const stmt = HOTSPOT_QUERIES[metric] ?? HOTSPOT_QUERIES['fan-in'];
314
- const rows = stmt!.all(kind, limit);
315
-
316
- const hotspots = rows.map((r) => ({
317
- name: r.name,
318
- kind: r.kind,
319
- lineCount: r.line_count,
320
- symbolCount: r.symbol_count,
321
- importCount: r.import_count,
322
- exportCount: r.export_count,
323
- fanIn: r.fan_in,
324
- fanOut: r.fan_out,
325
- cohesion: r.cohesion,
326
- fileCount: r.file_count,
327
- density:
328
- (r.file_count ?? 0) > 0
329
- ? (r.symbol_count || 0) / (r.file_count ?? 1)
330
- : (r.line_count ?? 0) > 0
331
- ? (r.symbol_count || 0) / (r.line_count ?? 1)
332
- : 0,
333
- coupling: (r.fan_in || 0) + (r.fan_out || 0),
334
- }));
347
+ const stmt = db.prepare(buildHotspotQuery(metric, testFilter));
348
+ const rows = stmt.all(kind, limit) as HotspotRow[];
349
+ const hotspots = rows.map(mapJsHotspotRow);
335
350
 
336
351
  const base = { metric, level, limit, hotspots };
337
352
  return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset });
@@ -532,6 +532,56 @@ function batchUpdateRoles(
532
532
  })();
533
533
  }
534
534
 
535
+ interface CallableNodeRow {
536
+ id: number;
537
+ name: string;
538
+ kind: string;
539
+ file: string;
540
+ fan_in: number;
541
+ fan_out: number;
542
+ }
543
+
544
+ /** Build the activeFiles set: files with at least one callable connected to the graph. */
545
+ function buildActiveFilesSet(rows: CallableNodeRow[]): Set<string> {
546
+ const activeFiles = new Set<string>();
547
+ for (const r of rows) {
548
+ if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') {
549
+ activeFiles.add(r.file);
550
+ }
551
+ }
552
+ return activeFiles;
553
+ }
554
+
555
+ /** Map callable rows to classifier input objects, attaching exported/prod-fan-in/active-file metadata. */
556
+ function buildClassifierInput(
557
+ rows: CallableNodeRow[],
558
+ exportedIds: Set<number>,
559
+ prodFanInMap: Map<number, number>,
560
+ activeFiles: Set<string>,
561
+ ): Array<{
562
+ id: string;
563
+ name: string;
564
+ kind: string;
565
+ file: string;
566
+ fanIn: number;
567
+ fanOut: number;
568
+ isExported: boolean;
569
+ productionFanIn: number;
570
+ hasActiveFileSiblings: boolean | undefined;
571
+ }> {
572
+ return rows.map((r) => ({
573
+ id: String(r.id),
574
+ name: r.name,
575
+ kind: r.kind,
576
+ file: r.file,
577
+ fanIn: r.fan_in,
578
+ fanOut: r.fan_out,
579
+ isExported: exportedIds.has(r.id),
580
+ productionFanIn: prodFanInMap.get(r.id) || 0,
581
+ hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined,
582
+ }));
583
+ }
584
+
535
585
  function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSummary): RoleSummary {
536
586
  // Leaf kinds (parameter, property) can never have callers/callees.
537
587
  // Classify them directly as dead-leaf without the expensive fan-in/fan-out JOINs.
@@ -558,14 +608,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
558
608
  ) fo ON n.id = fo.source_id
559
609
  WHERE n.kind NOT IN ('file', 'directory', 'parameter', 'property')`,
560
610
  )
561
- .all() as {
562
- id: number;
563
- name: string;
564
- kind: string;
565
- file: string;
566
- fan_in: number;
567
- fan_out: number;
568
- }[];
611
+ .all() as CallableNodeRow[];
569
612
 
570
613
  if (rows.length === 0 && leafRows.length === 0) return emptySummary;
571
614
 
@@ -629,28 +672,9 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
629
672
  prodFanInMap.set(r.target_id, r.cnt);
630
673
  }
631
674
 
632
- // Files with at least one callable (non-constant) connected to the graph.
633
- // Constants in these files are likely consumed locally via identifier reference.
634
- const activeFiles = new Set<string>();
635
- for (const r of rows) {
636
- if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') {
637
- activeFiles.add(r.file);
638
- }
639
- }
640
-
641
675
  // Delegate classification to the pure-logic classifier
642
- const classifierInput = rows.map((r) => ({
643
- id: String(r.id),
644
- name: r.name,
645
- kind: r.kind,
646
- file: r.file,
647
- fanIn: r.fan_in,
648
- fanOut: r.fan_out,
649
- isExported: exportedIds.has(r.id),
650
- productionFanIn: prodFanInMap.get(r.id) || 0,
651
- hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined,
652
- }));
653
-
676
+ const activeFiles = buildActiveFilesSet(rows);
677
+ const classifierInput = buildClassifierInput(rows, exportedIds, prodFanInMap, activeFiles);
654
678
  const roleMap = classifyRoles(classifierInput);
655
679
 
656
680
  const { summary, idsByRole } = buildRoleSummary(rows, leafRows, roleMap, emptySummary);
@@ -733,14 +757,7 @@ function classifyNodeRolesIncremental(
733
757
  WHERE n.kind NOT IN ('file', 'directory', 'parameter', 'property')
734
758
  AND n.file IN (${placeholders})`,
735
759
  )
736
- .all(...allAffectedFiles) as {
737
- id: number;
738
- name: string;
739
- kind: string;
740
- file: string;
741
- fan_in: number;
742
- fan_out: number;
743
- }[];
760
+ .all(...allAffectedFiles) as CallableNodeRow[];
744
761
 
745
762
  if (rows.length === 0 && leafRows.length === 0) return emptySummary;
746
763
 
@@ -810,25 +827,8 @@ function classifyNodeRolesIncremental(
810
827
  }
811
828
 
812
829
  // 5. Classify affected nodes using global medians
813
- const activeFiles = new Set<string>();
814
- for (const r of rows) {
815
- if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') {
816
- activeFiles.add(r.file);
817
- }
818
- }
819
-
820
- const classifierInput = rows.map((r) => ({
821
- id: String(r.id),
822
- name: r.name,
823
- kind: r.kind,
824
- file: r.file,
825
- fanIn: r.fan_in,
826
- fanOut: r.fan_out,
827
- isExported: exportedIds.has(r.id),
828
- productionFanIn: prodFanInMap.get(r.id) || 0,
829
- hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined,
830
- }));
831
-
830
+ const activeFiles = buildActiveFilesSet(rows);
831
+ const classifierInput = buildClassifierInput(rows, exportedIds, prodFanInMap, activeFiles);
832
832
  const roleMap = classifyRoles(classifierInput, globalMedians);
833
833
 
834
834
  // 6. Build summary (only for affected nodes) and update only those nodes
@@ -88,12 +88,10 @@ export function runLouvainUndirectedModularity(
88
88
  optionsInput: LeidenOptions = {},
89
89
  ): LouvainResult {
90
90
  const options: NormalizedOptions = normalizeOptions(optionsInput);
91
- let currentGraph: CodeGraph = graph;
92
- const levels: LevelEntry[] = [];
93
91
  const rngSource = createRng(options.randomSeed);
94
92
  const random: () => number = () => rngSource.nextDouble();
95
93
 
96
- const baseGraphAdapter: GraphAdapter = makeGraphAdapter(currentGraph, {
94
+ const baseGraphAdapter: GraphAdapter = makeGraphAdapter(graph, {
97
95
  directed: options.directed,
98
96
  ...optionsInput,
99
97
  });
@@ -101,98 +99,27 @@ export function runLouvainUndirectedModularity(
101
99
  const originalToCurrent = new Int32Array(origN);
102
100
  for (let i = 0; i < origN; i++) originalToCurrent[i] = i;
103
101
 
104
- let fixedNodeMask: Uint8Array | null = null;
105
- if (options.fixedNodes) {
106
- const fixed = new Uint8Array(origN);
107
- const asSet: Set<string> =
108
- options.fixedNodes instanceof Set ? options.fixedNodes : new Set(options.fixedNodes);
109
- for (const id of asSet) {
110
- const idx = baseGraphAdapter.idToIndex.get(String(id));
111
- if (idx != null) fixed[idx] = 1;
112
- }
113
- fixedNodeMask = fixed;
114
- }
102
+ const fixedNodeMask: Uint8Array | null = buildFixedNodeMask(baseGraphAdapter, options.fixedNodes);
115
103
 
104
+ const levels: LevelEntry[] = [];
105
+ let currentGraph: CodeGraph = graph;
116
106
  for (let level = 0; level < options.maxLevels; level++) {
117
107
  const graphAdapter: GraphAdapter =
118
108
  level === 0
119
109
  ? baseGraphAdapter
120
110
  : makeGraphAdapter(currentGraph, { directed: options.directed, ...optionsInput });
121
- const partition: Partition = makePartition(graphAdapter);
122
- partition.graph = graphAdapter;
123
- partition.initializeAggregates();
124
-
125
- const order = new Int32Array(graphAdapter.n);
126
- for (let i = 0; i < graphAdapter.n; i++) order[i] = i;
127
-
128
- let improved: boolean = true;
129
- let localPasses: number = 0;
130
- const strategyCode: CandidateStrategyCode = options.candidateStrategyCode;
131
- while (improved) {
132
- improved = false;
133
- localPasses++;
134
- shuffleArrayInPlace(order, random);
135
- for (let idx = 0; idx < order.length; idx++) {
136
- const nodeIndex: number = order[idx]!;
137
- if (level === 0 && fixedNodeMask && fixedNodeMask[nodeIndex]) continue;
138
- const candidateCount: number = partition.accumulateNeighborCommunityEdgeWeights(nodeIndex);
139
- const { bestCommunityId, bestGain } = findBestCommunityMove(
140
- partition,
141
- graphAdapter,
142
- nodeIndex,
143
- candidateCount,
144
- strategyCode,
145
- options,
146
- random,
147
- );
148
- if (bestCommunityId !== partition.nodeCommunity[nodeIndex]! && bestGain > GAIN_EPSILON) {
149
- partition.moveNodeToCommunity(nodeIndex, bestCommunityId);
150
- improved = true;
151
- }
152
- }
153
- if (localPasses >= options.maxLocalPasses) break;
154
- }
155
-
156
- renumberCommunities(partition, options.preserveLabels);
157
-
158
- let effectivePartition: Partition = partition;
159
- if (options.refine) {
160
- const refined: Partition = refineWithinCoarseCommunities(
161
- graphAdapter,
162
- partition,
163
- random,
164
- options,
165
- level === 0 ? fixedNodeMask : null,
166
- );
167
- // Post-refinement: split any disconnected communities into their
168
- // connected components. This is the cheap O(V+E) alternative to
169
- // checking gamma-connectedness on every candidate during refinement.
170
- // A disconnected community violates even basic connectivity, so
171
- // splitting is always correct.
172
- splitDisconnectedCommunities(graphAdapter, refined);
173
- renumberCommunities(refined, options.preserveLabels);
174
- effectivePartition = refined;
175
- }
111
+ const levelOutcome = runLevel(
112
+ graphAdapter,
113
+ options,
114
+ random,
115
+ level === 0 ? fixedNodeMask : null,
116
+ );
176
117
 
177
- levels.push({ graph: graphAdapter, partition: effectivePartition });
178
- const fineToCoarse: Int32Array = effectivePartition.nodeCommunity;
179
- for (let i = 0; i < originalToCurrent.length; i++) {
180
- originalToCurrent[i] = fineToCoarse[originalToCurrent[i]!]!;
181
- }
118
+ levels.push({ graph: graphAdapter, partition: levelOutcome.effectivePartition });
119
+ applyFineToCoarseMapping(originalToCurrent, levelOutcome.effectivePartition.nodeCommunity);
182
120
 
183
- // Terminate when no further coarsening is possible. Check both the
184
- // move-phase partition (did the greedy phase find merges?) and the
185
- // effective partition that feeds buildCoarseGraph (would coarsening
186
- // actually reduce the graph?). When refine is enabled the refined
187
- // partition starts from singletons and may have more communities than
188
- // the move phase found, so checking only effectivePartition would
189
- // cause premature termination.
190
- if (
191
- partition.communityCount === graphAdapter.n &&
192
- effectivePartition.communityCount === graphAdapter.n
193
- )
194
- break;
195
- currentGraph = buildCoarseGraph(graphAdapter, effectivePartition);
121
+ if (levelOutcome.terminate) break;
122
+ currentGraph = buildCoarseGraph(graphAdapter, levelOutcome.effectivePartition);
196
123
  }
197
124
 
198
125
  const last: LevelEntry = levels[levels.length - 1]!;
@@ -206,6 +133,134 @@ export function runLouvainUndirectedModularity(
206
133
  };
207
134
  }
208
135
 
136
+ /**
137
+ * Build a fixed-node mask aligned with the base graph adapter's node indices.
138
+ * Returns null when no fixed nodes are configured.
139
+ */
140
+ function buildFixedNodeMask(
141
+ baseGraphAdapter: GraphAdapter,
142
+ fixedNodes: Set<string> | string[] | undefined,
143
+ ): Uint8Array | null {
144
+ if (!fixedNodes) return null;
145
+ const mask = new Uint8Array(baseGraphAdapter.n);
146
+ const asSet: Set<string> = fixedNodes instanceof Set ? fixedNodes : new Set(fixedNodes);
147
+ for (const id of asSet) {
148
+ const idx = baseGraphAdapter.idToIndex.get(String(id));
149
+ if (idx != null) mask[idx] = 1;
150
+ }
151
+ return mask;
152
+ }
153
+
154
+ interface LevelOutcome {
155
+ effectivePartition: Partition;
156
+ terminate: boolean;
157
+ }
158
+
159
+ /**
160
+ * Run one level of the Louvain/Leiden pipeline: greedy local-move phase,
161
+ * optional Leiden refinement, and a termination check. Returns the
162
+ * partition that feeds the next coarse graph plus a `terminate` flag set
163
+ * when no further coarsening is possible.
164
+ */
165
+ function runLevel(
166
+ graphAdapter: GraphAdapter,
167
+ options: NormalizedOptions,
168
+ random: () => number,
169
+ fixedNodeMask: Uint8Array | null,
170
+ ): LevelOutcome {
171
+ const partition: Partition = makePartition(graphAdapter);
172
+ partition.graph = graphAdapter;
173
+ partition.initializeAggregates();
174
+
175
+ runLocalMovePhase(graphAdapter, partition, options, random, fixedNodeMask);
176
+ renumberCommunities(partition, options.preserveLabels);
177
+
178
+ let effectivePartition: Partition = partition;
179
+ if (options.refine) {
180
+ const refined: Partition = refineWithinCoarseCommunities(
181
+ graphAdapter,
182
+ partition,
183
+ random,
184
+ options,
185
+ fixedNodeMask,
186
+ );
187
+ // Post-refinement: split any disconnected communities into their
188
+ // connected components. This is the cheap O(V+E) alternative to
189
+ // checking gamma-connectedness on every candidate during refinement.
190
+ // A disconnected community violates even basic connectivity, so
191
+ // splitting is always correct.
192
+ splitDisconnectedCommunities(graphAdapter, refined);
193
+ renumberCommunities(refined, options.preserveLabels);
194
+ effectivePartition = refined;
195
+ }
196
+
197
+ // Terminate when no further coarsening is possible. Check both the
198
+ // move-phase partition (did the greedy phase find merges?) and the
199
+ // effective partition that feeds buildCoarseGraph (would coarsening
200
+ // actually reduce the graph?). When refine is enabled the refined
201
+ // partition starts from singletons and may have more communities than
202
+ // the move phase found, so checking only effectivePartition would
203
+ // cause premature termination.
204
+ const terminate =
205
+ partition.communityCount === graphAdapter.n &&
206
+ effectivePartition.communityCount === graphAdapter.n;
207
+ return { effectivePartition, terminate };
208
+ }
209
+
210
+ /**
211
+ * Greedy local-move phase: iterate randomly over nodes, moving each to the
212
+ * best community among the candidate set. Loops until no improvement or
213
+ * `maxLocalPasses` is reached.
214
+ */
215
+ function runLocalMovePhase(
216
+ graphAdapter: GraphAdapter,
217
+ partition: Partition,
218
+ options: NormalizedOptions,
219
+ random: () => number,
220
+ fixedNodeMask: Uint8Array | null,
221
+ ): void {
222
+ const order = new Int32Array(graphAdapter.n);
223
+ for (let i = 0; i < graphAdapter.n; i++) order[i] = i;
224
+
225
+ const strategyCode: CandidateStrategyCode = options.candidateStrategyCode;
226
+ let improved: boolean = true;
227
+ let localPasses: number = 0;
228
+ while (improved) {
229
+ improved = false;
230
+ localPasses++;
231
+ shuffleArrayInPlace(order, random);
232
+ for (let idx = 0; idx < order.length; idx++) {
233
+ const nodeIndex: number = order[idx]!;
234
+ if (fixedNodeMask?.[nodeIndex]) continue;
235
+ const candidateCount: number = partition.accumulateNeighborCommunityEdgeWeights(nodeIndex);
236
+ const { bestCommunityId, bestGain } = findBestCommunityMove(
237
+ partition,
238
+ graphAdapter,
239
+ nodeIndex,
240
+ candidateCount,
241
+ strategyCode,
242
+ options,
243
+ random,
244
+ );
245
+ if (bestCommunityId !== partition.nodeCommunity[nodeIndex]! && bestGain > GAIN_EPSILON) {
246
+ partition.moveNodeToCommunity(nodeIndex, bestCommunityId);
247
+ improved = true;
248
+ }
249
+ }
250
+ if (localPasses >= options.maxLocalPasses) break;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Compose the running `originalToCurrent` mapping with this level's
256
+ * fine→coarse community labels, in place.
257
+ */
258
+ function applyFineToCoarseMapping(originalToCurrent: Int32Array, fineToCoarse: Int32Array): void {
259
+ for (let i = 0; i < originalToCurrent.length; i++) {
260
+ originalToCurrent[i] = fineToCoarse[originalToCurrent[i]!]!;
261
+ }
262
+ }
263
+
209
264
  /**
210
265
  * Evaluate all candidate communities for a node and return the best move.
211
266
  * Encapsulates the four candidate-selection strategies (All, RandomAny,