@optave/codegraph 3.1.5 → 3.2.0

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 (91) hide show
  1. package/README.md +3 -2
  2. package/package.json +7 -7
  3. package/src/ast-analysis/engine.js +252 -258
  4. package/src/ast-analysis/shared.js +0 -12
  5. package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
  6. package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
  7. package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
  8. package/src/cli/commands/ast.js +2 -1
  9. package/src/cli/commands/audit.js +2 -1
  10. package/src/cli/commands/batch.js +2 -1
  11. package/src/cli/commands/brief.js +12 -0
  12. package/src/cli/commands/cfg.js +2 -1
  13. package/src/cli/commands/check.js +20 -23
  14. package/src/cli/commands/children.js +6 -1
  15. package/src/cli/commands/complexity.js +2 -1
  16. package/src/cli/commands/context.js +6 -1
  17. package/src/cli/commands/dataflow.js +2 -1
  18. package/src/cli/commands/deps.js +8 -3
  19. package/src/cli/commands/flow.js +2 -1
  20. package/src/cli/commands/fn-impact.js +6 -1
  21. package/src/cli/commands/owners.js +4 -2
  22. package/src/cli/commands/query.js +6 -1
  23. package/src/cli/commands/roles.js +2 -1
  24. package/src/cli/commands/search.js +8 -2
  25. package/src/cli/commands/sequence.js +2 -1
  26. package/src/cli/commands/triage.js +38 -27
  27. package/src/db/connection.js +18 -12
  28. package/src/db/migrations.js +41 -64
  29. package/src/db/query-builder.js +60 -4
  30. package/src/db/repository/in-memory-repository.js +27 -16
  31. package/src/db/repository/nodes.js +8 -10
  32. package/src/domain/analysis/brief.js +155 -0
  33. package/src/domain/analysis/context.js +174 -190
  34. package/src/domain/analysis/dependencies.js +200 -146
  35. package/src/domain/analysis/exports.js +3 -2
  36. package/src/domain/analysis/impact.js +267 -152
  37. package/src/domain/analysis/module-map.js +247 -221
  38. package/src/domain/analysis/roles.js +8 -5
  39. package/src/domain/analysis/symbol-lookup.js +7 -5
  40. package/src/domain/graph/builder/helpers.js +1 -1
  41. package/src/domain/graph/builder/incremental.js +116 -90
  42. package/src/domain/graph/builder/pipeline.js +106 -80
  43. package/src/domain/graph/builder/stages/build-edges.js +318 -239
  44. package/src/domain/graph/builder/stages/detect-changes.js +198 -177
  45. package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
  46. package/src/domain/graph/watcher.js +2 -2
  47. package/src/domain/parser.js +20 -11
  48. package/src/domain/queries.js +1 -0
  49. package/src/domain/search/search/filters.js +9 -5
  50. package/src/domain/search/search/keyword.js +12 -5
  51. package/src/domain/search/search/prepare.js +13 -5
  52. package/src/extractors/csharp.js +224 -207
  53. package/src/extractors/go.js +176 -172
  54. package/src/extractors/hcl.js +94 -78
  55. package/src/extractors/java.js +213 -207
  56. package/src/extractors/javascript.js +274 -304
  57. package/src/extractors/php.js +234 -221
  58. package/src/extractors/python.js +252 -250
  59. package/src/extractors/ruby.js +192 -185
  60. package/src/extractors/rust.js +182 -167
  61. package/src/features/ast.js +5 -3
  62. package/src/features/audit.js +4 -2
  63. package/src/features/boundaries.js +98 -83
  64. package/src/features/cfg.js +134 -143
  65. package/src/features/communities.js +68 -53
  66. package/src/features/complexity.js +143 -132
  67. package/src/features/dataflow.js +146 -149
  68. package/src/features/export.js +3 -3
  69. package/src/features/graph-enrichment.js +2 -2
  70. package/src/features/manifesto.js +9 -6
  71. package/src/features/owners.js +4 -3
  72. package/src/features/sequence.js +152 -141
  73. package/src/features/shared/find-nodes.js +31 -0
  74. package/src/features/structure.js +130 -99
  75. package/src/features/triage.js +83 -68
  76. package/src/graph/classifiers/risk.js +3 -2
  77. package/src/graph/classifiers/roles.js +6 -3
  78. package/src/index.js +1 -0
  79. package/src/mcp/server.js +65 -56
  80. package/src/mcp/tool-registry.js +13 -0
  81. package/src/mcp/tools/brief.js +8 -0
  82. package/src/mcp/tools/index.js +2 -0
  83. package/src/presentation/brief.js +51 -0
  84. package/src/presentation/queries-cli/exports.js +21 -14
  85. package/src/presentation/queries-cli/impact.js +55 -39
  86. package/src/presentation/queries-cli/inspect.js +184 -189
  87. package/src/presentation/queries-cli/overview.js +57 -58
  88. package/src/presentation/queries-cli/path.js +36 -29
  89. package/src/presentation/table.js +0 -8
  90. package/src/shared/generators.js +7 -3
  91. package/src/shared/kinds.js +1 -1
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js';
3
+ import { debug } from '../../infrastructure/logger.js';
3
4
  import { isTestFile } from '../../infrastructure/test-filter.js';
