@optave/codegraph 3.11.0 → 3.11.2

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 (230) 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/call-resolver.d.ts +71 -0
  28. package/dist/domain/graph/builder/call-resolver.d.ts.map +1 -0
  29. package/dist/domain/graph/builder/call-resolver.js +130 -0
  30. package/dist/domain/graph/builder/call-resolver.js.map +1 -0
  31. package/dist/domain/graph/builder/helpers.d.ts +4 -4
  32. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/helpers.js +47 -33
  34. package/dist/domain/graph/builder/helpers.js.map +1 -1
  35. package/dist/domain/graph/builder/incremental.d.ts +6 -0
  36. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  37. package/dist/domain/graph/builder/incremental.js +214 -127
  38. package/dist/domain/graph/builder/incremental.js.map +1 -1
  39. package/dist/domain/graph/builder/pipeline.d.ts +1 -44
  40. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  41. package/dist/domain/graph/builder/pipeline.js +10 -766
  42. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  43. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  44. package/dist/domain/graph/builder/stages/build-edges.js +151 -192
  45. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  46. package/dist/domain/graph/builder/stages/build-structure.d.ts.map +1 -1
  47. package/dist/domain/graph/builder/stages/build-structure.js +82 -65
  48. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  49. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  50. package/dist/domain/graph/builder/stages/detect-changes.js +84 -56
  51. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  52. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/finalize.js +60 -51
  54. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  55. package/dist/domain/graph/builder/stages/insert-nodes.d.ts +8 -6
  56. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  57. package/dist/domain/graph/builder/stages/insert-nodes.js +107 -122
  58. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  59. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts +14 -0
  60. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts.map +1 -0
  61. package/dist/domain/graph/builder/stages/native-db-lifecycle.js +77 -0
  62. package/dist/domain/graph/builder/stages/native-db-lifecycle.js.map +1 -0
  63. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts +62 -0
  64. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -0
  65. package/dist/domain/graph/builder/stages/native-orchestrator.js +747 -0
  66. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -0
  67. package/dist/domain/graph/cycles.d.ts +6 -4
  68. package/dist/domain/graph/cycles.d.ts.map +1 -1
  69. package/dist/domain/graph/cycles.js +50 -55
  70. package/dist/domain/graph/cycles.js.map +1 -1
  71. package/dist/domain/graph/journal.d.ts.map +1 -1
  72. package/dist/domain/graph/journal.js +89 -70
  73. package/dist/domain/graph/journal.js.map +1 -1
  74. package/dist/domain/graph/watcher.d.ts.map +1 -1
  75. package/dist/domain/graph/watcher.js +10 -4
  76. package/dist/domain/graph/watcher.js.map +1 -1
  77. package/dist/domain/parser.d.ts +12 -23
  78. package/dist/domain/parser.d.ts.map +1 -1
  79. package/dist/domain/parser.js +126 -79
  80. package/dist/domain/parser.js.map +1 -1
  81. package/dist/domain/search/generator.d.ts +3 -1
  82. package/dist/domain/search/generator.d.ts.map +1 -1
  83. package/dist/domain/search/generator.js +68 -45
  84. package/dist/domain/search/generator.js.map +1 -1
  85. package/dist/domain/search/models.d.ts +2 -0
  86. package/dist/domain/search/models.d.ts.map +1 -1
  87. package/dist/domain/search/models.js +37 -3
  88. package/dist/domain/search/models.js.map +1 -1
  89. package/dist/domain/search/search/hybrid.d.ts.map +1 -1
  90. package/dist/domain/search/search/hybrid.js +49 -40
  91. package/dist/domain/search/search/hybrid.js.map +1 -1
  92. package/dist/domain/search/search/semantic.d.ts.map +1 -1
  93. package/dist/domain/search/search/semantic.js +69 -49
  94. package/dist/domain/search/search/semantic.js.map +1 -1
  95. package/dist/domain/wasm-worker-entry.js +201 -136
  96. package/dist/domain/wasm-worker-entry.js.map +1 -1
  97. package/dist/extractors/elixir.js +95 -71
  98. package/dist/extractors/elixir.js.map +1 -1
  99. package/dist/extractors/gleam.d.ts.map +1 -1
  100. package/dist/extractors/gleam.js +23 -31
  101. package/dist/extractors/gleam.js.map +1 -1
  102. package/dist/extractors/helpers.d.ts +79 -1
  103. package/dist/extractors/helpers.d.ts.map +1 -1
  104. package/dist/extractors/helpers.js +137 -0
  105. package/dist/extractors/helpers.js.map +1 -1
  106. package/dist/extractors/java.d.ts.map +1 -1
  107. package/dist/extractors/java.js +37 -49
  108. package/dist/extractors/java.js.map +1 -1
  109. package/dist/extractors/javascript.d.ts.map +1 -1
  110. package/dist/extractors/javascript.js +44 -44
  111. package/dist/extractors/javascript.js.map +1 -1
  112. package/dist/extractors/julia.js +27 -34
  113. package/dist/extractors/julia.js.map +1 -1
  114. package/dist/extractors/r.d.ts.map +1 -1
  115. package/dist/extractors/r.js +33 -58
  116. package/dist/extractors/r.js.map +1 -1
  117. package/dist/extractors/solidity.d.ts.map +1 -1
  118. package/dist/extractors/solidity.js +38 -61
  119. package/dist/extractors/solidity.js.map +1 -1
  120. package/dist/features/boundaries.d.ts.map +1 -1
  121. package/dist/features/boundaries.js +49 -39
  122. package/dist/features/boundaries.js.map +1 -1
  123. package/dist/features/cfg.d.ts.map +1 -1
  124. package/dist/features/cfg.js +90 -63
  125. package/dist/features/cfg.js.map +1 -1
  126. package/dist/features/check.d.ts.map +1 -1
  127. package/dist/features/check.js +43 -34
  128. package/dist/features/check.js.map +1 -1
  129. package/dist/features/cochange.d.ts.map +1 -1
  130. package/dist/features/cochange.js +68 -56
  131. package/dist/features/cochange.js.map +1 -1
  132. package/dist/features/complexity.d.ts.map +1 -1
  133. package/dist/features/complexity.js +105 -75
  134. package/dist/features/complexity.js.map +1 -1
  135. package/dist/features/dataflow.d.ts.map +1 -1
  136. package/dist/features/dataflow.js +37 -29
  137. package/dist/features/dataflow.js.map +1 -1
  138. package/dist/features/flow.d.ts.map +1 -1
  139. package/dist/features/flow.js +31 -22
  140. package/dist/features/flow.js.map +1 -1
  141. package/dist/features/graph-enrichment.d.ts.map +1 -1
  142. package/dist/features/graph-enrichment.js +77 -70
  143. package/dist/features/graph-enrichment.js.map +1 -1
  144. package/dist/features/owners.d.ts +17 -26
  145. package/dist/features/owners.d.ts.map +1 -1
  146. package/dist/features/owners.js +120 -109
  147. package/dist/features/owners.js.map +1 -1
  148. package/dist/features/sequence.d.ts.map +1 -1
  149. package/dist/features/sequence.js +59 -54
  150. package/dist/features/sequence.js.map +1 -1
  151. package/dist/features/structure-query.d.ts.map +1 -1
  152. package/dist/features/structure-query.js +60 -60
  153. package/dist/features/structure-query.js.map +1 -1
  154. package/dist/features/structure.d.ts.map +1 -1
  155. package/dist/features/structure.js +149 -52
  156. package/dist/features/structure.js.map +1 -1
  157. package/dist/graph/algorithms/leiden/optimiser.d.ts.map +1 -1
  158. package/dist/graph/algorithms/leiden/optimiser.js +100 -69
  159. package/dist/graph/algorithms/leiden/optimiser.js.map +1 -1
  160. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  161. package/dist/graph/classifiers/roles.js +63 -59
  162. package/dist/graph/classifiers/roles.js.map +1 -1
  163. package/dist/infrastructure/config.d.ts +1 -1
  164. package/dist/infrastructure/config.d.ts.map +1 -1
  165. package/dist/infrastructure/config.js +1 -1
  166. package/dist/infrastructure/config.js.map +1 -1
  167. package/dist/presentation/cfg.d.ts.map +1 -1
  168. package/dist/presentation/cfg.js +44 -29
  169. package/dist/presentation/cfg.js.map +1 -1
  170. package/dist/presentation/flow.d.ts.map +1 -1
  171. package/dist/presentation/flow.js +58 -38
  172. package/dist/presentation/flow.js.map +1 -1
  173. package/dist/types.d.ts +1 -1
  174. package/dist/types.d.ts.map +1 -1
  175. package/grammars/tree-sitter-erlang.wasm +0 -0
  176. package/package.json +9 -9
  177. package/src/ast-analysis/engine.ts +145 -61
  178. package/src/ast-analysis/visitor-utils.ts +86 -46
  179. package/src/ast-analysis/visitors/ast-store-visitor.ts +104 -69
  180. package/src/ast-analysis/visitors/dataflow-visitor.ts +86 -47
  181. package/src/cli/commands/embed.ts +54 -4
  182. package/src/domain/analysis/dependencies.ts +166 -85
  183. package/src/domain/analysis/fn-impact.ts +120 -50
  184. package/src/domain/analysis/module-map.ts +175 -140
  185. package/src/domain/graph/builder/call-resolver.ts +181 -0
  186. package/src/domain/graph/builder/helpers.ts +85 -76
  187. package/src/domain/graph/builder/incremental.ts +321 -152
  188. package/src/domain/graph/builder/pipeline.ts +19 -957
  189. package/src/domain/graph/builder/stages/build-edges.ts +229 -275
  190. package/src/domain/graph/builder/stages/build-structure.ts +115 -82
  191. package/src/domain/graph/builder/stages/detect-changes.ts +107 -64
  192. package/src/domain/graph/builder/stages/finalize.ts +72 -70
  193. package/src/domain/graph/builder/stages/insert-nodes.ts +154 -120
  194. package/src/domain/graph/builder/stages/native-db-lifecycle.ts +74 -0
  195. package/src/domain/graph/builder/stages/native-orchestrator.ts +942 -0
  196. package/src/domain/graph/cycles.ts +51 -49
  197. package/src/domain/graph/journal.ts +84 -69
  198. package/src/domain/graph/watcher.ts +12 -4
  199. package/src/domain/parser.ts +143 -66
  200. package/src/domain/search/generator.ts +132 -74
  201. package/src/domain/search/models.ts +39 -3
  202. package/src/domain/search/search/hybrid.ts +53 -42
  203. package/src/domain/search/search/semantic.ts +105 -65
  204. package/src/domain/wasm-worker-entry.ts +235 -152
  205. package/src/extractors/elixir.ts +91 -64
  206. package/src/extractors/gleam.ts +33 -37
  207. package/src/extractors/helpers.ts +205 -1
  208. package/src/extractors/java.ts +42 -45
  209. package/src/extractors/javascript.ts +44 -43
  210. package/src/extractors/julia.ts +28 -35
  211. package/src/extractors/r.ts +38 -56
  212. package/src/extractors/solidity.ts +43 -71
  213. package/src/features/boundaries.ts +64 -46
  214. package/src/features/cfg.ts +145 -74
  215. package/src/features/check.ts +60 -43
  216. package/src/features/cochange.ts +95 -72
  217. package/src/features/complexity.ts +134 -79
  218. package/src/features/dataflow.ts +57 -34
  219. package/src/features/flow.ts +48 -24
  220. package/src/features/graph-enrichment.ts +105 -70
  221. package/src/features/owners.ts +186 -146
  222. package/src/features/sequence.ts +99 -69
  223. package/src/features/structure-query.ts +94 -79
  224. package/src/features/structure.ts +199 -79
  225. package/src/graph/algorithms/leiden/optimiser.ts +142 -87
  226. package/src/graph/classifiers/roles.ts +64 -54
  227. package/src/infrastructure/config.ts +1 -1
  228. package/src/presentation/cfg.ts +48 -32
  229. package/src/presentation/flow.ts +100 -52
  230. 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 });
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { getNodeId, testFilterSQL } from '../db/index.js';
2
+ import { getBuildMeta, getNodeId, setBuildMeta, testFilterSQL } from '../db/index.js';
3
3
  import { debug } from '../infrastructure/logger.js';
