@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
@@ -5,73 +5,41 @@ import { isTestFile } from '../infrastructure/test-filter.js';
5
5
  import { normalizePath } from '../shared/constants.js';
6
6
  import { paginateResult } from '../shared/paginate.js';
7
7
 
8
- // ─── Build-time: insert directory nodes, contains edges, and metrics ────
9
-
10
- /**
11
- * Build directory structure nodes, containment edges, and compute metrics.
12
- * Called from builder.js after edge building.
13
- *
14
- * @param {import('better-sqlite3').Database} db - Open read-write database
15
- * @param {Map<string, object>} fileSymbols - Map of relPath → { definitions, imports, exports, calls }
16
- * @param {string} rootDir - Absolute root directory
17
- * @param {Map<string, number>} lineCountMap - Map of relPath → line count
18
- * @param {Set<string>} directories - Set of relative directory paths
19
- */
20
- export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, directories, changedFiles) {
21
- const insertNode = db.prepare(
22
- 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
23
- );
24
- const getNodeIdStmt = {
25
- get: (name, kind, file, line) => {
26
- const id = getNodeId(db, name, kind, file, line);
27
- return id != null ? { id } : undefined;
28
- },
29
- };
30
- const insertEdge = db.prepare(
31
- 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
32
- );
33
- const upsertMetric = db.prepare(`
34
- INSERT OR REPLACE INTO node_metrics
35
- (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count)
36
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
37
- `);
8
+ // ─── Build-time helpers ───────────────────────────────────────────────
38
9
 
39
- const isIncremental = changedFiles != null && changedFiles.length > 0;
10
+ function getAncestorDirs(filePaths) {
11
+ const dirs = new Set();
12
+ for (const f of filePaths) {
13
+ let d = normalizePath(path.dirname(f));
14
+ while (d && d !== '.') {
15
+ dirs.add(d);
16
+ d = normalizePath(path.dirname(d));
17
+ }
18
+ }
19
+ return dirs;
20
+ }
40
21
 
22
+ function cleanupPreviousData(db, getNodeIdStmt, isIncremental, changedFiles) {
41
23
  if (isIncremental) {
42
- // Incremental: only clean up data for changed files and their ancestor directories
43
- const affectedDirs = new Set();
44
- for (const f of changedFiles) {
45
- let d = normalizePath(path.dirname(f));
46
- while (d && d !== '.') {
47
- affectedDirs.add(d);
48
- d = normalizePath(path.dirname(d));
49
- }
50
- }
24
+ const affectedDirs = getAncestorDirs(changedFiles);
51
25
  const deleteContainsForDir = db.prepare(
52
26
  "DELETE FROM edges WHERE kind = 'contains' AND source_id IN (SELECT id FROM nodes WHERE name = ? AND kind = 'directory')",
53
27
  );
54
28
  const deleteMetricForNode = db.prepare('DELETE FROM node_metrics WHERE node_id = ?');
55
29
  db.transaction(() => {
56
- // Delete contains edges only from affected directories
57
30
  for (const dir of affectedDirs) {
58
31
  deleteContainsForDir.run(dir);
59
32
  }
60
- // Delete metrics for changed files
61
33
  for (const f of changedFiles) {
62
34
  const fileRow = getNodeIdStmt.get(f, 'file', f, 0);
63
35
  if (fileRow) deleteMetricForNode.run(fileRow.id);
64
36
  }
65
- // Delete metrics for affected directories
66
37
  for (const dir of affectedDirs) {
67
38
  const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
68
39
  if (dirRow) deleteMetricForNode.run(dirRow.id);
69
40
  }
70
41
  })();
71
42
  } else {
72
- // Full rebuild: clean previous directory nodes/edges (idempotent)
73
- // Scope contains-edge delete to directory-sourced edges only,
74
- // preserving symbol-level contains edges (file→def, class→method, etc.)
75
43
  db.exec(`
76
44
  DELETE FROM edges WHERE kind = 'contains'
77
45
  AND source_id IN (SELECT id FROM nodes WHERE kind = 'directory');
@@ -79,8 +47,9 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
79
47
  DELETE FROM nodes WHERE kind = 'directory';
80
48
  `);
81
49
  }
50
+ }
82
51
 
83
- // Step 1: Ensure all directories are represented (including intermediate parents)
52
+ function collectAllDirectories(directories, fileSymbols) {
84
53
  const allDirs = new Set();
85
54
  for (const dir of directories) {
86
55
  let d = dir;
@@ -89,7 +58,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
89
58
  d = normalizePath(path.dirname(d));
90
59
  }
91
60
  }
92
- // Also add dirs derived from file paths
93
61
  for (const relPath of fileSymbols.keys()) {
94
62
  let d = normalizePath(path.dirname(relPath));
95
63
  while (d && d !== '.') {
@@ -97,37 +65,17 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
97
65
  d = normalizePath(path.dirname(d));
98
66
  }
99
67
  }
68
+ return allDirs;
69
+ }
100
70
 
