@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.
- package/README.md +38 -31
- package/dist/ast-analysis/engine.d.ts.map +1 -1
- package/dist/ast-analysis/engine.js +91 -60
- package/dist/ast-analysis/engine.js.map +1 -1
- package/dist/ast-analysis/visitor-utils.d.ts +3 -0
- package/dist/ast-analysis/visitor-utils.d.ts.map +1 -1
- package/dist/ast-analysis/visitor-utils.js +83 -49
- package/dist/ast-analysis/visitor-utils.js.map +1 -1
- package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
- package/dist/ast-analysis/visitors/ast-store-visitor.js +78 -62
- package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
- package/dist/ast-analysis/visitors/dataflow-visitor.d.ts.map +1 -1
- package/dist/ast-analysis/visitors/dataflow-visitor.js +61 -42
- package/dist/ast-analysis/visitors/dataflow-visitor.js.map +1 -1
- package/dist/cli/commands/embed.d.ts.map +1 -1
- package/dist/cli/commands/embed.js +49 -4
- package/dist/cli/commands/embed.js.map +1 -1
- package/dist/domain/analysis/dependencies.d.ts.map +1 -1
- package/dist/domain/analysis/dependencies.js +106 -80
- package/dist/domain/analysis/dependencies.js.map +1 -1
- package/dist/domain/analysis/fn-impact.d.ts.map +1 -1
- package/dist/domain/analysis/fn-impact.js +77 -52
- package/dist/domain/analysis/fn-impact.js.map +1 -1
- package/dist/domain/analysis/module-map.d.ts.map +1 -1
- package/dist/domain/analysis/module-map.js +132 -121
- package/dist/domain/analysis/module-map.js.map +1 -1
- package/dist/domain/graph/builder/call-resolver.d.ts +71 -0
- package/dist/domain/graph/builder/call-resolver.d.ts.map +1 -0
- package/dist/domain/graph/builder/call-resolver.js +130 -0
- package/dist/domain/graph/builder/call-resolver.js.map +1 -0
- package/dist/domain/graph/builder/helpers.d.ts +4 -4
- package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
- package/dist/domain/graph/builder/helpers.js +47 -33
- package/dist/domain/graph/builder/helpers.js.map +1 -1
- package/dist/domain/graph/builder/incremental.d.ts +6 -0
- package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
- package/dist/domain/graph/builder/incremental.js +214 -127
- package/dist/domain/graph/builder/incremental.js.map +1 -1
- package/dist/domain/graph/builder/pipeline.d.ts +1 -44
- package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
- package/dist/domain/graph/builder/pipeline.js +10 -766
- package/dist/domain/graph/builder/pipeline.js.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.js +151 -192
- package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
- package/dist/domain/graph/builder/stages/build-structure.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/build-structure.js +82 -65
- package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
- package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/detect-changes.js +84 -56
- package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
- package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/finalize.js +60 -51
- package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
- package/dist/domain/graph/builder/stages/insert-nodes.d.ts +8 -6
- package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/insert-nodes.js +107 -122
- package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
- package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts +14 -0
- package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts.map +1 -0
- package/dist/domain/graph/builder/stages/native-db-lifecycle.js +77 -0
- package/dist/domain/graph/builder/stages/native-db-lifecycle.js.map +1 -0
- package/dist/domain/graph/builder/stages/native-orchestrator.d.ts +62 -0
- package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -0
- package/dist/domain/graph/builder/stages/native-orchestrator.js +747 -0
- package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -0
- package/dist/domain/graph/cycles.d.ts +6 -4
- package/dist/domain/graph/cycles.d.ts.map +1 -1
- package/dist/domain/graph/cycles.js +50 -55
- package/dist/domain/graph/cycles.js.map +1 -1
- package/dist/domain/graph/journal.d.ts.map +1 -1
- package/dist/domain/graph/journal.js +89 -70
- package/dist/domain/graph/journal.js.map +1 -1
- package/dist/domain/graph/watcher.d.ts.map +1 -1
- package/dist/domain/graph/watcher.js +10 -4
- package/dist/domain/graph/watcher.js.map +1 -1
- package/dist/domain/parser.d.ts +12 -23
- package/dist/domain/parser.d.ts.map +1 -1
- package/dist/domain/parser.js +126 -79
- package/dist/domain/parser.js.map +1 -1
- package/dist/domain/search/generator.d.ts +3 -1
- package/dist/domain/search/generator.d.ts.map +1 -1
- package/dist/domain/search/generator.js +68 -45
- package/dist/domain/search/generator.js.map +1 -1
- package/dist/domain/search/models.d.ts +2 -0
- package/dist/domain/search/models.d.ts.map +1 -1
- package/dist/domain/search/models.js +37 -3
- package/dist/domain/search/models.js.map +1 -1
- package/dist/domain/search/search/hybrid.d.ts.map +1 -1
- package/dist/domain/search/search/hybrid.js +49 -40
- package/dist/domain/search/search/hybrid.js.map +1 -1
- package/dist/domain/search/search/semantic.d.ts.map +1 -1
- package/dist/domain/search/search/semantic.js +69 -49
- package/dist/domain/search/search/semantic.js.map +1 -1
- package/dist/domain/wasm-worker-entry.js +201 -136
- package/dist/domain/wasm-worker-entry.js.map +1 -1
- package/dist/extractors/elixir.js +95 -71
- package/dist/extractors/elixir.js.map +1 -1
- package/dist/extractors/gleam.d.ts.map +1 -1
- package/dist/extractors/gleam.js +23 -31
- package/dist/extractors/gleam.js.map +1 -1
- package/dist/extractors/helpers.d.ts +79 -1
- package/dist/extractors/helpers.d.ts.map +1 -1
- package/dist/extractors/helpers.js +137 -0
- package/dist/extractors/helpers.js.map +1 -1
- package/dist/extractors/java.d.ts.map +1 -1
- package/dist/extractors/java.js +37 -49
- package/dist/extractors/java.js.map +1 -1
- package/dist/extractors/javascript.d.ts.map +1 -1
- package/dist/extractors/javascript.js +44 -44
- package/dist/extractors/javascript.js.map +1 -1
- package/dist/extractors/julia.js +27 -34
- package/dist/extractors/julia.js.map +1 -1
- package/dist/extractors/r.d.ts.map +1 -1
- package/dist/extractors/r.js +33 -58
- package/dist/extractors/r.js.map +1 -1
- package/dist/extractors/solidity.d.ts.map +1 -1
- package/dist/extractors/solidity.js +38 -61
- package/dist/extractors/solidity.js.map +1 -1
- package/dist/features/boundaries.d.ts.map +1 -1
- package/dist/features/boundaries.js +49 -39
- package/dist/features/boundaries.js.map +1 -1
- package/dist/features/cfg.d.ts.map +1 -1
- package/dist/features/cfg.js +90 -63
- package/dist/features/cfg.js.map +1 -1
- package/dist/features/check.d.ts.map +1 -1
- package/dist/features/check.js +43 -34
- package/dist/features/check.js.map +1 -1
- package/dist/features/cochange.d.ts.map +1 -1
- package/dist/features/cochange.js +68 -56
- package/dist/features/cochange.js.map +1 -1
- package/dist/features/complexity.d.ts.map +1 -1
- package/dist/features/complexity.js +105 -75
- package/dist/features/complexity.js.map +1 -1
- package/dist/features/dataflow.d.ts.map +1 -1
- package/dist/features/dataflow.js +37 -29
- package/dist/features/dataflow.js.map +1 -1
- package/dist/features/flow.d.ts.map +1 -1
- package/dist/features/flow.js +31 -22
- package/dist/features/flow.js.map +1 -1
- package/dist/features/graph-enrichment.d.ts.map +1 -1
- package/dist/features/graph-enrichment.js +77 -70
- package/dist/features/graph-enrichment.js.map +1 -1
- package/dist/features/owners.d.ts +17 -26
- package/dist/features/owners.d.ts.map +1 -1
- package/dist/features/owners.js +120 -109
- package/dist/features/owners.js.map +1 -1
- package/dist/features/sequence.d.ts.map +1 -1
- package/dist/features/sequence.js +59 -54
- package/dist/features/sequence.js.map +1 -1
- package/dist/features/structure-query.d.ts.map +1 -1
- package/dist/features/structure-query.js +60 -60
- package/dist/features/structure-query.js.map +1 -1
- package/dist/features/structure.d.ts.map +1 -1
- package/dist/features/structure.js +149 -52
- package/dist/features/structure.js.map +1 -1
- package/dist/graph/algorithms/leiden/optimiser.d.ts.map +1 -1
- package/dist/graph/algorithms/leiden/optimiser.js +100 -69
- package/dist/graph/algorithms/leiden/optimiser.js.map +1 -1
- package/dist/graph/classifiers/roles.d.ts.map +1 -1
- package/dist/graph/classifiers/roles.js +63 -59
- package/dist/graph/classifiers/roles.js.map +1 -1
- package/dist/infrastructure/config.d.ts +1 -1
- package/dist/infrastructure/config.d.ts.map +1 -1
- package/dist/infrastructure/config.js +1 -1
- package/dist/infrastructure/config.js.map +1 -1
- package/dist/presentation/cfg.d.ts.map +1 -1
- package/dist/presentation/cfg.js +44 -29
- package/dist/presentation/cfg.js.map +1 -1
- package/dist/presentation/flow.d.ts.map +1 -1
- package/dist/presentation/flow.js +58 -38
- package/dist/presentation/flow.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/grammars/tree-sitter-erlang.wasm +0 -0
- package/package.json +9 -9
- package/src/ast-analysis/engine.ts +145 -61
- package/src/ast-analysis/visitor-utils.ts +86 -46
- package/src/ast-analysis/visitors/ast-store-visitor.ts +104 -69
- package/src/ast-analysis/visitors/dataflow-visitor.ts +86 -47
- package/src/cli/commands/embed.ts +54 -4
- package/src/domain/analysis/dependencies.ts +166 -85
- package/src/domain/analysis/fn-impact.ts +120 -50
- package/src/domain/analysis/module-map.ts +175 -140
- package/src/domain/graph/builder/call-resolver.ts +181 -0
- package/src/domain/graph/builder/helpers.ts +85 -76
- package/src/domain/graph/builder/incremental.ts +321 -152
- package/src/domain/graph/builder/pipeline.ts +19 -957
- package/src/domain/graph/builder/stages/build-edges.ts +229 -275
- package/src/domain/graph/builder/stages/build-structure.ts +115 -82
- package/src/domain/graph/builder/stages/detect-changes.ts +107 -64
- package/src/domain/graph/builder/stages/finalize.ts +72 -70
- package/src/domain/graph/builder/stages/insert-nodes.ts +154 -120
- package/src/domain/graph/builder/stages/native-db-lifecycle.ts +74 -0
- package/src/domain/graph/builder/stages/native-orchestrator.ts +942 -0
- package/src/domain/graph/cycles.ts +51 -49
- package/src/domain/graph/journal.ts +84 -69
- package/src/domain/graph/watcher.ts +12 -4
- package/src/domain/parser.ts +143 -66
- package/src/domain/search/generator.ts +132 -74
- package/src/domain/search/models.ts +39 -3
- package/src/domain/search/search/hybrid.ts +53 -42
- package/src/domain/search/search/semantic.ts +105 -65
- package/src/domain/wasm-worker-entry.ts +235 -152
- package/src/extractors/elixir.ts +91 -64
- package/src/extractors/gleam.ts +33 -37
- package/src/extractors/helpers.ts +205 -1
- package/src/extractors/java.ts +42 -45
- package/src/extractors/javascript.ts +44 -43
- package/src/extractors/julia.ts +28 -35
- package/src/extractors/r.ts +38 -56
- package/src/extractors/solidity.ts +43 -71
- package/src/features/boundaries.ts +64 -46
- package/src/features/cfg.ts +145 -74
- package/src/features/check.ts +60 -43
- package/src/features/cochange.ts +95 -72
- package/src/features/complexity.ts +134 -79
- package/src/features/dataflow.ts +57 -34
- package/src/features/flow.ts +48 -24
- package/src/features/graph-enrichment.ts +105 -70
- package/src/features/owners.ts +186 -146
- package/src/features/sequence.ts +99 -69
- package/src/features/structure-query.ts +94 -79
- package/src/features/structure.ts +199 -79
- package/src/graph/algorithms/leiden/optimiser.ts +142 -87
- package/src/graph/classifiers/roles.ts +64 -54
- package/src/infrastructure/config.ts +1 -1
- package/src/presentation/cfg.ts +48 -32
- package/src/presentation/flow.ts +100 -52
- 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(
|
|
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
|
|
291
|
-
|
|
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
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
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.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
db
|
|
709
|
-
|
|
710
|
-
|
|
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 =
|
|
814
|
-
|
|
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
|