@optave/codegraph 3.1.4 → 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 (210) hide show
  1. package/README.md +29 -72
  2. package/package.json +10 -8
  3. package/src/ast-analysis/engine.js +260 -246
  4. package/src/ast-analysis/shared.js +2 -14
  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 +4 -7
  9. package/src/cli/commands/audit.js +11 -11
  10. package/src/cli/commands/batch.js +6 -5
  11. package/src/cli/commands/branch-compare.js +1 -1
  12. package/src/cli/commands/brief.js +12 -0
  13. package/src/cli/commands/build.js +1 -1
  14. package/src/cli/commands/cfg.js +5 -8
  15. package/src/cli/commands/check.js +28 -36
  16. package/src/cli/commands/children.js +9 -7
  17. package/src/cli/commands/co-change.js +5 -3
  18. package/src/cli/commands/communities.js +2 -6
  19. package/src/cli/commands/complexity.js +5 -3
  20. package/src/cli/commands/context.js +9 -8
  21. package/src/cli/commands/cycles.js +12 -8
  22. package/src/cli/commands/dataflow.js +5 -8
  23. package/src/cli/commands/deps.js +9 -8
  24. package/src/cli/commands/diff-impact.js +2 -6
  25. package/src/cli/commands/embed.js +1 -1
  26. package/src/cli/commands/export.js +34 -31
  27. package/src/cli/commands/exports.js +2 -6
  28. package/src/cli/commands/flow.js +5 -8
  29. package/src/cli/commands/fn-impact.js +9 -8
  30. package/src/cli/commands/impact.js +2 -6
  31. package/src/cli/commands/info.js +2 -2
  32. package/src/cli/commands/map.js +1 -1
  33. package/src/cli/commands/mcp.js +1 -1
  34. package/src/cli/commands/models.js +1 -1
  35. package/src/cli/commands/owners.js +5 -3
  36. package/src/cli/commands/path.js +2 -2
  37. package/src/cli/commands/plot.js +40 -31
  38. package/src/cli/commands/query.js +9 -8
  39. package/src/cli/commands/registry.js +2 -2
  40. package/src/cli/commands/roles.js +5 -8
  41. package/src/cli/commands/search.js +9 -3
  42. package/src/cli/commands/sequence.js +5 -8
  43. package/src/cli/commands/snapshot.js +6 -1
  44. package/src/cli/commands/stats.js +1 -1
  45. package/src/cli/commands/structure.js +5 -4
  46. package/src/cli/commands/triage.js +41 -30
  47. package/src/cli/commands/watch.js +1 -1
  48. package/src/cli/commands/where.js +2 -6
  49. package/src/cli/index.js +11 -5
  50. package/src/cli/shared/open-graph.js +13 -0
  51. package/src/cli/shared/options.js +22 -2
  52. package/src/cli.js +1 -1
  53. package/src/db/connection.js +140 -11
  54. package/src/{db.js → db/index.js} +12 -5
  55. package/src/db/migrations.js +42 -65
  56. package/src/db/query-builder.js +72 -9
  57. package/src/db/repository/base.js +1 -1
  58. package/src/db/repository/graph-read.js +3 -3
  59. package/src/db/repository/in-memory-repository.js +30 -28
  60. package/src/db/repository/nodes.js +10 -17
  61. package/src/domain/analysis/brief.js +155 -0
  62. package/src/domain/analysis/context.js +392 -0
  63. package/src/domain/analysis/dependencies.js +395 -0
  64. package/src/{analysis → domain/analysis}/exports.js +11 -6
  65. package/src/domain/analysis/impact.js +581 -0
  66. package/src/domain/analysis/module-map.js +348 -0
  67. package/src/{analysis → domain/analysis}/roles.js +12 -9
  68. package/src/{analysis → domain/analysis}/symbol-lookup.js +19 -11
  69. package/src/{builder → domain/graph/builder}/helpers.js +4 -4
  70. package/src/{builder → domain/graph/builder}/incremental.js +119 -93
  71. package/src/domain/graph/builder/pipeline.js +156 -0
  72. package/src/domain/graph/builder/stages/build-edges.js +376 -0
  73. package/src/{builder → domain/graph/builder}/stages/build-structure.js +4 -4
  74. package/src/{builder → domain/graph/builder}/stages/collect-files.js +2 -2
  75. package/src/{builder → domain/graph/builder}/stages/detect-changes.js +204 -183
  76. package/src/{builder → domain/graph/builder}/stages/finalize.js +4 -4
  77. package/src/domain/graph/builder/stages/insert-nodes.js +203 -0
  78. package/src/{builder → domain/graph/builder}/stages/parse-files.js +2 -2
  79. package/src/{builder → domain/graph/builder}/stages/resolve-imports.js +1 -1
  80. package/src/{builder → domain/graph/builder}/stages/run-analyses.js +2 -2
  81. package/src/{change-journal.js → domain/graph/change-journal.js} +1 -1
  82. package/src/{cycles.js → domain/graph/cycles.js} +4 -4
  83. package/src/{journal.js → domain/graph/journal.js} +1 -1
  84. package/src/{resolve.js → domain/graph/resolve.js} +2 -2
  85. package/src/{watcher.js → domain/graph/watcher.js} +7 -7
  86. package/src/{parser.js → domain/parser.js} +24 -15
  87. package/src/{queries.js → domain/queries.js} +17 -16
  88. package/src/{embeddings → domain/search}/generator.js +3 -3
  89. package/src/{embeddings → domain/search}/models.js +2 -2
  90. package/src/{embeddings → domain/search}/search/cli-formatter.js +1 -1
  91. package/src/{embeddings → domain/search}/search/filters.js +9 -5
  92. package/src/{embeddings → domain/search}/search/hybrid.js +1 -1
  93. package/src/{embeddings → domain/search}/search/keyword.js +13 -6
  94. package/src/{embeddings → domain/search}/search/prepare.js +15 -7
  95. package/src/{embeddings → domain/search}/search/semantic.js +1 -1
  96. package/src/{embeddings → domain/search}/strategies/structured.js +1 -1
  97. package/src/extractors/csharp.js +224 -207
  98. package/src/extractors/go.js +176 -172
  99. package/src/extractors/hcl.js +94 -78
  100. package/src/extractors/java.js +213 -207
  101. package/src/extractors/javascript.js +275 -305
  102. package/src/extractors/php.js +234 -221
  103. package/src/extractors/python.js +252 -250
  104. package/src/extractors/ruby.js +192 -185
  105. package/src/extractors/rust.js +182 -167
  106. package/src/{ast.js → features/ast.js} +13 -11
  107. package/src/{audit.js → features/audit.js} +20 -46
  108. package/src/{batch.js → features/batch.js} +5 -5
  109. package/src/{boundaries.js → features/boundaries.js} +100 -85
  110. package/src/{branch-compare.js → features/branch-compare.js} +3 -3
  111. package/src/{cfg.js → features/cfg.js} +141 -150
  112. package/src/{check.js → features/check.js} +13 -30
  113. package/src/{cochange.js → features/cochange.js} +5 -5
  114. package/src/{communities.js → features/communities.js} +72 -57
  115. package/src/{complexity.js → features/complexity.js} +154 -143
  116. package/src/{dataflow.js → features/dataflow.js} +155 -158
  117. package/src/{export.js → features/export.js} +6 -6
  118. package/src/{flow.js → features/flow.js} +4 -4
  119. package/src/{viewer.js → features/graph-enrichment.js} +8 -8
  120. package/src/{manifesto.js → features/manifesto.js} +15 -12
  121. package/src/{owners.js → features/owners.js} +6 -5
  122. package/src/features/sequence.js +300 -0
  123. package/src/features/shared/find-nodes.js +31 -0
  124. package/src/{snapshot.js → features/snapshot.js} +3 -3
  125. package/src/{structure.js → features/structure.js} +139 -108
  126. package/src/features/triage.js +141 -0
  127. package/src/graph/builders/dependency.js +33 -14
  128. package/src/graph/classifiers/risk.js +3 -2
  129. package/src/graph/classifiers/roles.js +6 -3
  130. package/src/index.cjs +16 -0
  131. package/src/index.js +40 -39
  132. package/src/{native.js → infrastructure/native.js} +1 -1
  133. package/src/mcp/middleware.js +1 -1
  134. package/src/mcp/server.js +68 -59
  135. package/src/mcp/tool-registry.js +15 -2
  136. package/src/mcp/tools/ast-query.js +1 -1
  137. package/src/mcp/tools/audit.js +1 -1
  138. package/src/mcp/tools/batch-query.js +1 -1
  139. package/src/mcp/tools/branch-compare.js +3 -1
  140. package/src/mcp/tools/brief.js +8 -0
  141. package/src/mcp/tools/cfg.js +1 -1
  142. package/src/mcp/tools/check.js +3 -3
  143. package/src/mcp/tools/co-changes.js +1 -1
  144. package/src/mcp/tools/code-owners.js +1 -1
  145. package/src/mcp/tools/communities.js +1 -1
  146. package/src/mcp/tools/complexity.js +1 -1
  147. package/src/mcp/tools/dataflow.js +2 -2
  148. package/src/mcp/tools/execution-flow.js +2 -2
  149. package/src/mcp/tools/export-graph.js +2 -2
  150. package/src/mcp/tools/find-cycles.js +2 -2
  151. package/src/mcp/tools/index.js +2 -0
  152. package/src/mcp/tools/list-repos.js +1 -1
  153. package/src/mcp/tools/sequence.js +1 -1
  154. package/src/mcp/tools/structure.js +1 -1
  155. package/src/mcp/tools/triage.js +2 -2
  156. package/src/{commands → presentation}/audit.js +2 -2
  157. package/src/{commands → presentation}/batch.js +1 -1
  158. package/src/{commands → presentation}/branch-compare.js +2 -2
  159. package/src/presentation/brief.js +51 -0
  160. package/src/{commands → presentation}/cfg.js +1 -1
  161. package/src/{commands → presentation}/check.js +2 -2
  162. package/src/{commands → presentation}/communities.js +1 -1
  163. package/src/{commands → presentation}/complexity.js +1 -1
  164. package/src/{commands → presentation}/dataflow.js +1 -1
  165. package/src/{commands → presentation}/flow.js +2 -2
  166. package/src/{commands → presentation}/manifesto.js +1 -1
  167. package/src/{commands → presentation}/owners.js +1 -1
  168. package/src/presentation/queries-cli/exports.js +53 -0
  169. package/src/presentation/queries-cli/impact.js +214 -0
  170. package/src/presentation/queries-cli/index.js +5 -0
  171. package/src/presentation/queries-cli/inspect.js +329 -0
  172. package/src/presentation/queries-cli/overview.js +196 -0
  173. package/src/presentation/queries-cli/path.js +65 -0
  174. package/src/presentation/queries-cli.js +27 -0
  175. package/src/{commands → presentation}/query.js +1 -1
  176. package/src/presentation/result-formatter.js +126 -3
  177. package/src/{commands → presentation}/sequence.js +2 -2
  178. package/src/{commands → presentation}/structure.js +1 -1
  179. package/src/presentation/table.js +0 -8
  180. package/src/{commands → presentation}/triage.js +1 -1
  181. package/src/{constants.js → shared/constants.js} +1 -1
  182. package/src/shared/file-utils.js +2 -2
  183. package/src/shared/generators.js +9 -5
  184. package/src/shared/hierarchy.js +1 -1
  185. package/src/{kinds.js → shared/kinds.js} +1 -1
  186. package/src/analysis/context.js +0 -408
  187. package/src/analysis/dependencies.js +0 -341
  188. package/src/analysis/impact.js +0 -463
  189. package/src/analysis/module-map.js +0 -322
  190. package/src/builder/pipeline.js +0 -130
  191. package/src/builder/stages/build-edges.js +0 -297
  192. package/src/builder/stages/insert-nodes.js +0 -195
  193. package/src/mcp.js +0 -2
  194. package/src/queries-cli.js +0 -866
  195. package/src/sequence.js +0 -289
  196. package/src/triage.js +0 -126
  197. /package/src/{builder → domain/graph/builder}/context.js +0 -0
  198. /package/src/{builder.js → domain/graph/builder.js} +0 -0
  199. /package/src/{embeddings → domain/search}/index.js +0 -0
  200. /package/src/{embeddings → domain/search}/stores/fts5.js +0 -0
  201. /package/src/{embeddings → domain/search}/stores/sqlite-blob.js +0 -0
  202. /package/src/{embeddings → domain/search}/strategies/source.js +0 -0
  203. /package/src/{embeddings → domain/search}/strategies/text-utils.js +0 -0
  204. /package/src/{config.js → infrastructure/config.js} +0 -0
  205. /package/src/{logger.js → infrastructure/logger.js} +0 -0
  206. /package/src/{registry.js → infrastructure/registry.js} +0 -0
  207. /package/src/{update-check.js → infrastructure/update-check.js} +0 -0
  208. /package/src/{commands → presentation}/cochange.js +0 -0
  209. /package/src/{errors.js → shared/errors.js} +0 -0
  210. /package/src/{paginate.js → shared/paginate.js} +0 -0