101
- // Step 2: Insert directory nodes (INSERT OR IGNORE safe for incremental)
102
- const insertDirs = db.transaction(() => {
103
- for (const dir of allDirs) {
104
- insertNode.run(dir, 'directory', dir, 0, null);
105
- }
106
- });
107
- insertDirs();
108
-
109
- // Step 3: Insert 'contains' edges (dir → file, dir → subdirectory)
110
- // On incremental, only re-insert for affected directories (others are intact)
111
- const affectedDirs = isIncremental
112
- ? (() => {
113
- const dirs = new Set();
114
- for (const f of changedFiles) {
115
- let d = normalizePath(path.dirname(f));
116
- while (d && d !== '.') {
117
- dirs.add(d);
118
- d = normalizePath(path.dirname(d));
119
- }
120
- }
121
- return dirs;
122
- })()
123
- : null;
71
+ function insertContainsEdges(db, insertEdge, getNodeIdStmt, fileSymbols, allDirs, changedFiles) {
72
+ const isIncremental = changedFiles != null && changedFiles.length > 0;
73
+ const affectedDirs = isIncremental ? getAncestorDirs(changedFiles) : null;
124
74
 
125
- const insertContains = db.transaction(() => {
126
- // dir → file
75
+ db.transaction(() => {
127
76
  for (const relPath of fileSymbols.keys()) {
128
77
  const dir = normalizePath(path.dirname(relPath));
129
78
  if (!dir || dir === '.') continue;
130
- // On incremental, skip dirs whose contains edges are intact
131
79
  if (affectedDirs && !affectedDirs.has(dir)) continue;
132
80
  const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
133
81
  const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
@@ -135,11 +83,9 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
135
83
  insertEdge.run(dirRow.id, fileRow.id, 'contains', 1.0, 0);
136
84
  }
137
85
  }
138
- // dir → subdirectory
139
86
  for (const dir of allDirs) {
140
87
  const parent = normalizePath(path.dirname(dir));
141
88
  if (!parent || parent === '.' || parent === dir) continue;
142
- // On incremental, skip parent dirs whose contains edges are intact
143
89
  if (affectedDirs && !affectedDirs.has(parent)) continue;
144
90
  const parentRow = getNodeIdStmt.get(parent, 'directory', parent, 0);
145
91
  const childRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
@@ -147,11 +93,10 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
147
93
  insertEdge.run(parentRow.id, childRow.id, 'contains', 1.0, 0);
148
94
  }
149
95
  }
150
- });
151
- insertContains();
96
+ })();
97
+ }
152
98
 
153
- // Step 4: Compute per-file metrics
154
- // Pre-compute fan-in/fan-out per file from import edges
99
+ function computeImportEdgeMaps(db) {
155
100
  const fanInMap = new Map();
156
101
  const fanOutMap = new Map();
157
102
  const importEdges = db
@@ -169,14 +114,24 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
169
114
  fanOutMap.set(source_file, (fanOutMap.get(source_file) || 0) + 1);
170
115
  fanInMap.set(target_file, (fanInMap.get(target_file) || 0) + 1);
171
116
  }