4
5
  import { findCycles } from '../graph/cycles.js';
5
6
  import { LANGUAGE_REGISTRY } from '../parser.js';
@@ -36,6 +37,241 @@ export const FALSE_POSITIVE_NAMES = new Set([
36
37
  ]);
37
38
  export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
38
39
 
40
+ // ---------------------------------------------------------------------------
41
+ // Section helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function buildTestFileIds(db) {
45
+ const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
46
+ const testFileIds = new Set();
47
+ const testFiles = new Set();
48
+ for (const n of allFileNodes) {
49
+ if (isTestFile(n.file)) {
50
+ testFileIds.add(n.id);
51
+ testFiles.add(n.file);
52
+ }
53
+ }
54
+ const allNodes = db.prepare('SELECT id, file FROM nodes').all();
55
+ for (const n of allNodes) {
56
+ if (testFiles.has(n.file)) testFileIds.add(n.id);
57
+ }
58
+ return testFileIds;
59
+ }
60
+
61
+ function countNodesByKind(db, testFileIds) {
62
+ let nodeRows;
63
+ if (testFileIds) {
64
+ const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
65
+ const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
66
+ const counts = {};
67
+ for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
68
+ nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
69
+ } else {
70
+ nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
71
+ }
72
+ const byKind = {};
73
+ let total = 0;
74
+ for (const r of nodeRows) {
75
+ byKind[r.kind] = r.c;
76
+ total += r.c;
77
+ }
78
+ return { total, byKind };
79
+ }
80
+
81
+ function countEdgesByKind(db, testFileIds) {
82
+ let edgeRows;
83
+ if (testFileIds) {
84
+ const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
85
+ const filtered = allEdges.filter(
86
+ (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
87
+ );
88
+ const counts = {};
89
+ for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
90
+ edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
91
+ } else {
92
+ edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
93
+ }
94
+ const byKind = {};
95
+ let total = 0;
96
+ for (const r of edgeRows) {
97
+ byKind[r.kind] = r.c;
98
+ total += r.c;
99
+ }
100
+ return { total, byKind };
101
+ }
102
+
103
+ function countFilesByLanguage(db, noTests) {
104
+ const extToLang = new Map();
105
+ for (const entry of LANGUAGE_REGISTRY) {
106
+ for (const ext of entry.extensions) {
107
+ extToLang.set(ext, entry.id);
108
+ }
109
+ }
110
+ let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
111
+ if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
112
+ const byLanguage = {};
113
+ for (const row of fileNodes) {
114
+ const ext = path.extname(row.file).toLowerCase();
115
+ const lang = extToLang.get(ext) || 'other';
116
+ byLanguage[lang] = (byLanguage[lang] || 0) + 1;
117
+ }
118
+ return { total: fileNodes.length, languages: Object.keys(byLanguage).length, byLanguage };
119
+ }
120
+
121
+ function findHotspots(db, noTests, limit) {
122
+ const testFilter = testFilterSQL('n.file', noTests);
123
+ const hotspotRows = db
124
+ .prepare(`
125
+ SELECT n.file,
126
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
127
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
128
+ FROM nodes n
129
+ WHERE n.kind = 'file' ${testFilter}
130
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
131
+ + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
132
+ `)
133
+ .all();
134
+ const filtered = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
135
+ return filtered.slice(0, limit).map((r) => ({
136
+ file: r.file,
137
+ fanIn: r.fan_in,
138
+ fanOut: r.fan_out,
139
+ }));
140
+ }
141
+
142
+ function getEmbeddingsInfo(db) {
143
+ try {
144
+ const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
145
+ if (count && count.c > 0) {
146
+ const meta = {};
147
+ const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
148
+ for (const r of metaRows) meta[r.key] = r.value;
149
+ return {
150
+ count: count.c,
151
+ model: meta.model || null,
152
+ dim: meta.dim ? parseInt(meta.dim, 10) : null,
153
+ builtAt: meta.built_at || null,
154
+ };
155
+ }
156
+ } catch (e) {
157
+ debug(`embeddings lookup skipped: ${e.message}`);
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function computeQualityMetrics(db, testFilter) {
163
+ const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
164
+
165
+ const totalCallable = db
166
+ .prepare(
167
+ `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
168
+ )
169
+ .get().c;
170
+ const callableWithCallers = db
171
+ .prepare(`
172
+ SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
173
+ JOIN nodes n ON e.target_id = n.id
174
+ WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
175
+ `)
176
+ .get().c;
177
+ const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
178
+
179
+ const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c;
180
+ const highConfCallEdges = db
181
+ .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
182
+ .get().c;
183
+ const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
184
+
185
+ const fpRows = db
186
+ .prepare(`
187
+ SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
188
+ FROM nodes n
189
+ LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
190
+ WHERE n.kind IN ('function', 'method')
191
+ GROUP BY n.id
192
+ HAVING caller_count > ?
193
+ ORDER BY caller_count DESC
194
+ `)
195
+ .all(FALSE_POSITIVE_CALLER_THRESHOLD);
196
+ const falsePositiveWarnings = fpRows
197
+ .filter((r) =>
198
+ FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
199
+ )
200
+ .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
201
+
202
+ let fpEdgeCount = 0;
203
+ for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
204
+ const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
205
+
206
+ const score = Math.round(
207
+ callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
208
+ );
209
+
210
+ return {
211
+ score,
212
+ callerCoverage: {
213
+ ratio: callerCoverage,
214
+ covered: callableWithCallers,
215
+ total: totalCallable,
216
+ },
217
+ callConfidence: {
218
+ ratio: callConfidence,
219
+ highConf: highConfCallEdges,
220
+ total: totalCallEdges,
221
+ },
222
+ falsePositiveWarnings,
223
+ };
224
+ }
225
+
226
+ function countRoles(db, noTests) {
227
+ let roleRows;
228
+ if (noTests) {
229
+ const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
230
+ const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
231
+ const counts = {};
232
+ for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
233
+ roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
234
+ } else {
235
+ roleRows = db
236
+ .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
237
+ .all();
238
+ }
239
+ const roles = {};
240
+ for (const r of roleRows) roles[r.role] = r.c;
241
+ return roles;
242
+ }
243
+
244
+ function getComplexitySummary(db, testFilter) {
245
+ try {
246
+ const cRows = db
247
+ .prepare(
248
+ `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
249
+ FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
250
+ WHERE n.kind IN ('function','method') ${testFilter}`,
251
+ )
252
+ .all();
253
+ if (cRows.length > 0) {
254
+ const miValues = cRows.map((r) => r.maintainability_index || 0);
255
+ return {
256
+ analyzed: cRows.length,
257
+ avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
258
+ avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
259
+ maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
260
+ maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
261
+ avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
262
+ minMI: +Math.min(...miValues).toFixed(1),
263
+ };
264
+ }
265
+ } catch (e) {
266
+ debug(`complexity summary skipped: ${e.message}`);
267
+ }
268
+ return null;
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Public API
273
+ // ---------------------------------------------------------------------------
274
+
39
275
  export function moduleMapData(customDbPath, limit = 20, opts = {}) {
40
276
  const db = openReadonlyOrFail(customDbPath);
41
277
  try {
@@ -78,237 +314,27 @@ export function statsData(customDbPath, opts = {}) {
78
314
  const db = openReadonlyOrFail(customDbPath);
79
315
  try {
80
316
  const noTests = opts.noTests || false;
317
+ const testFilter = testFilterSQL('n.file', noTests);
81
318
 
82
- // Build set of test file IDs for filtering nodes and edges
83
- let testFileIds = null;
84
- if (noTests) {
85
- const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
86
- testFileIds = new Set();
87
- const testFiles = new Set();
88
- for (const n of allFileNodes) {
89
- if (isTestFile(n.file)) {
90
- testFileIds.add(n.id);
91
- testFiles.add(n.file);
92
- }
93
- }
94
-
95
- // Also collect non-file node IDs that belong to test files
96
- const allNodes = db.prepare('SELECT id, file FROM nodes').all();
97
- for (const n of allNodes) {
98
- if (testFiles.has(n.file)) testFileIds.add(n.id);
99
- }
100
- }
101
-
102
- // Node breakdown by kind
103
- let nodeRows;
104
- if (noTests) {
105
- const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
106
- const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
107
- const counts = {};
108
- for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
109
- nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
110
- } else {
111
- nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
112
- }
113
- const nodesByKind = {};
114
- let totalNodes = 0;
115
- for (const r of nodeRows) {
116
- nodesByKind[r.kind] = r.c;
117
- totalNodes += r.c;
118
- }
119
-
120
- // Edge breakdown by kind
121
- let edgeRows;
122
- if (noTests) {
123
- const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
124
- const filtered = allEdges.filter(
125
- (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
126
- );
127
- const counts = {};
128
- for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
129
- edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
130
- } else {
131
- edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
132
- }
133
- const edgesByKind = {};
134
- let totalEdges = 0;
135
- for (const r of edgeRows) {
136
- edgesByKind[r.kind] = r.c;
137
- totalEdges += r.c;
138
- }
319
+ const testFileIds = noTests ? buildTestFileIds(db) : null;
139
320
 
140
- // File/language distribution map extensions via LANGUAGE_REGISTRY
141
- const extToLang = new Map();
142
- for (const entry of LANGUAGE_REGISTRY) {
143
- for (const ext of entry.extensions) {
144
- extToLang.set(ext, entry.id);
145
- }
146
- }
147
- let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
148
- if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
149
- const byLanguage = {};
150
- for (const row of fileNodes) {
151
- const ext = path.extname(row.file).toLowerCase();
152
- const lang = extToLang.get(ext) || 'other';
153
- byLanguage[lang] = (byLanguage[lang] || 0) + 1;
154
- }
155
- const langCount = Object.keys(byLanguage).length;
321
+ const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds);
322
+ const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds);
323
+ const files = countFilesByLanguage(db, noTests);
156
324
 
157
- // Cycles
158
325
  const fileCycles = findCycles(db, { fileLevel: true, noTests });
159
326
  const fnCycles = findCycles(db, { fileLevel: false, noTests });
160
327
 
161
- // Top 5 coupling hotspots (fan-in + fan-out, file nodes)
162
- const testFilter = testFilterSQL('n.file', noTests);
163
- const hotspotRows = db
164
- .prepare(`
165
- SELECT n.file,
166
- (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
167
- (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
168
- FROM nodes n
169
- WHERE n.kind = 'file' ${testFilter}
170
- ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
171
- + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
172
- `)
173
- .all();
174
- const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
175
- const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
176
- file: r.file,
177
- fanIn: r.fan_in,
178
- fanOut: r.fan_out,
179
- }));
180
-
181
- // Embeddings metadata
182
- let embeddings = null;
183
- try {
184
- const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
185
- if (count && count.c > 0) {
186
- const meta = {};
187
- const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
188
- for (const r of metaRows) meta[r.key] = r.value;
189
- embeddings = {
190
- count: count.c,
191
- model: meta.model || null,
192
- dim: meta.dim ? parseInt(meta.dim, 10) : null,
193
- builtAt: meta.built_at || null,
194
- };
195
- }
196
- } catch {
197
- /* embeddings table may not exist */
198
- }
199
-
200
- // Graph quality metrics
201
- const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
202
- const totalCallable = db
203
- .prepare(
204
- `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
205
- )
206
- .get().c;
207
- const callableWithCallers = db
208
- .prepare(`
209
- SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
210
- JOIN nodes n ON e.target_id = n.id
211
- WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
212
- `)
213
- .get().c;
214
- const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
215
-
216
- const totalCallEdges = db
217
- .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'")
218
- .get().c;
219
- const highConfCallEdges = db
220
- .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
221
- .get().c;
222
- const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
223
-
224
- // False-positive warnings: generic names with > threshold callers
225
- const fpRows = db
226
- .prepare(`
227
- SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
228
- FROM nodes n
229
- LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
230
- WHERE n.kind IN ('function', 'method')
231
- GROUP BY n.id
232
- HAVING caller_count > ?
233
- ORDER BY caller_count DESC
234
- `)
235
- .all(FALSE_POSITIVE_CALLER_THRESHOLD);
236
- const falsePositiveWarnings = fpRows
237
- .filter((r) =>
238
- FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
239
- )
240
- .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
241
-
242
- // Edges from suspicious nodes
243
- let fpEdgeCount = 0;
244
- for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
245
- const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
246
-
247
- const score = Math.round(
248
- callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
249
- );
250
-
251
- const quality = {
252
- score,
253
- callerCoverage: {
254
- ratio: callerCoverage,
255
- covered: callableWithCallers,
256
- total: totalCallable,
257
- },
258
- callConfidence: {
259
- ratio: callConfidence,
260
- highConf: highConfCallEdges,
261
- total: totalCallEdges,
262
- },
263
- falsePositiveWarnings,
264
- };
265
-
266
- // Role distribution
267
- let roleRows;
268
- if (noTests) {
269
- const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
270
- const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
271
- const counts = {};
272
- for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
273
- roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
274
- } else {
275
- roleRows = db
276
- .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
277
- .all();
278
- }
279
- const roles = {};
280
- for (const r of roleRows) roles[r.role] = r.c;
281
-
282
- // Complexity summary
283
- let complexity = null;
284
- try {
285
- const cRows = db
286
- .prepare(
287
- `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
288
- FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
289
- WHERE n.kind IN ('function','method') ${testFilter}`,
290
- )
291
- .all();
292
- if (cRows.length > 0) {
293
- const miValues = cRows.map((r) => r.maintainability_index || 0);
294
- complexity = {
295
- analyzed: cRows.length,
296
- avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
297
- avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
298
- maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
299
- maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
300
- avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
301
- minMI: +Math.min(...miValues).toFixed(1),
302
- };
303
- }
304
- } catch {
305
- /* table may not exist in older DBs */
306
- }
328
+ const hotspots = findHotspots(db, noTests, 5);
329
+ const embeddings = getEmbeddingsInfo(db);
330
+ const quality = computeQualityMetrics(db, testFilter);
331
+ const roles = countRoles(db, noTests);
332
+ const complexity = getComplexitySummary(db, testFilter);
307
333
 
308
334
  return {
309
335
  nodes: { total: totalNodes, byKind: nodesByKind },
310
336
  edges: { total: totalEdges, byKind: edgesByKind },
311
- files: { total: fileNodes.length, languages: langCount, byLanguage },
337
+ files,
312
338
  cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
313
339
  hotspots,
314
340
  embeddings,
@@ -1,4 +1,5 @@
1
1
  import { openReadonlyOrFail } from '../../db/index.js';
2
+ import { buildFileConditionSQL } from '../../db/query-builder.js';
2
3
  import { isTestFile } from '../../infrastructure/test-filter.js';
3
4
  import { normalizeSymbol } from '../../shared/normalize.js';
4
5
  import { paginateResult } from '../../shared/paginate.js';
@@ -8,8 +9,6 @@ export function rolesData(customDbPath, opts = {}) {
8
9
  try {
9
10
  const noTests = opts.noTests || false;
10
11
  const filterRole = opts.role || null;
11
- const filterFile = opts.file || null;
12
-
13
12
  const conditions = ['role IS NOT NULL'];
14
13
  const params = [];
15
14
 
@@ -17,9 +16,13 @@ export function rolesData(customDbPath, opts = {}) {
17
16
  conditions.push('role = ?');
18
17
  params.push(filterRole);
19
18
  }
20
- if (filterFile) {
21
- conditions.push('file LIKE ?');
22
- params.push(`%${filterFile}%`);
19
+ {
20
+ const fc = buildFileConditionSQL(opts.file, 'file');
21
+ if (fc.sql) {
22
+ // Strip leading ' AND ' since we're using conditions array
23
+ conditions.push(fc.sql.replace(/^ AND /, ''));
24
+ params.push(...fc.params);
25
+ }
23
26
  }
24
27
 
25
28
  let rows = db
@@ -14,12 +14,13 @@ import {
14
14
  openReadonlyOrFail,
15
15
  Repository,
16
16
  } from '../../db/index.js';
17
+ import { debug } from '../../infrastructure/logger.js';
17
18
  import { isTestFile } from '../../infrastructure/test-filter.js';
18
- import { ALL_SYMBOL_KINDS } from '../../shared/kinds.js';
19
+ import { EVERY_SYMBOL_KIND } from '../../shared/kinds.js';
19
20
  import { getFileHash, normalizeSymbol } from '../../shared/normalize.js';
20
21
  import { paginateResult } from '../../shared/paginate.js';
21
22
 
22
- const FUNCTION_KINDS = ['function', 'method', 'class'];
23
+ const FUNCTION_KINDS = ['function', 'method', 'class', 'constant'];
23
24
 
24
25
  /**
25
26
  * Find nodes matching a name query, ranked by relevance.
@@ -109,12 +110,12 @@ export function queryNameData(name, customDbPath, opts = {}) {
109
110
  }
110
111
 
111
112
  function whereSymbolImpl(db, target, noTests) {
112
- const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
113
+ const placeholders = EVERY_SYMBOL_KIND.map(() => '?').join(', ');
113
114
  let nodes = db
114
115
  .prepare(
115
116
  `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
116
117
  )
117
- .all(`%${target}%`, ...ALL_SYMBOL_KINDS);
118
+ .all(`%${target}%`, ...EVERY_SYMBOL_KIND);
118
119
  if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
119
120
 
120
121
  const hc = new Map();
@@ -206,7 +207,8 @@ export function childrenData(name, customDbPath, opts = {}) {
206
207
  let children;
207
208
  try {
208
209
  children = findNodeChildren(db, node.id);
209
- } catch {
210
+ } catch (e) {
211
+ debug(`findNodeChildren failed for node ${node.id}: ${e.message}`);
210
212
  children = [];
211
213
  }
212
214
  if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file));
@@ -179,7 +179,7 @@ export function purgeFilesFromGraph(db, files, options = {}) {
179
179
  }
180
180
 
181
181
  /** Batch INSERT chunk size for multi-value INSERTs. */
182
- export const BATCH_CHUNK = 200;
182
+ const BATCH_CHUNK = 200;
183
183
 
184
184
  /**
185
185
  * Batch-insert node rows via multi-value INSERT statements.