@@ -1,77 +1,45 @@
1
1
  import path from 'node:path';
2
- import { normalizePath } from './constants.js';
3
- import { getNodeId, openReadonlyOrFail, testFilterSQL } from './db.js';
4
- import { isTestFile } from './infrastructure/test-filter.js';
5
- import { debug } from './logger.js';
6
- import { paginateResult } from './paginate.js';
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
- `);
38
-
39
- const isIncremental = changedFiles != null && changedFiles.length > 0;
2
+ import { getNodeId, openReadonlyOrFail, testFilterSQL } from '../db/index.js';
3
+ import { debug } from '../infrastructure/logger.js';
4
+ import { isTestFile } from '../infrastructure/test-filter.js';
5
+ import { normalizePath } from '../shared/constants.js';
6
+ import { paginateResult } from '../shared/paginate.js';
7
+
8
+ // ─── Build-time helpers ───────────────────────────────────────────────
9
+
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,19 +259,77 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
303
259
  fileCount,
304
260
  );
305
261
  }
306
- });
307
- computeDirMetrics();
262
+ })();
263
+ }
308
264
 
309
- const dirCount = allDirs.size;
310
- debug(`Structure: ${dirCount} directories, ${fileSymbols.size} files with metrics`);
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);
299
+
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 ─────────────────────────────────────────
314
328
 
315
329
  // Re-export from classifier for backward compatibility
316
- export { FRAMEWORK_ENTRY_PREFIXES } from './graph/classifiers/roles.js';
330
+ export { FRAMEWORK_ENTRY_PREFIXES } from '../graph/classifiers/roles.js';
317
331
 
318
- import { classifyRoles } from './graph/classifiers/roles.js';
332
+ import { classifyRoles } from '../graph/classifiers/roles.js';
319
333
 
320
334
  export function classifyNodeRoles(db) {
321
335
  const rows = db
@@ -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';
@@ -0,0 +1,141 @@
1
+ import { openRepo } from '../db/index.js';
2
+ import { DEFAULT_WEIGHTS, scoreRisk } from '../graph/classifiers/risk.js';
3
+ import { warn } from '../infrastructure/logger.js';
4
+ import { isTestFile } from '../infrastructure/test-filter.js';
5
+ import { paginateResult } from '../shared/paginate.js';
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
+
73
+ // ─── Data Function ────────────────────────────────────────────────────
74
+
75
+ const EMPTY_SUMMARY = (weights) => ({
76
+ total: 0,
77
+ analyzed: 0,
78
+ avgScore: 0,
79
+ maxScore: 0,
80
+ weights,
81
+ signalCoverage: {},
82
+ });
83
+
84
+ /**
85
+ * Compute composite risk scores for all symbols.
86
+ *
87
+ * @param {string} [customDbPath] - Path to graph.db
88
+ * @param {object} [opts]
89
+ * @returns {{ items: object[], summary: object, _pagination?: object }}
90
+ */
91
+ export function triageData(customDbPath, opts = {}) {
92
+ const { repo, close } = openRepo(customDbPath, opts);
93
+ try {
94
+ const noTests = opts.noTests || false;
95
+ const minScore = opts.minScore != null ? Number(opts.minScore) : null;
96
+ const sort = opts.sort || 'risk';
97
+ const weights = { ...DEFAULT_WEIGHTS, ...(opts.weights || {}) };
98
+
99
+ let rows;
100
+ try {
101
+ rows = repo.findNodesForTriage({
102
+ noTests,
103
+ file: opts.file || null,
104
+ kind: opts.kind || null,
105
+ role: opts.role || null,
106
+ });
107
+ } catch (err) {
108
+ warn(`triage query failed: ${err.message}`);
109
+ return { items: [], summary: EMPTY_SUMMARY(weights) };
110
+ }
111
+
112
+ const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows;
113
+ if (filtered.length === 0) {
114
+ return { items: [], summary: EMPTY_SUMMARY(weights) };
115
+ }
116
+
117
+ const riskMetrics = scoreRisk(filtered, weights);
118
+ const items = buildTriageItems(filtered, riskMetrics);
119
+
120
+ const scored = minScore != null ? items.filter((it) => it.riskScore >= minScore) : items;
121
+ scored.sort(SORT_FNS[sort] || SORT_FNS.risk);
122
+
123
+ const result = {
124
+ items: scored,
125
+ summary: computeTriageSummary(filtered, scored, weights),
126
+ };
127
+
128
+ return paginateResult(result, 'items', {
129
+ limit: opts.limit,
130
+ offset: opts.offset,
131
+ });
132
+ } finally {
133
+ close();
134
+ }
135
+ }
136
+
137
+ // ─── Utilities ────────────────────────────────────────────────────────
138
+
139
+ function round4(n) {
140
+ return Math.round(n * 10000) / 10000;
141
+ }
@@ -3,32 +3,39 @@
3
3
  * Replaces inline graph construction in cycles.js, communities.js, viewer.js, export.js.
4
4
  */
5
5
 
6
- import { getCallableNodes, getCallEdges, getFileNodesAll, getImportEdges } from '../../db.js';
6
+ import {
7
+ getCallableNodes,
8
+ getCallEdges,
9
+ getFileNodesAll,
10
+ getImportEdges,
11
+ Repository,
12
+ } from '../../db/index.js';
7
13
  import { isTestFile } from '../../infrastructure/test-filter.js';
8
14
  import { CodeGraph } from '../model.js';
9
15
 
10
16
  /**
11
- * @param {object} db - Open better-sqlite3 database (readonly)
17
+ * @param {object} dbOrRepo - Open better-sqlite3 database (readonly) or a Repository instance
12
18
  * @param {object} [opts]
13
19
  * @param {boolean} [opts.fileLevel=true] - File-level (imports) or function-level (calls)
14
20
  * @param {boolean} [opts.noTests=false] - Exclude test files
15
21
  * @param {number} [opts.minConfidence] - Minimum edge confidence (function-level only)
16
22
  * @returns {CodeGraph}
17
23
  */
18
- export function buildDependencyGraph(db, opts = {}) {
24
+ export function buildDependencyGraph(dbOrRepo, opts = {}) {
19
25
  const fileLevel = opts.fileLevel !== false;
20
26
  const noTests = opts.noTests || false;
21
27
 
22
28
  if (fileLevel) {
23
- return buildFileLevelGraph(db, noTests);
29
+ return buildFileLevelGraph(dbOrRepo, noTests);
24
30
  }
25
- return buildFunctionLevelGraph(db, noTests, opts.minConfidence);
31
+ return buildFunctionLevelGraph(dbOrRepo, noTests, opts.minConfidence);
26
32
  }
27
33
 
28
- function buildFileLevelGraph(db, noTests) {
34
+ function buildFileLevelGraph(dbOrRepo, noTests) {
29
35
  const graph = new CodeGraph();
36
+ const isRepo = dbOrRepo instanceof Repository;
30
37
 
31
- let nodes = getFileNodesAll(db);
38
+ let nodes = isRepo ? dbOrRepo.getFileNodesAll() : getFileNodesAll(dbOrRepo);
32
39
  if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
33
40
 
34
41
  const nodeIds = new Set();
@@ -37,7 +44,7 @@ function buildFileLevelGraph(db, noTests) {
37
44
  nodeIds.add(n.id);
38
45
  }
39
46
 
40
- const edges = getImportEdges(db);
47
+ const edges = isRepo ? dbOrRepo.getImportEdges() : getImportEdges(dbOrRepo);
41
48
  for (const e of edges) {
42
49
  if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
43
50
  const src = String(e.source_id);
@@ -51,10 +58,11 @@ function buildFileLevelGraph(db, noTests) {
51
58
  return graph;
52
59
  }
53
60
 
54
- function buildFunctionLevelGraph(db, noTests, minConfidence) {
61
+ function buildFunctionLevelGraph(dbOrRepo, noTests, minConfidence) {
55
62
  const graph = new CodeGraph();
63
+ const isRepo = dbOrRepo instanceof Repository;
56
64
 
57
- let nodes = getCallableNodes(db);
65
+ let nodes = isRepo ? dbOrRepo.getCallableNodes() : getCallableNodes(dbOrRepo);
58
66
  if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
59
67
 
60
68
  const nodeIds = new Set();
@@ -70,11 +78,22 @@ function buildFunctionLevelGraph(db, noTests, minConfidence) {
70
78
 
71
79
  let edges;
72
80
  if (minConfidence != null) {
73
- edges = db
74
- .prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?")
75
- .all(minConfidence);
81
+ if (isRepo) {
82
+ // Trade-off: Repository.getCallEdges() returns all call edges, so we
83
+ // filter in JS. This is O(all call edges) rather than the SQL path's
84
+ // indexed WHERE clause. Acceptable for current data sizes; a dedicated
85
+ // getCallEdgesByMinConfidence(threshold) method on the Repository
86
+ // interface would be the proper fix if this becomes a bottleneck.
87
+ edges = dbOrRepo
88
+ .getCallEdges()
89
+ .filter((e) => e.confidence != null && e.confidence >= minConfidence);
90
+ } else {
91
+ edges = dbOrRepo
92
+ .prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?")
93
+ .all(minConfidence);
94
+ }
76
95
  } else {
77
- edges = getCallEdges(db);
96
+ edges = isRepo ? dbOrRepo.getCallEdges() : getCallEdges(dbOrRepo);
78
97
  }
79
98
 
80
99
  for (const e of edges) {
@@ -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