117
+ return { fanInMap, fanOutMap, importEdges };
118
+ }
172
119
 
173
- const computeFileMetrics = db.transaction(() => {
120
+ function computeFileMetrics(
121
+ db,
122
+ upsertMetric,
123
+ getNodeIdStmt,
124
+ fileSymbols,
125
+ lineCountMap,
126
+ fanInMap,
127
+ fanOutMap,
128
+ ) {
129
+ db.transaction(() => {
174
130
  for (const [relPath, symbols] of fileSymbols) {
175
131
  const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
176
132
  if (!fileRow) continue;
177
133
 
178
134
  const lineCount = lineCountMap.get(relPath) || 0;
179
- // Deduplicate definitions by name+kind+line
180
135
  const seen = new Set();
181
136
  let symbolCount = 0;
182
137
  for (const d of symbols.definitions) {
@@ -203,11 +158,17 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
203
158
  null,
204
159
  );
205
160
  }
206
- });
207
- computeFileMetrics();
161
+ })();
162
+ }
208
163
 
209
- // Step 5: Compute per-directory metrics
210
- // Build a map of dir → descendant files
164
+ function computeDirectoryMetrics(
165
+ db,
166
+ upsertMetric,
167
+ getNodeIdStmt,
168
+ fileSymbols,
169
+ allDirs,
170
+ importEdges,
171
+ ) {
211
172
  const dirFiles = new Map();
212
173
  for (const dir of allDirs) {
213
174
  dirFiles.set(dir, []);
@@ -222,7 +183,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
222
183
  }
223
184
  }
224
185
 
225
- // Build reverse index: file → set of ancestor directories (O(files × depth))
226
186
  const fileToAncestorDirs = new Map();
227
187
  for (const [dir, files] of dirFiles) {
228
188
  for (const f of files) {
@@ -231,7 +191,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
231
191
  }
232
192
  }
233
193
 
234
- // Single O(E) pass: pre-aggregate edge counts per directory
235
194
  const dirEdgeCounts = new Map();
236
195
  for (const dir of allDirs) {
237
196
  dirEdgeCounts.set(dir, { intra: 0, fanIn: 0, fanOut: 0 });
@@ -241,7 +200,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
241
200
  const tgtDirs = fileToAncestorDirs.get(target_file);
242
201
  if (!srcDirs && !tgtDirs) continue;
243
202
 
244
- // For each directory that contains the source file
245
203
  if (srcDirs) {
246
204
  for (const dir of srcDirs) {
247
205
  const counts = dirEdgeCounts.get(dir);
@@ -253,10 +211,9 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
253
211
  }
254
212
  }
255
213
  }
256
- // For each directory that contains the target but NOT the source
257
214
  if (tgtDirs) {
258
215
  for (const dir of tgtDirs) {
259
- if (srcDirs?.has(dir)) continue; // already counted as intra
216
+ if (srcDirs?.has(dir)) continue;
260
217
  const counts = dirEdgeCounts.get(dir);
261
218
  if (!counts) continue;
262
219
  counts.fanIn++;
@@ -264,7 +221,7 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
264
221
  }
265
222
  }
266
223
 
267
- const computeDirMetrics = db.transaction(() => {
224
+ db.transaction(() => {
268
225
  for (const [dir, files] of dirFiles) {
269
226
  const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0);
270
227
  if (!dirRow) continue;
@@ -286,7 +243,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
286
243
  }
287
244
  }
288
245
 
289
- // O(1) lookup from pre-aggregated edge counts
290
246
  const counts = dirEdgeCounts.get(dir) || { intra: 0, fanIn: 0, fanOut: 0 };
291
247
  const totalEdges = counts.intra + counts.fanIn + counts.fanOut;
292
248
  const cohesion = totalEdges > 0 ? counts.intra / totalEdges : null;
@@ -303,11 +259,69 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
303
259
  fileCount,
304
260
  );
305
261
  }