4
4
  import { normalizePath } from '../shared/constants.js';
5
5
  import type { BetterSqlite3Database } from '../types.js';
@@ -532,6 +532,161 @@ 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
+
585
+ // ─── Median cache helpers ─────────────────────────────────────────────────────
586
+
587
+ const ROLES_MEDIANS_KEY = 'roles_medians';
588
+
589
+ // Invalidate cached medians when the edge count drifts past this threshold.
590
+ // A 1-file rebuild adds/removes < 100 edges — well within the margin.
591
+ const MEDIAN_INVALIDATION_DELTA = 500;
592
+
593
+ /**
594
+ * Full edge-table GROUP BY scan — O(M). Only runs on cache miss.
595
+ *
596
+ * Joins `nodes` to restrict to the same non-leaf kinds that
597
+ * `classifyNodeRolesFull` uses when computing medians from in-memory rows
598
+ * (excludes 'file', 'directory', 'parameter', 'property'). This keeps the
599
+ * two paths consistent so a cold-cache fallback produces the same distribution
600
+ * as the full-build cached value.
601
+ *
602
+ * Also returns the filtered edge count used for computing the medians so the
603
+ * caller can pass it directly to `writeMedianCache` without a second query.
604
+ */
605
+ function computeGlobalMediansFromEdges(db: BetterSqlite3Database): {
606
+ fanIn: number;
607
+ fanOut: number;
608
+ edgeCount: number;
609
+ } {
610
+ const excludedKinds = `('file', 'directory', 'parameter', 'property')`;
611
+ const fanInRows = db
612
+ .prepare(
613
+ `SELECT COUNT(*) AS cnt FROM edges e
614
+ JOIN nodes t ON e.target_id = t.id
615
+ WHERE e.kind IN ('calls', 'imports-type')
616
+ AND t.kind NOT IN ${excludedKinds}
617
+ GROUP BY e.target_id`,
618
+ )
619
+ .all() as { cnt: number }[];
620
+ const fanOutRows = db
621
+ .prepare(
622
+ `SELECT COUNT(*) AS cnt FROM edges e
623
+ JOIN nodes s ON e.source_id = s.id
624
+ WHERE e.kind = 'calls'
625
+ AND s.kind NOT IN ${excludedKinds}
626
+ GROUP BY e.source_id`,
627
+ )
628
+ .all() as { cnt: number }[];
629
+ const fanInDist = fanInRows.map((r) => r.cnt).sort((a, b) => a - b);
630
+ const fanOutDist = fanOutRows.map((r) => r.cnt).sort((a, b) => a - b);
631
+ // Sum of fanInRows[*].cnt equals the total edge count for the relevant
632
+ // edge subset — no extra COUNT query needed.
633
+ const edgeCount = fanInRows.reduce((acc, r) => acc + r.cnt, 0);
634
+ return { fanIn: median(fanInDist), fanOut: median(fanOutDist), edgeCount };
635
+ }
636
+
637
+ /**
638
+ * Read cached role medians from build_meta. Returns null when absent or stale
639
+ * (edge count moved beyond MEDIAN_INVALIDATION_DELTA from the cached value).
640
+ *
641
+ * The staleness check uses the same edge subset (calls + imports-type) that
642
+ * the medians are derived from, so only changes to the edges that actually
643
+ * influence fan-in/fan-out can evict the cache.
644
+ */
645
+ function readCachedMedians(db: BetterSqlite3Database): { fanIn: number; fanOut: number } | null {
646
+ const raw = getBuildMeta(db, ROLES_MEDIANS_KEY);
647
+ if (!raw) return null;
648
+ try {
649
+ const cached = JSON.parse(raw) as { fanIn: number; fanOut: number; edgeCount: number };
650
+ // Count only the edge kinds that drive median computation — same subset
651
+ // used by computeGlobalMediansFromEdges and classifyNodeRolesFull.
652
+ const currentCount = (
653
+ db
654
+ .prepare(`SELECT COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type')`)
655
+ .get() as { cnt: number }
656
+ ).cnt;
657
+ if (
658
+ Math.abs(currentCount - cached.edgeCount) >
659
+ Math.max(MEDIAN_INVALIDATION_DELTA, cached.edgeCount * 0.1)
660
+ )
661
+ return null;
662
+ return { fanIn: cached.fanIn, fanOut: cached.fanOut };
663
+ } catch {
664
+ return null;
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Persist global role medians + current edge count to build_meta.
670
+ *
671
+ * @param edgeCount - pre-computed calls+imports-type edge count. When provided,
672
+ * the function skips the COUNT query entirely. Pass when the count is already
673
+ * known at the call site (e.g. from `computeGlobalMediansFromEdges`).
674
+ */
675
+ function writeMedianCache(
676
+ db: BetterSqlite3Database,
677
+ medians: { fanIn: number; fanOut: number },
678
+ edgeCount?: number,
679
+ ): void {
680
+ const cnt =
681
+ edgeCount ??
682
+ (
683
+ db
684
+ .prepare(`SELECT COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type')`)
685
+ .get() as { cnt: number }
686
+ ).cnt;
687
+ setBuildMeta(db, { [ROLES_MEDIANS_KEY]: JSON.stringify({ ...medians, edgeCount: cnt }) });
688
+ }
689
+
535
690
  function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSummary): RoleSummary {
536
691
  // Leaf kinds (parameter, property) can never have callers/callees.
537
692
  // Classify them directly as dead-leaf without the expensive fan-in/fan-out JOINs.
@@ -558,14 +713,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
558
713
  ) fo ON n.id = fo.source_id
559
714
  WHERE n.kind NOT IN ('file', 'directory', 'parameter', 'property')`,
560
715
  )
561
- .all() as {
562
- id: number;
563
- name: string;
564
- kind: string;
565
- file: string;
566
- fan_in: number;
567
- fan_out: number;
568
- }[];
716
+ .all() as CallableNodeRow[];
569
717
 
570
718
  if (rows.length === 0 && leafRows.length === 0) return emptySummary;
571
719
 
@@ -629,29 +777,28 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
629
777
  prodFanInMap.set(r.target_id, r.cnt);
630
778
  }
631
779
 
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
- // 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
-
654
- const roleMap = classifyRoles(classifierInput);
780
+ // Delegate classification to the pure-logic classifier.
781
+ // Compute medians from the already-loaded rows (no extra DB round-trip),
782
+ // pass them as overrides to avoid recomputing inside classifyRoles,
783
+ // and cache them for subsequent incremental builds.
784
+ const activeFiles = buildActiveFilesSet(rows);
785
+ const classifierInput = buildClassifierInput(rows, exportedIds, prodFanInMap, activeFiles);
786
+ const nonZeroFanIn = classifierInput
787
+ .filter((n) => n.fanIn > 0)
788
+ .map((n) => n.fanIn)
789
+ .sort((a, b) => a - b);
790
+ const nonZeroFanOut = classifierInput
791
+ .filter((n) => n.fanOut > 0)
792
+ .map((n) => n.fanOut)
793
+ .sort((a, b) => a - b);
794
+ const globalMedians = { fanIn: median(nonZeroFanIn), fanOut: median(nonZeroFanOut) };
795
+ const roleMap = classifyRoles(classifierInput, globalMedians);
796
+ // Derive the edge count from already-loaded in-memory rows: summing fan_in
797
+ // across all nodes equals COUNT(*) FROM edges WHERE kind IN ('calls','imports-type'),
798
+ // since the full-build query left-joins every matching edge exactly once per target.
799
+ // Passing this avoids an extra COUNT query on the full-build path.
800
+ const inMemoryEdgeCount = rows.reduce((acc, r) => acc + r.fan_in, 0);
801
+ writeMedianCache(db, globalMedians, inMemoryEdgeCount);
655
802
 
656
803
  const { summary, idsByRole } = buildRoleSummary(rows, leafRows, roleMap, emptySummary);
657
804
 
@@ -667,7 +814,9 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
667
814
  * plus their immediate edge neighbours (callers and callees in other files).
668
815
  *
669
816
  * Uses indexed point lookups for fan-in/fan-out instead of full table scans.
670
- * Global medians are computed from edge distribution (fast GROUP BY on index).
817
+ * Global medians are read from the build_meta cache written by the last full
818
+ * classification; the cache is only recomputed when the edge count drifts
819
+ * beyond MEDIAN_INVALIDATION_DELTA (i.e. large structural changes).
671
820
  * Unchanged files not connected to changed files keep their roles from the
672
821
  * previous build.
673
822
  */
@@ -694,25 +843,20 @@ function classifyNodeRolesIncremental(
694
843
  const allAffectedFiles = [...changedFiles, ...neighbourFiles.map((r) => r.file)];
695
844
  const placeholders = allAffectedFiles.map(() => '?').join(',');
696
845
 
697
- // 1. Compute global medians from edge distribution (fast: scans edge index, no node join)
698
- const fanInDist = (
699
- db
700
- .prepare(
701
- `SELECT COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type') GROUP BY target_id`,
702
- )
703
- .all() as { cnt: number }[]
704
- )
705
- .map((r) => r.cnt)
706
- .sort((a, b) => a - b);
707
- const fanOutDist = (
708
- db
709
- .prepare(`SELECT COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY source_id`)
710
- .all() as { cnt: number }[]
711
- )
712
- .map((r) => r.cnt)
713
- .sort((a, b) => a - b);
714
-
715
- const globalMedians = { fanIn: median(fanInDist), fanOut: median(fanOutDist) };
846
+ // 1. Read global medians from cache; fall back to full edge scan only on miss.
847
+ // The median barely moves for a 1-file change, so the cache is almost always
848
+ // valid, eliminating 2× full edge-table GROUP BY queries (~10-15 ms on large graphs).
849
+ const cachedMedians = readCachedMedians(db);
850
+ let globalMedians: { fanIn: number; fanOut: number };
851
+ if (cachedMedians) {
852
+ globalMedians = cachedMedians;
853
+ } else {
854
+ const computed = computeGlobalMediansFromEdges(db);
855
+ // Pass the edgeCount returned by computeGlobalMediansFromEdges so
856
+ // writeMedianCache does not issue a second COUNT query.
857
+ writeMedianCache(db, computed, computed.edgeCount);
858
+ globalMedians = computed;
859
+ }
716
860
 
717
861
  // 2a. Leaf kinds (parameter, property) in affected files — always dead-leaf
718
862
  const leafRows = db
@@ -733,14 +877,7 @@ function classifyNodeRolesIncremental(
733
877
  WHERE n.kind NOT IN ('file', 'directory', 'parameter', 'property')
734
878
  AND n.file IN (${placeholders})`,
735
879
  )
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
- }[];
880
+ .all(...allAffectedFiles) as CallableNodeRow[];
744
881
 
745
882
  if (rows.length === 0 && leafRows.length === 0) return emptySummary;
746
883
 
@@ -810,25 +947,8 @@ function classifyNodeRolesIncremental(
810
947
  }
811
948
 
812
949
  // 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
-
950
+ const activeFiles = buildActiveFilesSet(rows);
951
+ const classifierInput = buildClassifierInput(rows, exportedIds, prodFanInMap, activeFiles);
832
952
  const roleMap = classifyRoles(classifierInput, globalMedians);
833
953
 
834
954
  // 6. Build summary (only for affected nodes) and update only those nodes