306
- });
307
- computeDirMetrics();
262
+ })();
263
+ }
264
+
265
+ // ─── Build-time: insert directory nodes, contains edges, and metrics ────
266
+
267
+ /**
268
+ * Build directory structure nodes, containment edges, and compute metrics.
269
+ * Called from builder.js after edge building.
270
+ *
271
+ * @param {import('better-sqlite3').Database} db - Open read-write database
272
+ * @param {Map<string, object>} fileSymbols - Map of relPath → { definitions, imports, exports, calls }
273
+ * @param {string} rootDir - Absolute root directory
274
+ * @param {Map<string, number>} lineCountMap - Map of relPath → line count
275
+ * @param {Set<string>} directories - Set of relative directory paths
276
+ */
277
+ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, directories, changedFiles) {
278
+ const insertNode = db.prepare(
279
+ 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
280
+ );
281
+ const getNodeIdStmt = {
282
+ get: (name, kind, file, line) => {
283
+ const id = getNodeId(db, name, kind, file, line);
284
+ return id != null ? { id } : undefined;
285
+ },
286
+ };
287
+ const insertEdge = db.prepare(
288
+ 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
289
+ );
290
+ const upsertMetric = db.prepare(`
291
+ INSERT OR REPLACE INTO node_metrics
292
+ (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count)
293
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
294
+ `);
295
+
296
+ const isIncremental = changedFiles != null && changedFiles.length > 0;
297
+
298
+ cleanupPreviousData(db, getNodeIdStmt, isIncremental, changedFiles);
308
299
 
309
- const dirCount = allDirs.size;
310
- debug(`Structure: ${dirCount} directories, ${fileSymbols.size} files with metrics`);
300
+ const allDirs = collectAllDirectories(directories, fileSymbols);
301
+
302
+ db.transaction(() => {
303
+ for (const dir of allDirs) {
304
+ insertNode.run(dir, 'directory', dir, 0, null);
305
+ }
306
+ })();
307
+
308
+ insertContainsEdges(db, insertEdge, getNodeIdStmt, fileSymbols, allDirs, changedFiles);
309
+
310
+ const { fanInMap, fanOutMap, importEdges } = computeImportEdgeMaps(db);
311
+
312
+ computeFileMetrics(
313
+ db,
314
+ upsertMetric,
315
+ getNodeIdStmt,
316
+ fileSymbols,
317
+ lineCountMap,
318
+ fanInMap,
319
+ fanOutMap,
320
+ );
321
+
322
+ computeDirectoryMetrics(db, upsertMetric, getNodeIdStmt, fileSymbols, allDirs, importEdges);
323
+
324
+ debug(`Structure: ${allDirs.size} directories, ${fileSymbols.size} files with metrics`);
311
325
  }
312
326
 
313
327
  // ─── Node role classification ─────────────────────────────────────────
@@ -335,7 +349,7 @@ export function classifyNodeRoles(db) {
335
349
  .all();
336
350
 
337
351
  if (rows.length === 0) {
338
- return { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
352
+ return { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, 'test-only': 0, leaf: 0 };
339
353
  }
340
354
 
341
355
  const exportedIds = new Set(
@@ -351,6 +365,22 @@ export function classifyNodeRoles(db) {
351
365
  .map((r) => r.target_id),
352
366
  );
353
367
 
368
+ // Compute production fan-in (excluding callers in test files)
369
+ const prodFanInMap = new Map();
370
+ const prodRows = db
371
+ .prepare(
372
+ `SELECT e.target_id, COUNT(*) AS cnt
373
+ FROM edges e
374
+ JOIN nodes caller ON e.source_id = caller.id
375
+ WHERE e.kind = 'calls'
376
+ ${testFilterSQL('caller.file')}
377
+ GROUP BY e.target_id`,
378
+ )
379
+ .all();
380
+ for (const r of prodRows) {
381
+ prodFanInMap.set(r.target_id, r.cnt);
382
+ }
383
+
354
384
  // Delegate classification to the pure-logic classifier
355
385
  const classifierInput = rows.map((r) => ({
356
386
  id: String(r.id),
@@ -358,12 +388,13 @@ export function classifyNodeRoles(db) {
358
388
  fanIn: r.fan_in,
359
389
  fanOut: r.fan_out,
360
390
  isExported: exportedIds.has(r.id),
391
+ productionFanIn: prodFanInMap.get(r.id) || 0,
361
392
  }));
362
393
 
363
394
  const roleMap = classifyRoles(classifierInput);
364
395
 
365
396
  // Build summary and updates
366
- const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
397
+ const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, 'test-only': 0, leaf: 0 };
367
398
  const updates = [];
368
399
  for (const row of rows) {
369
400
  const role = roleMap.get(String(row.id)) || 'leaf';
@@ -4,8 +4,83 @@ import { warn } from '../infrastructure/logger.js';
4
4
  import { isTestFile } from '../infrastructure/test-filter.js';
5
5
  import { paginateResult } from '../shared/paginate.js';
6
6
 
7
+ // ─── Scoring ─────────────────────────────────────────────────────────
8
+
9
+ const SORT_FNS = {
10
+ risk: (a, b) => b.riskScore - a.riskScore,
11
+ complexity: (a, b) => b.cognitive - a.cognitive,
12
+ churn: (a, b) => b.churn - a.churn,
13
+ 'fan-in': (a, b) => b.fanIn - a.fanIn,
14
+ mi: (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
15
+ };
16
+
17
+ /**
18
+ * Build scored triage items from raw rows and risk metrics.
19
+ * @param {object[]} rows - Raw DB rows
20
+ * @param {object[]} riskMetrics - Per-row risk metric objects from scoreRisk
21
+ * @returns {object[]}
22
+ */
23
+ function buildTriageItems(rows, riskMetrics) {
24
+ return rows.map((r, i) => ({
25
+ name: r.name,
26
+ kind: r.kind,
27
+ file: r.file,
28
+ line: r.line,
29
+ role: r.role || null,
30
+ fanIn: r.fan_in,
31
+ cognitive: r.cognitive,
32
+ churn: r.churn,
33
+ maintainabilityIndex: r.mi,
34
+ normFanIn: riskMetrics[i].normFanIn,
35
+ normComplexity: riskMetrics[i].normComplexity,
36
+ normChurn: riskMetrics[i].normChurn,
37
+ normMI: riskMetrics[i].normMI,
38
+ roleWeight: riskMetrics[i].roleWeight,
39
+ riskScore: riskMetrics[i].riskScore,
40
+ }));
41
+ }
42
+
43
+ /**
44
+ * Compute signal coverage and summary statistics.
45
+ * @param {object[]} filtered - All filtered rows
46
+ * @param {object[]} scored - Scored and filtered items
47
+ * @param {object} weights - Active weights
48
+ * @returns {object}
49
+ */
50
+ function computeTriageSummary(filtered, scored, weights) {
51
+ const signalCoverage = {
52
+ complexity: round4(filtered.filter((r) => r.cognitive > 0).length / filtered.length),
53
+ churn: round4(filtered.filter((r) => r.churn > 0).length / filtered.length),
54
+ fanIn: round4(filtered.filter((r) => r.fan_in > 0).length / filtered.length),
55
+ mi: round4(filtered.filter((r) => r.mi > 0).length / filtered.length),
56
+ };
57
+
58
+ const scores = scored.map((it) => it.riskScore);
59
+ const avgScore =
60
+ scores.length > 0 ? round4(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
61
+ const maxScore = scores.length > 0 ? round4(Math.max(...scores)) : 0;
62
+
63
+ return {
64
+ total: filtered.length,
65
+ analyzed: scored.length,
66
+ avgScore,
67
+ maxScore,
68
+ weights,
69
+ signalCoverage,
70
+ };
71
+ }
72
+
7
73
  // ─── Data Function ────────────────────────────────────────────────────
8
74
 
75
+ const EMPTY_SUMMARY = (weights) => ({
76
+ total: 0,
77
+ analyzed: 0,
78
+ avgScore: 0,
79
+ maxScore: 0,
80
+ weights,
81
+ signalCoverage: {},
82
+ });
83
+
9
84
  /**
10
85
  * Compute composite risk scores for all symbols.
11
86
  *
@@ -17,9 +92,6 @@ export function triageData(customDbPath, opts = {}) {
17
92
  const { repo, close } = openRepo(customDbPath, opts);
18
93
  try {
19
94
  const noTests = opts.noTests || false;
20
- const fileFilter = opts.file || null;
21
- const kindFilter = opts.kind || null;
22
- const roleFilter = opts.role || null;
23
95
  const minScore = opts.minScore != null ? Number(opts.minScore) : null;
24
96
  const sort = opts.sort || 'risk';
25
97
  const weights = { ...DEFAULT_WEIGHTS, ...(opts.weights || {}) };
@@ -28,86 +100,29 @@ export function triageData(customDbPath, opts = {}) {
28
100
  try {
29
101
  rows = repo.findNodesForTriage({
30
102
  noTests,
31
- file: fileFilter,
32
- kind: kindFilter,
33
- role: roleFilter,
103
+ file: opts.file || null,
104
+ kind: opts.kind || null,
105
+ role: opts.role || null,
34
106
  });
35
107
  } catch (err) {
36
108
  warn(`triage query failed: ${err.message}`);
37
- return {
38
- items: [],
39
- summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} },
40
- };
109
+ return { items: [], summary: EMPTY_SUMMARY(weights) };
41
110
  }
42
111
 
43
- // Post-filter test files (belt-and-suspenders)
44
112
  const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows;
45
-
46
113
  if (filtered.length === 0) {
47
- return {
48
- items: [],
49
- summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} },
50
- };
114
+ return { items: [], summary: EMPTY_SUMMARY(weights) };
51
115
  }
52
116
 
53
- // Delegate scoring to classifier
54
117
  const riskMetrics = scoreRisk(filtered, weights);
118
+ const items = buildTriageItems(filtered, riskMetrics);
55
119
 
56
- // Compute risk scores
57
- const items = filtered.map((r, i) => ({
58
- name: r.name,
59
- kind: r.kind,
60
- file: r.file,
61
- line: r.line,
62
- role: r.role || null,
63
- fanIn: r.fan_in,
64
- cognitive: r.cognitive,
65
- churn: r.churn,
66
- maintainabilityIndex: r.mi,
67
- normFanIn: riskMetrics[i].normFanIn,
68
- normComplexity: riskMetrics[i].normComplexity,
69
- normChurn: riskMetrics[i].normChurn,
70
- normMI: riskMetrics[i].normMI,
71
- roleWeight: riskMetrics[i].roleWeight,
72
- riskScore: riskMetrics[i].riskScore,
73
- }));
74
-
75
- // Apply minScore filter
76
120
  const scored = minScore != null ? items.filter((it) => it.riskScore >= minScore) : items;
77
-
78
- // Sort
79
- const sortFns = {
80
- risk: (a, b) => b.riskScore - a.riskScore,
81
- complexity: (a, b) => b.cognitive - a.cognitive,
82
- churn: (a, b) => b.churn - a.churn,
83
- 'fan-in': (a, b) => b.fanIn - a.fanIn,
84
- mi: (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
85
- };
86
- scored.sort(sortFns[sort] || sortFns.risk);
87
-
88
- // Signal coverage: % of items with non-zero signal
89
- const signalCoverage = {
90
- complexity: round4(filtered.filter((r) => r.cognitive > 0).length / filtered.length),
91
- churn: round4(filtered.filter((r) => r.churn > 0).length / filtered.length),
92
- fanIn: round4(filtered.filter((r) => r.fan_in > 0).length / filtered.length),
93
- mi: round4(filtered.filter((r) => r.mi > 0).length / filtered.length),
94
- };
95
-
96
- const scores = scored.map((it) => it.riskScore);
97
- const avgScore =
98
- scores.length > 0 ? round4(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
99
- const maxScore = scores.length > 0 ? round4(Math.max(...scores)) : 0;
121
+ scored.sort(SORT_FNS[sort] || SORT_FNS.risk);
100
122
 
101
123
  const result = {
102
124
  items: scored,
103
- summary: {
104
- total: filtered.length,
105
- analyzed: scored.length,
106
- avgScore,
107
- maxScore,
108
- weights,
109
- signalCoverage,
110
- },
125
+ summary: computeTriageSummary(filtered, scored, weights),
111
126
  };
112
127
 
113
128
  return paginateResult(result, 'items', {
@@ -16,14 +16,15 @@ export const DEFAULT_WEIGHTS = {
16
16
 
17
17
  // Role weights reflect structural importance: core modules are central to the
18
18
  // dependency graph, utilities are widely imported, entry points are API
19
- // surfaces. Adapters bridge subsystems but are replaceable. Leaves and dead
20
- // code have minimal downstream impact.
19
+ // surfaces. Adapters bridge subsystems but are replaceable. Leaves, dead
20
+ // code, and test-only symbols have minimal downstream impact.
21
21
  export const ROLE_WEIGHTS = {
22
22
  core: 1.0,
23
23
  utility: 0.9,
24
24
  entry: 0.8,
25
25
  adapter: 0.5,
26
26
  leaf: 0.2,
27
+ 'test-only': 0.1,
27
28
  dead: 0.1,
28
29
  };
29
30
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Node role classification — pure logic, no DB.
3
3
  *
4
- * Roles: entry, core, utility, adapter, leaf, dead
4
+ * Roles: entry, core, utility, adapter, leaf, dead, test-only
5
5
  */
6
6
 
7
7
  export const FRAMEWORK_ENTRY_PREFIXES = ['route:', 'event:', 'command:'];
@@ -15,7 +15,7 @@ function median(sorted) {
15
15
  /**
16
16
  * Classify nodes into architectural roles based on fan-in/fan-out metrics.
17
17
  *
18
- * @param {{ id: string, name: string, fanIn: number, fanOut: number, isExported: boolean }[]} nodes
18
+ * @param {{ id: string, name: string, fanIn: number, fanOut: number, isExported: boolean, testOnlyFanIn?: number }[]} nodes
19
19
  * @returns {Map<string, string>} nodeId → role
20
20
  */
21
21
  export function classifyRoles(nodes) {
@@ -38,15 +38,18 @@ export function classifyRoles(nodes) {
38
38
  for (const node of nodes) {
39
39
  const highIn = node.fanIn >= medFanIn && node.fanIn > 0;
40
40
  const highOut = node.fanOut >= medFanOut && node.fanOut > 0;
41
+ const hasProdFanIn = typeof node.productionFanIn === 'number';
41
42
 
42
43
  let role;
43
44
  const isFrameworkEntry = FRAMEWORK_ENTRY_PREFIXES.some((p) => node.name.startsWith(p));
44
45
  if (isFrameworkEntry) {
45
46
  role = 'entry';
46
47
  } else if (node.fanIn === 0 && !node.isExported) {
47
- role = 'dead';
48
+ role = node.testOnlyFanIn > 0 ? 'test-only' : 'dead';
48
49
  } else if (node.fanIn === 0 && node.isExported) {
49
50
  role = 'entry';
51
+ } else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0) {
52
+ role = 'test-only';
50
53
  } else if (highIn && !highOut) {
51
54
  role = 'core';
52
55
  } else if (highIn && highOut) {
package/src/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  export { buildGraph } from './domain/graph/builder.js';
13
13
  export { findCycles } from './domain/graph/cycles.js';
14
14
  export {
15
+ briefData,
15
16
  childrenData,
16
17
  contextData,
17
18
  diffImpactData,