@optave/codegraph 3.1.3 → 3.1.5

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 (232) hide show
  1. package/README.md +38 -84
  2. package/package.json +13 -8
  3. package/src/ast-analysis/engine.js +32 -12
  4. package/src/ast-analysis/shared.js +6 -5
  5. package/src/cli/commands/ast.js +22 -0
  6. package/src/cli/commands/audit.js +45 -0
  7. package/src/cli/commands/batch.js +68 -0
  8. package/src/cli/commands/branch-compare.js +21 -0
  9. package/src/cli/commands/build.js +26 -0
  10. package/src/cli/commands/cfg.js +26 -0
  11. package/src/cli/commands/check.js +74 -0
  12. package/src/cli/commands/children.js +28 -0
  13. package/src/cli/commands/co-change.js +67 -0
  14. package/src/cli/commands/communities.js +19 -0
  15. package/src/cli/commands/complexity.js +46 -0
  16. package/src/cli/commands/context.js +30 -0
  17. package/src/cli/commands/cycles.js +32 -0
  18. package/src/cli/commands/dataflow.js +28 -0
  19. package/src/cli/commands/deps.js +12 -0
  20. package/src/cli/commands/diff-impact.js +26 -0
  21. package/src/cli/commands/embed.js +30 -0
  22. package/src/cli/commands/export.js +78 -0
  23. package/src/cli/commands/exports.js +14 -0
  24. package/src/cli/commands/flow.js +32 -0
  25. package/src/cli/commands/fn-impact.js +26 -0
  26. package/src/cli/commands/impact.js +12 -0
  27. package/src/cli/commands/info.js +76 -0
  28. package/src/cli/commands/map.js +19 -0
  29. package/src/cli/commands/mcp.js +18 -0
  30. package/src/cli/commands/models.js +19 -0
  31. package/src/cli/commands/owners.js +25 -0
  32. package/src/cli/commands/path.js +36 -0
  33. package/src/cli/commands/plot.js +89 -0
  34. package/src/cli/commands/query.js +45 -0
  35. package/src/cli/commands/registry.js +100 -0
  36. package/src/cli/commands/roles.js +30 -0
  37. package/src/cli/commands/search.js +42 -0
  38. package/src/cli/commands/sequence.js +28 -0
  39. package/src/cli/commands/snapshot.js +66 -0
  40. package/src/cli/commands/stats.js +15 -0
  41. package/src/cli/commands/structure.js +33 -0
  42. package/src/cli/commands/triage.js +78 -0
  43. package/src/cli/commands/watch.js +12 -0
  44. package/src/cli/commands/where.js +20 -0
  45. package/src/cli/index.js +124 -0
  46. package/src/cli/shared/open-graph.js +13 -0
  47. package/src/cli/shared/options.js +59 -0
  48. package/src/cli/shared/output.js +1 -0
  49. package/src/cli.js +11 -1522
  50. package/src/db/connection.js +130 -7
  51. package/src/{db.js → db/index.js} +17 -5
  52. package/src/db/migrations.js +42 -1
  53. package/src/db/query-builder.js +20 -12
  54. package/src/db/repository/base.js +201 -0
  55. package/src/db/repository/graph-read.js +7 -4
  56. package/src/db/repository/in-memory-repository.js +575 -0
  57. package/src/db/repository/index.js +5 -1
  58. package/src/db/repository/nodes.js +60 -6
  59. package/src/db/repository/sqlite-repository.js +219 -0
  60. package/src/domain/analysis/context.js +408 -0
  61. package/src/domain/analysis/dependencies.js +341 -0
  62. package/src/domain/analysis/exports.js +134 -0
  63. package/src/domain/analysis/impact.js +466 -0
  64. package/src/domain/analysis/module-map.js +322 -0
  65. package/src/domain/analysis/roles.js +45 -0
  66. package/src/domain/analysis/symbol-lookup.js +238 -0
  67. package/src/domain/graph/builder/context.js +85 -0
  68. package/src/domain/graph/builder/helpers.js +218 -0
  69. package/src/domain/graph/builder/incremental.js +178 -0
  70. package/src/domain/graph/builder/pipeline.js +130 -0
  71. package/src/domain/graph/builder/stages/build-edges.js +297 -0
  72. package/src/domain/graph/builder/stages/build-structure.js +113 -0
  73. package/src/domain/graph/builder/stages/collect-files.js +44 -0
  74. package/src/domain/graph/builder/stages/detect-changes.js +413 -0
  75. package/src/domain/graph/builder/stages/finalize.js +139 -0
  76. package/src/domain/graph/builder/stages/insert-nodes.js +195 -0
  77. package/src/domain/graph/builder/stages/parse-files.js +28 -0
  78. package/src/domain/graph/builder/stages/resolve-imports.js +143 -0
  79. package/src/domain/graph/builder/stages/run-analyses.js +44 -0
  80. package/src/domain/graph/builder.js +11 -0
  81. package/src/{change-journal.js → domain/graph/change-journal.js} +1 -1
  82. package/src/domain/graph/cycles.js +82 -0
  83. package/src/{journal.js → domain/graph/journal.js} +1 -1
  84. package/src/{resolve.js → domain/graph/resolve.js} +3 -3
  85. package/src/{watcher.js → domain/graph/watcher.js} +10 -150
  86. package/src/{parser.js → domain/parser.js} +5 -5
  87. package/src/domain/queries.js +48 -0
  88. package/src/domain/search/generator.js +163 -0
  89. package/src/domain/search/index.js +13 -0
  90. package/src/domain/search/models.js +218 -0
  91. package/src/domain/search/search/cli-formatter.js +151 -0
  92. package/src/domain/search/search/filters.js +46 -0
  93. package/src/domain/search/search/hybrid.js +121 -0
  94. package/src/domain/search/search/keyword.js +68 -0
  95. package/src/domain/search/search/prepare.js +66 -0
  96. package/src/domain/search/search/semantic.js +145 -0
  97. package/src/domain/search/stores/fts5.js +27 -0
  98. package/src/domain/search/stores/sqlite-blob.js +24 -0
  99. package/src/domain/search/strategies/source.js +14 -0
  100. package/src/domain/search/strategies/structured.js +43 -0
  101. package/src/domain/search/strategies/text-utils.js +43 -0
  102. package/src/extractors/csharp.js +10 -2
  103. package/src/extractors/go.js +3 -1
  104. package/src/extractors/helpers.js +71 -0
  105. package/src/extractors/java.js +9 -2
  106. package/src/extractors/javascript.js +39 -2
  107. package/src/extractors/php.js +3 -1
  108. package/src/extractors/python.js +14 -3
  109. package/src/extractors/rust.js +3 -1
  110. package/src/{ast.js → features/ast.js} +8 -8
  111. package/src/{audit.js → features/audit.js} +16 -44
  112. package/src/{batch.js → features/batch.js} +6 -5
  113. package/src/{boundaries.js → features/boundaries.js} +2 -2
  114. package/src/{branch-compare.js → features/branch-compare.js} +3 -3
  115. package/src/{cfg.js → features/cfg.js} +11 -12
  116. package/src/{check.js → features/check.js} +13 -30
  117. package/src/{cochange.js → features/cochange.js} +5 -5
  118. package/src/{communities.js → features/communities.js} +18 -90
  119. package/src/{complexity.js → features/complexity.js} +13 -13
  120. package/src/{dataflow.js → features/dataflow.js} +12 -13
  121. package/src/features/export.js +378 -0
  122. package/src/{flow.js → features/flow.js} +4 -4
  123. package/src/features/graph-enrichment.js +327 -0
  124. package/src/{manifesto.js → features/manifesto.js} +6 -6
  125. package/src/{owners.js → features/owners.js} +2 -2
  126. package/src/{sequence.js → features/sequence.js} +16 -52
  127. package/src/{snapshot.js → features/snapshot.js} +8 -7
  128. package/src/{structure.js → features/structure.js} +20 -45
  129. package/src/{triage.js → features/triage.js} +27 -79
  130. package/src/graph/algorithms/bfs.js +49 -0
  131. package/src/graph/algorithms/centrality.js +16 -0
  132. package/src/graph/algorithms/index.js +5 -0
  133. package/src/graph/algorithms/louvain.js +26 -0
  134. package/src/graph/algorithms/shortest-path.js +41 -0
  135. package/src/graph/algorithms/tarjan.js +49 -0
  136. package/src/graph/builders/dependency.js +110 -0
  137. package/src/graph/builders/index.js +3 -0
  138. package/src/graph/builders/structure.js +40 -0
  139. package/src/graph/builders/temporal.js +33 -0
  140. package/src/graph/classifiers/index.js +2 -0
  141. package/src/graph/classifiers/risk.js +85 -0
  142. package/src/graph/classifiers/roles.js +64 -0
  143. package/src/graph/index.js +13 -0
  144. package/src/graph/model.js +230 -0
  145. package/src/index.cjs +16 -0
  146. package/src/index.js +42 -219
  147. package/src/{native.js → infrastructure/native.js} +3 -1
  148. package/src/infrastructure/result-formatter.js +2 -21
  149. package/src/mcp/index.js +2 -0
  150. package/src/mcp/middleware.js +26 -0
  151. package/src/mcp/server.js +128 -0
  152. package/src/{mcp.js → mcp/tool-registry.js} +6 -675
  153. package/src/mcp/tools/ast-query.js +14 -0
  154. package/src/mcp/tools/audit.js +21 -0
  155. package/src/mcp/tools/batch-query.js +11 -0
  156. package/src/mcp/tools/branch-compare.js +12 -0
  157. package/src/mcp/tools/cfg.js +21 -0
  158. package/src/mcp/tools/check.js +43 -0
  159. package/src/mcp/tools/co-changes.js +20 -0
  160. package/src/mcp/tools/code-owners.js +12 -0
  161. package/src/mcp/tools/communities.js +15 -0
  162. package/src/mcp/tools/complexity.js +18 -0
  163. package/src/mcp/tools/context.js +17 -0
  164. package/src/mcp/tools/dataflow.js +26 -0
  165. package/src/mcp/tools/diff-impact.js +24 -0
  166. package/src/mcp/tools/execution-flow.js +26 -0
  167. package/src/mcp/tools/export-graph.js +57 -0
  168. package/src/mcp/tools/file-deps.js +12 -0
  169. package/src/mcp/tools/file-exports.js +13 -0
  170. package/src/mcp/tools/find-cycles.js +15 -0
  171. package/src/mcp/tools/fn-impact.js +15 -0
  172. package/src/mcp/tools/impact-analysis.js +12 -0
  173. package/src/mcp/tools/index.js +71 -0
  174. package/src/mcp/tools/list-functions.js +14 -0
  175. package/src/mcp/tools/list-repos.js +11 -0
  176. package/src/mcp/tools/module-map.js +6 -0
  177. package/src/mcp/tools/node-roles.js +14 -0
  178. package/src/mcp/tools/path.js +12 -0
  179. package/src/mcp/tools/query.js +30 -0
  180. package/src/mcp/tools/semantic-search.js +65 -0
  181. package/src/mcp/tools/sequence.js +17 -0
  182. package/src/mcp/tools/structure.js +15 -0
  183. package/src/mcp/tools/symbol-children.js +14 -0
  184. package/src/mcp/tools/triage.js +35 -0
  185. package/src/mcp/tools/where.js +13 -0
  186. package/src/{commands → presentation}/audit.js +2 -2
  187. package/src/{commands → presentation}/batch.js +1 -1
  188. package/src/{commands → presentation}/branch-compare.js +2 -2
  189. package/src/{commands → presentation}/cfg.js +1 -1
  190. package/src/{commands → presentation}/check.js +6 -6
  191. package/src/presentation/colors.js +44 -0
  192. package/src/{commands → presentation}/communities.js +1 -1
  193. package/src/{commands → presentation}/complexity.js +1 -1
  194. package/src/{commands → presentation}/dataflow.js +1 -1
  195. package/src/presentation/export.js +444 -0
  196. package/src/{commands → presentation}/flow.js +2 -2
  197. package/src/{commands → presentation}/manifesto.js +4 -4
  198. package/src/{commands → presentation}/owners.js +1 -1
  199. package/src/presentation/queries-cli/exports.js +46 -0
  200. package/src/presentation/queries-cli/impact.js +198 -0
  201. package/src/presentation/queries-cli/index.js +5 -0
  202. package/src/presentation/queries-cli/inspect.js +334 -0
  203. package/src/presentation/queries-cli/overview.js +197 -0
  204. package/src/presentation/queries-cli/path.js +58 -0
  205. package/src/presentation/queries-cli.js +27 -0
  206. package/src/{commands → presentation}/query.js +1 -1
  207. package/src/presentation/result-formatter.js +144 -0
  208. package/src/presentation/sequence-renderer.js +43 -0
  209. package/src/{commands → presentation}/sequence.js +2 -2
  210. package/src/{commands → presentation}/structure.js +2 -2
  211. package/src/presentation/table.js +47 -0
  212. package/src/{commands → presentation}/triage.js +1 -1
  213. package/src/{viewer.js → presentation/viewer.js} +68 -382
  214. package/src/{constants.js → shared/constants.js} +1 -1
  215. package/src/shared/errors.js +78 -0
  216. package/src/shared/file-utils.js +153 -0
  217. package/src/shared/generators.js +125 -0
  218. package/src/shared/hierarchy.js +27 -0
  219. package/src/shared/normalize.js +59 -0
  220. package/src/builder.js +0 -1486
  221. package/src/cycles.js +0 -137
  222. package/src/embedder.js +0 -1097
  223. package/src/export.js +0 -681
  224. package/src/queries-cli.js +0 -866
  225. package/src/queries.js +0 -2289
  226. /package/src/{config.js → infrastructure/config.js} +0 -0
  227. /package/src/{logger.js → infrastructure/logger.js} +0 -0
  228. /package/src/{registry.js → infrastructure/registry.js} +0 -0
  229. /package/src/{update-check.js → infrastructure/update-check.js} +0 -0
  230. /package/src/{commands → presentation}/cochange.js +0 -0
  231. /package/src/{kinds.js → shared/kinds.js} +0 -0
  232. /package/src/{paginate.js → shared/paginate.js} +0 -0
package/src/queries.js DELETED
@@ -1,2289 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { evaluateBoundaries } from './boundaries.js';
5
- import { coChangeForFiles } from './cochange.js';
6
- import { loadConfig } from './config.js';
7
- import { findCycles } from './cycles.js';
8
- import {
9
- countCrossFileCallers,
10
- findAllIncomingEdges,
11
- findAllOutgoingEdges,
12
- findCallees,
13
- findCallers,
14
- findCrossFileCallTargets,
15
- findDbPath,
16
- findDistinctCallers,
17
- findFileNodes,
18
- findImportDependents,
19
- findImportSources,
20
- findImportTargets,
21
- findIntraFileCallEdges,
22
- findNodeById,
23
- findNodeChildren,
24
- findNodesByFile,
25
- findNodesWithFanIn,
26
- getClassHierarchy,
27
- getComplexityForNode,
28
- iterateFunctionNodes,
29
- listFunctionNodes,
30
- openReadonlyOrFail,
31
- testFilterSQL,
32
- } from './db.js';
33
- import { isTestFile } from './infrastructure/test-filter.js';
34
- import { ALL_SYMBOL_KINDS } from './kinds.js';
35
- import { debug } from './logger.js';
36
- import { ownersForFiles } from './owners.js';
37
- import { paginateResult } from './paginate.js';
38
- import { LANGUAGE_REGISTRY } from './parser.js';
39
-
40
- // Re-export from dedicated module for backward compat
41
- export { isTestFile, TEST_PATTERN } from './infrastructure/test-filter.js';
42
-
43
- /**
44
- * Resolve a file path relative to repoRoot, rejecting traversal outside the repo.
45
- * Returns null if the resolved path escapes repoRoot.
46
- */
47
- function safePath(repoRoot, file) {
48
- const resolved = path.resolve(repoRoot, file);
49
- if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) return null;
50
- return resolved;
51
- }
52
-
53
- export const FALSE_POSITIVE_NAMES = new Set([
54
- 'run',
55
- 'get',
56
- 'set',
57
- 'init',
58
- 'start',
59
- 'handle',
60
- 'main',
61
- 'new',
62
- 'create',
63
- 'update',
64
- 'delete',
65
- 'process',
66
- 'execute',
67
- 'call',
68
- 'apply',
69
- 'setup',
70
- 'render',
71
- 'build',
72
- 'load',
73
- 'save',
74
- 'find',
75
- 'make',
76
- 'open',
77
- 'close',
78
- 'reset',
79
- 'send',
80
- 'read',
81
- 'write',
82
- ]);
83
- export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
84
-
85
- const FUNCTION_KINDS = ['function', 'method', 'class'];
86
-
87
- // Re-export kind/edge constants from kinds.js (canonical source)
88
- export {
89
- ALL_SYMBOL_KINDS,
90
- CORE_EDGE_KINDS,
91
- CORE_SYMBOL_KINDS,
92
- EVERY_EDGE_KIND,
93
- EVERY_SYMBOL_KIND,
94
- EXTENDED_SYMBOL_KINDS,
95
- STRUCTURAL_EDGE_KINDS,
96
- VALID_ROLES,
97
- } from './kinds.js';
98
-
99
- function resolveMethodViaHierarchy(db, methodName) {
100
- const methods = db
101
- .prepare(`SELECT * FROM nodes WHERE kind = 'method' AND name LIKE ?`)
102
- .all(`%.${methodName}`);
103
-
104
- const results = [...methods];
105
- for (const m of methods) {
106
- const className = m.name.split('.')[0];
107
- const classNode = db
108
- .prepare(`SELECT * FROM nodes WHERE name = ? AND kind = 'class' AND file = ?`)
109
- .get(className, m.file);
110
- if (!classNode) continue;
111
-
112
- const ancestors = getClassHierarchy(db, classNode.id);
113
- for (const ancestorId of ancestors) {
114
- const ancestor = db.prepare('SELECT name FROM nodes WHERE id = ?').get(ancestorId);
115
- if (!ancestor) continue;
116
- const parentMethods = db
117
- .prepare(`SELECT * FROM nodes WHERE name = ? AND kind = 'method'`)
118
- .all(`${ancestor.name}.${methodName}`);
119
- results.push(...parentMethods);
120
- }
121
- }
122
- return results;
123
- }
124
-
125
- /**
126
- * Find nodes matching a name query, ranked by relevance.
127
- * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker.
128
- */
129
- export function findMatchingNodes(db, name, opts = {}) {
130
- const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? opts.kinds : FUNCTION_KINDS;
131
-
132
- const rows = findNodesWithFanIn(db, `%${name}%`, { kinds, file: opts.file });
133
-
134
- const nodes = opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
135
-
136
- const lowerQuery = name.toLowerCase();
137
- for (const node of nodes) {
138
- const lowerName = node.name.toLowerCase();
139
- const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName;
140
-
141
- let matchScore;
142
- if (lowerName === lowerQuery || bareName === lowerQuery) {
143
- matchScore = 100;
144
- } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) {
145
- matchScore = 60;
146
- } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) {
147
- matchScore = 40;
148
- } else {
149
- matchScore = 10;
150
- }
151
-
152
- const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25);
153
- node._relevance = matchScore + fanInBonus;
154
- }
155
-
156
- nodes.sort((a, b) => b._relevance - a._relevance);
157
- return nodes;
158
- }
159
-
160
- export function kindIcon(kind) {
161
- switch (kind) {
162
- case 'function':
163
- return 'f';
164
- case 'class':
165
- return '*';
166
- case 'method':
167
- return 'o';
168
- case 'file':
169
- return '#';
170
- case 'interface':
171
- return 'I';
172
- case 'type':
173
- return 'T';
174
- case 'parameter':
175
- return 'p';
176
- case 'property':
177
- return '.';
178
- case 'constant':
179
- return 'C';
180
- default:
181
- return '-';
182
- }
183
- }
184
-
185
- // ─── Data-returning functions ───────────────────────────────────────────
186
-
187
- export function queryNameData(name, customDbPath, opts = {}) {
188
- const db = openReadonlyOrFail(customDbPath);
189
- try {
190
- const noTests = opts.noTests || false;
191
- let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
192
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
193
- if (nodes.length === 0) {
194
- return { query: name, results: [] };
195
- }
196
-
197
- const hc = new Map();
198
- const results = nodes.map((node) => {
199
- let callees = findAllOutgoingEdges(db, node.id);
200
-
201
- let callers = findAllIncomingEdges(db, node.id);
202
-
203
- if (noTests) {
204
- callees = callees.filter((c) => !isTestFile(c.file));
205
- callers = callers.filter((c) => !isTestFile(c.file));
206
- }
207
-
208
- return {
209
- ...normalizeSymbol(node, db, hc),
210
- callees: callees.map((c) => ({
211
- name: c.name,
212
- kind: c.kind,
213
- file: c.file,
214
- line: c.line,
215
- edgeKind: c.edge_kind,
216
- })),
217
- callers: callers.map((c) => ({
218
- name: c.name,
219
- kind: c.kind,
220
- file: c.file,
221
- line: c.line,
222
- edgeKind: c.edge_kind,
223
- })),
224
- };
225
- });
226
-
227
- const base = { query: name, results };
228
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
229
- } finally {
230
- db.close();
231
- }
232
- }
233
-
234
- export function impactAnalysisData(file, customDbPath, opts = {}) {
235
- const db = openReadonlyOrFail(customDbPath);
236
- try {
237
- const noTests = opts.noTests || false;
238
- const fileNodes = findFileNodes(db, `%${file}%`);
239
- if (fileNodes.length === 0) {
240
- return { file, sources: [], levels: {}, totalDependents: 0 };
241
- }
242
-
243
- const visited = new Set();
244
- const queue = [];
245
- const levels = new Map();
246
-
247
- for (const fn of fileNodes) {
248
- visited.add(fn.id);
249
- queue.push(fn.id);
250
- levels.set(fn.id, 0);
251
- }
252
-
253
- while (queue.length > 0) {
254
- const current = queue.shift();
255
- const level = levels.get(current);
256
- const dependents = findImportDependents(db, current);
257
- for (const dep of dependents) {
258
- if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
259
- visited.add(dep.id);
260
- queue.push(dep.id);
261
- levels.set(dep.id, level + 1);
262
- }
263
- }
264
- }
265
-
266
- const byLevel = {};
267
- for (const [id, level] of levels) {
268
- if (level === 0) continue;
269
- if (!byLevel[level]) byLevel[level] = [];
270
- const node = findNodeById(db, id);
271
- if (node) byLevel[level].push({ file: node.file });
272
- }
273
-
274
- return {
275
- file,
276
- sources: fileNodes.map((f) => f.file),
277
- levels: byLevel,
278
- totalDependents: visited.size - fileNodes.length,
279
- };
280
- } finally {
281
- db.close();
282
- }
283
- }
284
-
285
- export function moduleMapData(customDbPath, limit = 20, opts = {}) {
286
- const db = openReadonlyOrFail(customDbPath);
287
- try {
288
- const noTests = opts.noTests || false;
289
-
290
- const testFilter = testFilterSQL('n.file', noTests);
291
-
292
- const nodes = db
293
- .prepare(`
294
- SELECT n.*,
295
- (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges,
296
- (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges
297
- FROM nodes n
298
- WHERE n.kind = 'file'
299
- ${testFilter}
300
- ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC
301
- LIMIT ?
302
- `)
303
- .all(limit);
304
-
305
- const topNodes = nodes.map((n) => ({
306
- file: n.file,
307
- dir: path.dirname(n.file) || '.',
308
- inEdges: n.in_edges,
309
- outEdges: n.out_edges,
310
- coupling: n.in_edges + n.out_edges,
311
- }));
312
-
313
- const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
314
- const totalEdges = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
315
- const totalFiles = db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get().c;
316
-
317
- return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } };
318
- } finally {
319
- db.close();
320
- }
321
- }
322
-
323
- export function fileDepsData(file, customDbPath, opts = {}) {
324
- const db = openReadonlyOrFail(customDbPath);
325
- try {
326
- const noTests = opts.noTests || false;
327
- const fileNodes = findFileNodes(db, `%${file}%`);
328
- if (fileNodes.length === 0) {
329
- return { file, results: [] };
330
- }
331
-
332
- const results = fileNodes.map((fn) => {
333
- let importsTo = findImportTargets(db, fn.id);
334
- if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
335
-
336
- let importedBy = findImportSources(db, fn.id);
337
- if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
338
-
339
- const defs = findNodesByFile(db, fn.file);
340
-
341
- return {
342
- file: fn.file,
343
- imports: importsTo.map((i) => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })),
344
- importedBy: importedBy.map((i) => ({ file: i.file })),
345
- definitions: defs.map((d) => ({ name: d.name, kind: d.kind, line: d.line })),
346
- };
347
- });
348
-
349
- const base = { file, results };
350
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
351
- } finally {
352
- db.close();
353
- }
354
- }
355
-
356
- export function fnDepsData(name, customDbPath, opts = {}) {
357
- const db = openReadonlyOrFail(customDbPath);
358
- try {
359
- const depth = opts.depth || 3;
360
- const noTests = opts.noTests || false;
361
- const hc = new Map();
362
-
363
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
364
- if (nodes.length === 0) {
365
- return { name, results: [] };
366
- }
367
-
368
- const results = nodes.map((node) => {
369
- const callees = findCallees(db, node.id);
370
- const filteredCallees = noTests ? callees.filter((c) => !isTestFile(c.file)) : callees;
371
-
372
- let callers = findCallers(db, node.id);
373
-
374
- if (node.kind === 'method' && node.name.includes('.')) {
375
- const methodName = node.name.split('.').pop();
376
- const relatedMethods = resolveMethodViaHierarchy(db, methodName);
377
- for (const rm of relatedMethods) {
378
- if (rm.id === node.id) continue;
379
- const extraCallers = findCallers(db, rm.id);
380
- callers.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
381
- }
382
- }
383
- if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
384
-
385
- // Transitive callers
386
- const transitiveCallers = {};
387
- if (depth > 1) {
388
- const visited = new Set([node.id]);
389
- let frontier = callers
390
- .map((c) => {
391
- const row = db
392
- .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?')
393
- .get(c.name, c.kind, c.file, c.line);
394
- return row ? { ...c, id: row.id } : null;
395
- })
396
- .filter(Boolean);
397
-
398
- for (let d = 2; d <= depth; d++) {
399
- const nextFrontier = [];
400
- for (const f of frontier) {
401
- if (visited.has(f.id)) continue;
402
- visited.add(f.id);
403
- const upstream = db
404
- .prepare(`
405
- SELECT n.name, n.kind, n.file, n.line
406
- FROM edges e JOIN nodes n ON e.source_id = n.id
407
- WHERE e.target_id = ? AND e.kind = 'calls'
408
- `)
409
- .all(f.id);
410
- for (const u of upstream) {
411
- if (noTests && isTestFile(u.file)) continue;
412
- const uid = db
413
- .prepare(
414
- 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
415
- )
416
- .get(u.name, u.kind, u.file, u.line)?.id;
417
- if (uid && !visited.has(uid)) {
418
- nextFrontier.push({ ...u, id: uid });
419
- }
420
- }
421
- }
422
- if (nextFrontier.length > 0) {
423
- transitiveCallers[d] = nextFrontier.map((n) => ({
424
- name: n.name,
425
- kind: n.kind,
426
- file: n.file,
427
- line: n.line,
428
- }));
429
- }
430
- frontier = nextFrontier;
431
- if (frontier.length === 0) break;
432
- }
433
- }
434
-
435
- return {
436
- ...normalizeSymbol(node, db, hc),
437
- callees: filteredCallees.map((c) => ({
438
- name: c.name,
439
- kind: c.kind,
440
- file: c.file,
441
- line: c.line,
442
- })),
443
- callers: callers.map((c) => ({
444
- name: c.name,
445
- kind: c.kind,
446
- file: c.file,
447
- line: c.line,
448
- viaHierarchy: c.viaHierarchy || undefined,
449
- })),
450
- transitiveCallers,
451
- };
452
- });
453
-
454
- const base = { name, results };
455
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
456
- } finally {
457
- db.close();
458
- }
459
- }
460
-
461
- export function fnImpactData(name, customDbPath, opts = {}) {
462
- const db = openReadonlyOrFail(customDbPath);
463
- try {
464
- const maxDepth = opts.depth || 5;
465
- const noTests = opts.noTests || false;
466
- const hc = new Map();
467
-
468
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
469
- if (nodes.length === 0) {
470
- return { name, results: [] };
471
- }
472
-
473
- const results = nodes.map((node) => {
474
- const visited = new Set([node.id]);
475
- const levels = {};
476
- let frontier = [node.id];
477
-
478
- for (let d = 1; d <= maxDepth; d++) {
479
- const nextFrontier = [];
480
- for (const fid of frontier) {
481
- const callers = findDistinctCallers(db, fid);
482
- for (const c of callers) {
483
- if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
484
- visited.add(c.id);
485
- nextFrontier.push(c.id);
486
- if (!levels[d]) levels[d] = [];
487
- levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
488
- }
489
- }
490
- }
491
- frontier = nextFrontier;
492
- if (frontier.length === 0) break;
493
- }
494
-
495
- return {
496
- ...normalizeSymbol(node, db, hc),
497
- levels,
498
- totalDependents: visited.size - 1,
499
- };
500
- });
501
-
502
- const base = { name, results };
503
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
504
- } finally {
505
- db.close();
506
- }
507
- }
508
-
509
- export function pathData(from, to, customDbPath, opts = {}) {
510
- const db = openReadonlyOrFail(customDbPath);
511
- try {
512
- const noTests = opts.noTests || false;
513
- const maxDepth = opts.maxDepth || 10;
514
- const edgeKinds = opts.edgeKinds || ['calls'];
515
- const reverse = opts.reverse || false;
516
-
517
- const fromNodes = findMatchingNodes(db, from, {
518
- noTests,
519
- file: opts.fromFile,
520
- kind: opts.kind,
521
- });
522
- if (fromNodes.length === 0) {
523
- return {
524
- from,
525
- to,
526
- found: false,
527
- error: `No symbol matching "${from}"`,
528
- fromCandidates: [],
529
- toCandidates: [],
530
- };
531
- }
532
-
533
- const toNodes = findMatchingNodes(db, to, {
534
- noTests,
535
- file: opts.toFile,
536
- kind: opts.kind,
537
- });
538
- if (toNodes.length === 0) {
539
- return {
540
- from,
541
- to,
542
- found: false,
543
- error: `No symbol matching "${to}"`,
544
- fromCandidates: fromNodes
545
- .slice(0, 5)
546
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })),
547
- toCandidates: [],
548
- };
549
- }
550
-
551
- const sourceNode = fromNodes[0];
552
- const targetNode = toNodes[0];
553
-
554
- const fromCandidates = fromNodes
555
- .slice(0, 5)
556
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
557
- const toCandidates = toNodes
558
- .slice(0, 5)
559
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
560
-
561
- // Self-path
562
- if (sourceNode.id === targetNode.id) {
563
- return {
564
- from,
565
- to,
566
- fromCandidates,
567
- toCandidates,
568
- found: true,
569
- hops: 0,
570
- path: [
571
- {
572
- name: sourceNode.name,
573
- kind: sourceNode.kind,
574
- file: sourceNode.file,
575
- line: sourceNode.line,
576
- edgeKind: null,
577
- },
578
- ],
579
- alternateCount: 0,
580
- edgeKinds,
581
- reverse,
582
- maxDepth,
583
- };
584
- }
585
-
586
- // Build edge kind filter
587
- const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
588
-
589
- // BFS — direction depends on `reverse` flag
590
- // Forward: source_id → target_id (A calls... calls B)
591
- // Reverse: target_id → source_id (B is called by... called by A)
592
- const neighborQuery = reverse
593
- ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
594
- FROM edges e JOIN nodes n ON e.source_id = n.id
595
- WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
596
- : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
597
- FROM edges e JOIN nodes n ON e.target_id = n.id
598
- WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
599
- const neighborStmt = db.prepare(neighborQuery);
600
-
601
- const visited = new Set([sourceNode.id]);
602
- // parent map: nodeId → { parentId, edgeKind }
603
- const parent = new Map();
604
- let queue = [sourceNode.id];
605
- let found = false;
606
- let alternateCount = 0;
607
- let foundDepth = -1;
608
-
609
- for (let depth = 1; depth <= maxDepth; depth++) {
610
- const nextQueue = [];
611
- for (const currentId of queue) {
612
- const neighbors = neighborStmt.all(currentId, ...edgeKinds);
613
- for (const n of neighbors) {
614
- if (noTests && isTestFile(n.file)) continue;
615
- if (n.id === targetNode.id) {
616
- if (!found) {
617
- found = true;
618
- foundDepth = depth;
619
- parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
620
- }
621
- alternateCount++;
622
- continue;
623
- }
624
- if (!visited.has(n.id)) {
625
- visited.add(n.id);
626
- parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
627
- nextQueue.push(n.id);
628
- }
629
- }
630
- }
631
- if (found) break;
632
- queue = nextQueue;
633
- if (queue.length === 0) break;
634
- }
635
-
636
- if (!found) {
637
- return {
638
- from,
639
- to,
640
- fromCandidates,
641
- toCandidates,
642
- found: false,
643
- hops: null,
644
- path: [],
645
- alternateCount: 0,
646
- edgeKinds,
647
- reverse,
648
- maxDepth,
649
- };
650
- }
651
-
652
- // alternateCount includes the one we kept; subtract 1 for "alternates"
653
- alternateCount = Math.max(0, alternateCount - 1);
654
-
655
- // Reconstruct path from target back to source
656
- const pathIds = [targetNode.id];
657
- let cur = targetNode.id;
658
- while (cur !== sourceNode.id) {
659
- const p = parent.get(cur);
660
- pathIds.push(p.parentId);
661
- cur = p.parentId;
662
- }
663
- pathIds.reverse();
664
-
665
- // Build path with node info
666
- const nodeCache = new Map();
667
- const getNode = (id) => {
668
- if (nodeCache.has(id)) return nodeCache.get(id);
669
- const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
670
- nodeCache.set(id, row);
671
- return row;
672
- };
673
-
674
- const resultPath = pathIds.map((id, idx) => {
675
- const node = getNode(id);
676
- const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
677
- return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
678
- });
679
-
680
- return {
681
- from,
682
- to,
683
- fromCandidates,
684
- toCandidates,
685
- found: true,
686
- hops: foundDepth,
687
- path: resultPath,
688
- alternateCount,
689
- edgeKinds,
690
- reverse,
691
- maxDepth,
692
- };
693
- } finally {
694
- db.close();
695
- }
696
- }
697
-
698
- /**
699
- * Fix #2: Shell injection vulnerability.
700
- * Uses execFileSync instead of execSync to prevent shell interpretation of user input.
701
- */
702
- export function diffImpactData(customDbPath, opts = {}) {
703
- const db = openReadonlyOrFail(customDbPath);
704
- try {
705
- const noTests = opts.noTests || false;
706
- const maxDepth = opts.depth || 3;
707
-
708
- const dbPath = findDbPath(customDbPath);
709
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
710
-
711
- // Verify we're in a git repository before running git diff
712
- let checkDir = repoRoot;
713
- let isGitRepo = false;
714
- while (checkDir) {
715
- if (fs.existsSync(path.join(checkDir, '.git'))) {
716
- isGitRepo = true;
717
- break;
718
- }
719
- const parent = path.dirname(checkDir);
720
- if (parent === checkDir) break;
721
- checkDir = parent;
722
- }
723
- if (!isGitRepo) {
724
- return { error: `Not a git repository: ${repoRoot}` };
725
- }
726
-
727
- let diffOutput;
728
- try {
729
- const args = opts.staged
730
- ? ['diff', '--cached', '--unified=0', '--no-color']
731
- : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
732
- diffOutput = execFileSync('git', args, {
733
- cwd: repoRoot,
734
- encoding: 'utf-8',
735
- maxBuffer: 10 * 1024 * 1024,
736
- stdio: ['pipe', 'pipe', 'pipe'],
737
- });
738
- } catch (e) {
739
- return { error: `Failed to run git diff: ${e.message}` };
740
- }
741
-
742
- if (!diffOutput.trim()) {
743
- return {
744
- changedFiles: 0,
745
- newFiles: [],
746
- affectedFunctions: [],
747
- affectedFiles: [],
748
- summary: null,
749
- };
750
- }
751
-
752
- const changedRanges = new Map();
753
- const newFiles = new Set();
754
- let currentFile = null;
755
- let prevIsDevNull = false;
756
- for (const line of diffOutput.split('\n')) {
757
- if (line.startsWith('--- /dev/null')) {
758
- prevIsDevNull = true;
759
- continue;
760
- }
761
- if (line.startsWith('--- ')) {
762
- prevIsDevNull = false;
763
- continue;
764
- }
765
- const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
766
- if (fileMatch) {
767
- currentFile = fileMatch[1];
768
- if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
769
- if (prevIsDevNull) newFiles.add(currentFile);
770
- prevIsDevNull = false;
771
- continue;
772
- }
773
- const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
774
- if (hunkMatch && currentFile) {
775
- const start = parseInt(hunkMatch[1], 10);
776
- const count = parseInt(hunkMatch[2] || '1', 10);
777
- changedRanges.get(currentFile).push({ start, end: start + count - 1 });
778
- }
779
- }
780
-
781
- if (changedRanges.size === 0) {
782
- return {
783
- changedFiles: 0,
784
- newFiles: [],
785
- affectedFunctions: [],
786
- affectedFiles: [],
787
- summary: null,
788
- };
789
- }
790
-
791
- const affectedFunctions = [];
792
- for (const [file, ranges] of changedRanges) {
793
- if (noTests && isTestFile(file)) continue;
794
- const defs = db
795
- .prepare(
796
- `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
797
- )
798
- .all(file);
799
- for (let i = 0; i < defs.length; i++) {
800
- const def = defs[i];
801
- const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
802
- for (const range of ranges) {
803
- if (range.start <= endLine && range.end >= def.line) {
804
- affectedFunctions.push(def);
805
- break;
806
- }
807
- }
808
- }
809
- }
810
-
811
- const allAffected = new Set();
812
- const functionResults = affectedFunctions.map((fn) => {
813
- const visited = new Set([fn.id]);
814
- let frontier = [fn.id];
815
- let totalCallers = 0;
816
- const levels = {};
817
- const edges = [];
818
- const idToKey = new Map();
819
- idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
820
- for (let d = 1; d <= maxDepth; d++) {
821
- const nextFrontier = [];
822
- for (const fid of frontier) {
823
- const callers = findDistinctCallers(db, fid);
824
- for (const c of callers) {
825
- if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
826
- visited.add(c.id);
827
- nextFrontier.push(c.id);
828
- allAffected.add(`${c.file}:${c.name}`);
829
- const callerKey = `${c.file}::${c.name}:${c.line}`;
830
- idToKey.set(c.id, callerKey);
831
- if (!levels[d]) levels[d] = [];
832
- levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
833
- edges.push({ from: idToKey.get(fid), to: callerKey });
834
- totalCallers++;
835
- }
836
- }
837
- }
838
- frontier = nextFrontier;
839
- if (frontier.length === 0) break;
840
- }
841
- return {
842
- name: fn.name,
843
- kind: fn.kind,
844
- file: fn.file,
845
- line: fn.line,
846
- transitiveCallers: totalCallers,
847
- levels,
848
- edges,
849
- };
850
- });
851
-
852
- const affectedFiles = new Set();
853
- for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
854
-
855
- // Look up historically coupled files from co-change data
856
- let historicallyCoupled = [];
857
- try {
858
- db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
859
- const changedFilesList = [...changedRanges.keys()];
860
- const coResults = coChangeForFiles(changedFilesList, db, {
861
- minJaccard: 0.3,
862
- limit: 20,
863
- noTests,
864
- });
865
- // Exclude files already found via static analysis
866
- historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
867
- } catch {
868
- /* co_changes table doesn't exist — skip silently */
869
- }
870
-
871
- // Look up CODEOWNERS for changed + affected files
872
- let ownership = null;
873
- try {
874
- const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
875
- const ownerResult = ownersForFiles(allFilePaths, repoRoot);
876
- if (ownerResult.affectedOwners.length > 0) {
877
- ownership = {
878
- owners: Object.fromEntries(ownerResult.owners),
879
- affectedOwners: ownerResult.affectedOwners,
880
- suggestedReviewers: ownerResult.suggestedReviewers,
881
- };
882
- }
883
- } catch {
884
- /* CODEOWNERS missing or unreadable — skip silently */
885
- }
886
-
887
- // Check boundary violations scoped to changed files
888
- let boundaryViolations = [];
889
- let boundaryViolationCount = 0;
890
- try {
891
- const config = loadConfig(repoRoot);
892
- const boundaryConfig = config.manifesto?.boundaries;
893
- if (boundaryConfig) {
894
- const result = evaluateBoundaries(db, boundaryConfig, {
895
- scopeFiles: [...changedRanges.keys()],
896
- noTests,
897
- });
898
- boundaryViolations = result.violations;
899
- boundaryViolationCount = result.violationCount;
900
- }
901
- } catch {
902
- /* boundary check failed — skip silently */
903
- }
904
-
905
- const base = {
906
- changedFiles: changedRanges.size,
907
- newFiles: [...newFiles],
908
- affectedFunctions: functionResults,
909
- affectedFiles: [...affectedFiles],
910
- historicallyCoupled,
911
- ownership,
912
- boundaryViolations,
913
- boundaryViolationCount,
914
- summary: {
915
- functionsChanged: affectedFunctions.length,
916
- callersAffected: allAffected.size,
917
- filesAffected: affectedFiles.size,
918
- historicallyCoupledCount: historicallyCoupled.length,
919
- ownersAffected: ownership ? ownership.affectedOwners.length : 0,
920
- boundaryViolationCount,
921
- },
922
- };
923
- return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
924
- } finally {
925
- db.close();
926
- }
927
- }
928
-
929
- export function diffImpactMermaid(customDbPath, opts = {}) {
930
- const data = diffImpactData(customDbPath, opts);
931
- if (data.error) return data.error;
932
- if (data.changedFiles === 0 || data.affectedFunctions.length === 0) {
933
- return 'flowchart TB\n none["No impacted functions detected"]';
934
- }
935
-
936
- const newFileSet = new Set(data.newFiles || []);
937
- const lines = ['flowchart TB'];
938
-
939
- // Assign stable Mermaid node IDs
940
- let nodeCounter = 0;
941
- const nodeIdMap = new Map();
942
- const nodeLabels = new Map();
943
- function nodeId(key, label) {
944
- if (!nodeIdMap.has(key)) {
945
- nodeIdMap.set(key, `n${nodeCounter++}`);
946
- if (label) nodeLabels.set(key, label);
947
- }
948
- return nodeIdMap.get(key);
949
- }
950
-
951
- // Register all nodes (changed functions + their callers)
952
- for (const fn of data.affectedFunctions) {
953
- nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name);
954
- for (const callers of Object.values(fn.levels || {})) {
955
- for (const c of callers) {
956
- nodeId(`${c.file}::${c.name}:${c.line}`, c.name);
957
- }
958
- }
959
- }
960
-
961
- // Collect all edges and determine blast radius
962
- const allEdges = new Set();
963
- const edgeFromNodes = new Set();
964
- const edgeToNodes = new Set();
965
- const changedKeys = new Set();
966
-
967
- for (const fn of data.affectedFunctions) {
968
- changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`);
969
- for (const edge of fn.edges || []) {
970
- const edgeKey = `${edge.from}|${edge.to}`;
971
- if (!allEdges.has(edgeKey)) {
972
- allEdges.add(edgeKey);
973
- edgeFromNodes.add(edge.from);
974
- edgeToNodes.add(edge.to);
975
- }
976
- }
977
- }
978
-
979
- // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree)
980
- const blastRadiusKeys = new Set();
981
- for (const key of edgeToNodes) {
982
- if (!edgeFromNodes.has(key) && !changedKeys.has(key)) {
983
- blastRadiusKeys.add(key);
984
- }
985
- }
986
-
987
- // Intermediate callers: not changed, not blast radius
988
- const intermediateKeys = new Set();
989
- for (const key of edgeToNodes) {
990
- if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) {
991
- intermediateKeys.add(key);
992
- }
993
- }
994
-
995
- // Group changed functions by file
996
- const fileGroups = new Map();
997
- for (const fn of data.affectedFunctions) {
998
- if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []);
999
- fileGroups.get(fn.file).push(fn);
1000
- }
1001
-
1002
- // Emit changed-file subgraphs
1003
- let sgCounter = 0;
1004
- for (const [file, fns] of fileGroups) {
1005
- const isNew = newFileSet.has(file);
1006
- const tag = isNew ? 'new' : 'modified';
1007
- const sgId = `sg${sgCounter++}`;
1008
- lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`);
1009
- for (const fn of fns) {
1010
- const key = `${fn.file}::${fn.name}:${fn.line}`;
1011
- lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`);
1012
- }
1013
- lines.push(' end');
1014
- const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800';
1015
- lines.push(` style ${sgId} ${style}`);
1016
- }
1017
-
1018
- // Emit intermediate caller nodes (outside subgraphs)
1019
- for (const key of intermediateKeys) {
1020
- lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
1021
- }
1022
-
1023
- // Emit blast radius subgraph
1024
- if (blastRadiusKeys.size > 0) {
1025
- const sgId = `sg${sgCounter++}`;
1026
- lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`);
1027
- for (const key of blastRadiusKeys) {
1028
- lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
1029
- }
1030
- lines.push(' end');
1031
- lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`);
1032
- }
1033
-
1034
- // Emit edges (impact flows from changed fn toward callers)
1035
- for (const edgeKey of allEdges) {
1036
- const [from, to] = edgeKey.split('|');
1037
- lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`);
1038
- }
1039
-
1040
- return lines.join('\n');
1041
- }
1042
-
1043
- export function listFunctionsData(customDbPath, opts = {}) {
1044
- const db = openReadonlyOrFail(customDbPath);
1045
- try {
1046
- const noTests = opts.noTests || false;
1047
-
1048
- let rows = listFunctionNodes(db, { file: opts.file, pattern: opts.pattern });
1049
-
1050
- if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
1051
-
1052
- const hc = new Map();
1053
- const functions = rows.map((r) => normalizeSymbol(r, db, hc));
1054
- const base = { count: functions.length, functions };
1055
- return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
1056
- } finally {
1057
- db.close();
1058
- }
1059
- }
1060
-
1061
- /**
1062
- * Generator: stream functions one-by-one using .iterate() for memory efficiency.
1063
- * @param {string} [customDbPath]
1064
- * @param {object} [opts]
1065
- * @param {boolean} [opts.noTests]
1066
- * @param {string} [opts.file]
1067
- * @param {string} [opts.pattern]
1068
- * @yields {{ name: string, kind: string, file: string, line: number, role: string|null }}
1069
- */
1070
- export function* iterListFunctions(customDbPath, opts = {}) {
1071
- const db = openReadonlyOrFail(customDbPath);
1072
- try {
1073
- const noTests = opts.noTests || false;
1074
-
1075
- for (const row of iterateFunctionNodes(db, { file: opts.file, pattern: opts.pattern })) {
1076
- if (noTests && isTestFile(row.file)) continue;
1077
- yield {
1078
- name: row.name,
1079
- kind: row.kind,
1080
- file: row.file,
1081
- line: row.line,
1082
- endLine: row.end_line ?? null,
1083
- role: row.role ?? null,
1084
- };
1085
- }
1086
- } finally {
1087
- db.close();
1088
- }
1089
- }
1090
-
1091
- /**
1092
- * Generator: stream role-classified symbols one-by-one.
1093
- * @param {string} [customDbPath]
1094
- * @param {object} [opts]
1095
- * @param {boolean} [opts.noTests]
1096
- * @param {string} [opts.role]
1097
- * @param {string} [opts.file]
1098
- * @yields {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string }}
1099
- */
1100
- export function* iterRoles(customDbPath, opts = {}) {
1101
- const db = openReadonlyOrFail(customDbPath);
1102
- try {
1103
- const noTests = opts.noTests || false;
1104
- const conditions = ['role IS NOT NULL'];
1105
- const params = [];
1106
-
1107
- if (opts.role) {
1108
- conditions.push('role = ?');
1109
- params.push(opts.role);
1110
- }
1111
- if (opts.file) {
1112
- conditions.push('file LIKE ?');
1113
- params.push(`%${opts.file}%`);
1114
- }
1115
-
1116
- const stmt = db.prepare(
1117
- `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
1118
- );
1119
- for (const row of stmt.iterate(...params)) {
1120
- if (noTests && isTestFile(row.file)) continue;
1121
- yield {
1122
- name: row.name,
1123
- kind: row.kind,
1124
- file: row.file,
1125
- line: row.line,
1126
- endLine: row.end_line ?? null,
1127
- role: row.role ?? null,
1128
- };
1129
- }
1130
- } finally {
1131
- db.close();
1132
- }
1133
- }
1134
-
1135
- /**
1136
- * Generator: stream symbol lookup results one-by-one.
1137
- * @param {string} target - Symbol name to search for (partial match)
1138
- * @param {string} [customDbPath]
1139
- * @param {object} [opts]
1140
- * @param {boolean} [opts.noTests]
1141
- * @yields {{ name: string, kind: string, file: string, line: number, role: string|null, exported: boolean, uses: object[] }}
1142
- */
1143
- export function* iterWhere(target, customDbPath, opts = {}) {
1144
- const db = openReadonlyOrFail(customDbPath);
1145
- try {
1146
- const noTests = opts.noTests || false;
1147
- const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
1148
- const stmt = db.prepare(
1149
- `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
1150
- );
1151
- const crossFileCallersStmt = db.prepare(
1152
- `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
1153
- WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
1154
- );
1155
- const usesStmt = db.prepare(
1156
- `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
1157
- WHERE e.target_id = ? AND e.kind = 'calls'`,
1158
- );
1159
- for (const node of stmt.iterate(`%${target}%`, ...ALL_SYMBOL_KINDS)) {
1160
- if (noTests && isTestFile(node.file)) continue;
1161
-
1162
- const crossFileCallers = crossFileCallersStmt.get(node.id, node.file);
1163
- const exported = crossFileCallers.cnt > 0;
1164
-
1165
- let uses = usesStmt.all(node.id);
1166
- if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
1167
-
1168
- yield {
1169
- name: node.name,
1170
- kind: node.kind,
1171
- file: node.file,
1172
- line: node.line,
1173
- role: node.role || null,
1174
- exported,
1175
- uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
1176
- };
1177
- }
1178
- } finally {
1179
- db.close();
1180
- }
1181
- }
1182
-
1183
- export function statsData(customDbPath, opts = {}) {
1184
- const db = openReadonlyOrFail(customDbPath);
1185
- try {
1186
- const noTests = opts.noTests || false;
1187
-
1188
- // Build set of test file IDs for filtering nodes and edges
1189
- let testFileIds = null;
1190
- if (noTests) {
1191
- const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
1192
- testFileIds = new Set();
1193
- const testFiles = new Set();
1194
- for (const n of allFileNodes) {
1195
- if (isTestFile(n.file)) {
1196
- testFileIds.add(n.id);
1197
- testFiles.add(n.file);
1198
- }
1199
- }
1200
- // Also collect non-file node IDs that belong to test files
1201
- const allNodes = db.prepare('SELECT id, file FROM nodes').all();
1202
- for (const n of allNodes) {
1203
- if (testFiles.has(n.file)) testFileIds.add(n.id);
1204
- }
1205
- }
1206
-
1207
- // Node breakdown by kind
1208
- let nodeRows;
1209
- if (noTests) {
1210
- const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
1211
- const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
1212
- const counts = {};
1213
- for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
1214
- nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1215
- } else {
1216
- nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
1217
- }
1218
- const nodesByKind = {};
1219
- let totalNodes = 0;
1220
- for (const r of nodeRows) {
1221
- nodesByKind[r.kind] = r.c;
1222
- totalNodes += r.c;
1223
- }
1224
-
1225
- // Edge breakdown by kind
1226
- let edgeRows;
1227
- if (noTests) {
1228
- const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
1229
- const filtered = allEdges.filter(
1230
- (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
1231
- );
1232
- const counts = {};
1233
- for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
1234
- edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
1235
- } else {
1236
- edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
1237
- }
1238
- const edgesByKind = {};
1239
- let totalEdges = 0;
1240
- for (const r of edgeRows) {
1241
- edgesByKind[r.kind] = r.c;
1242
- totalEdges += r.c;
1243
- }
1244
-
1245
- // File/language distribution — map extensions via LANGUAGE_REGISTRY
1246
- const extToLang = new Map();
1247
- for (const entry of LANGUAGE_REGISTRY) {
1248
- for (const ext of entry.extensions) {
1249
- extToLang.set(ext, entry.id);
1250
- }
1251
- }
1252
- let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
1253
- if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
1254
- const byLanguage = {};
1255
- for (const row of fileNodes) {
1256
- const ext = path.extname(row.file).toLowerCase();
1257
- const lang = extToLang.get(ext) || 'other';
1258
- byLanguage[lang] = (byLanguage[lang] || 0) + 1;
1259
- }
1260
- const langCount = Object.keys(byLanguage).length;
1261
-
1262
- // Cycles
1263
- const fileCycles = findCycles(db, { fileLevel: true, noTests });
1264
- const fnCycles = findCycles(db, { fileLevel: false, noTests });
1265
-
1266
- // Top 5 coupling hotspots (fan-in + fan-out, file nodes)
1267
- const testFilter = testFilterSQL('n.file', noTests);
1268
- const hotspotRows = db
1269
- .prepare(`
1270
- SELECT n.file,
1271
- (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
1272
- (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
1273
- FROM nodes n
1274
- WHERE n.kind = 'file' ${testFilter}
1275
- ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
1276
- + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
1277
- `)
1278
- .all();
1279
- const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
1280
- const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
1281
- file: r.file,
1282
- fanIn: r.fan_in,
1283
- fanOut: r.fan_out,
1284
- }));
1285
-
1286
- // Embeddings metadata
1287
- let embeddings = null;
1288
- try {
1289
- const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
1290
- if (count && count.c > 0) {
1291
- const meta = {};
1292
- const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
1293
- for (const r of metaRows) meta[r.key] = r.value;
1294
- embeddings = {
1295
- count: count.c,
1296
- model: meta.model || null,
1297
- dim: meta.dim ? parseInt(meta.dim, 10) : null,
1298
- builtAt: meta.built_at || null,
1299
- };
1300
- }
1301
- } catch {
1302
- /* embeddings table may not exist */
1303
- }
1304
-
1305
- // Graph quality metrics
1306
- const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
1307
- const totalCallable = db
1308
- .prepare(
1309
- `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
1310
- )
1311
- .get().c;
1312
- const callableWithCallers = db
1313
- .prepare(`
1314
- SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
1315
- JOIN nodes n ON e.target_id = n.id
1316
- WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
1317
- `)
1318
- .get().c;
1319
- const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
1320
-
1321
- const totalCallEdges = db
1322
- .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'")
1323
- .get().c;
1324
- const highConfCallEdges = db
1325
- .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
1326
- .get().c;
1327
- const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
1328
-
1329
- // False-positive warnings: generic names with > threshold callers
1330
- const fpRows = db
1331
- .prepare(`
1332
- SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
1333
- FROM nodes n
1334
- LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
1335
- WHERE n.kind IN ('function', 'method')
1336
- GROUP BY n.id
1337
- HAVING caller_count > ?
1338
- ORDER BY caller_count DESC
1339
- `)
1340
- .all(FALSE_POSITIVE_CALLER_THRESHOLD);
1341
- const falsePositiveWarnings = fpRows
1342
- .filter((r) =>
1343
- FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
1344
- )
1345
- .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
1346
-
1347
- // Edges from suspicious nodes
1348
- let fpEdgeCount = 0;
1349
- for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
1350
- const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
1351
-
1352
- const score = Math.round(
1353
- callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
1354
- );
1355
-
1356
- const quality = {
1357
- score,
1358
- callerCoverage: {
1359
- ratio: callerCoverage,
1360
- covered: callableWithCallers,
1361
- total: totalCallable,
1362
- },
1363
- callConfidence: {
1364
- ratio: callConfidence,
1365
- highConf: highConfCallEdges,
1366
- total: totalCallEdges,
1367
- },
1368
- falsePositiveWarnings,
1369
- };
1370
-
1371
- // Role distribution
1372
- let roleRows;
1373
- if (noTests) {
1374
- const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
1375
- const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
1376
- const counts = {};
1377
- for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
1378
- roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
1379
- } else {
1380
- roleRows = db
1381
- .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
1382
- .all();
1383
- }
1384
- const roles = {};
1385
- for (const r of roleRows) roles[r.role] = r.c;
1386
-
1387
- // Complexity summary
1388
- let complexity = null;
1389
- try {
1390
- const cRows = db
1391
- .prepare(
1392
- `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
1393
- FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
1394
- WHERE n.kind IN ('function','method') ${testFilter}`,
1395
- )
1396
- .all();
1397
- if (cRows.length > 0) {
1398
- const miValues = cRows.map((r) => r.maintainability_index || 0);
1399
- complexity = {
1400
- analyzed: cRows.length,
1401
- avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
1402
- avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
1403
- maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
1404
- maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
1405
- avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
1406
- minMI: +Math.min(...miValues).toFixed(1),
1407
- };
1408
- }
1409
- } catch {
1410
- /* table may not exist in older DBs */
1411
- }
1412
-
1413
- return {
1414
- nodes: { total: totalNodes, byKind: nodesByKind },
1415
- edges: { total: totalEdges, byKind: edgesByKind },
1416
- files: { total: fileNodes.length, languages: langCount, byLanguage },
1417
- cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
1418
- hotspots,
1419
- embeddings,
1420
- quality,
1421
- roles,
1422
- complexity,
1423
- };
1424
- } finally {
1425
- db.close();
1426
- }
1427
- }
1428
-
1429
- // ─── Context helpers (private) ──────────────────────────────────────────
1430
-
1431
- function readSourceRange(repoRoot, file, startLine, endLine) {
1432
- try {
1433
- const absPath = safePath(repoRoot, file);
1434
- if (!absPath) return null;
1435
- const content = fs.readFileSync(absPath, 'utf-8');
1436
- const lines = content.split('\n');
1437
- const start = Math.max(0, (startLine || 1) - 1);
1438
- const end = Math.min(lines.length, endLine || startLine + 50);
1439
- return lines.slice(start, end).join('\n');
1440
- } catch (e) {
1441
- debug(`readSourceRange failed for ${file}: ${e.message}`);
1442
- return null;
1443
- }
1444
- }
1445
-
1446
- function extractSummary(fileLines, line) {
1447
- if (!fileLines || !line || line <= 1) return null;
1448
- const idx = line - 2; // line above the definition (0-indexed)
1449
- // Scan up to 10 lines above for JSDoc or comment
1450
- let jsdocEnd = -1;
1451
- for (let i = idx; i >= Math.max(0, idx - 10); i--) {
1452
- const trimmed = fileLines[i].trim();
1453
- if (trimmed.endsWith('*/')) {
1454
- jsdocEnd = i;
1455
- break;
1456
- }
1457
- if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
1458
- // Single-line comment immediately above
1459
- const text = trimmed
1460
- .replace(/^\/\/\s*/, '')
1461
- .replace(/^#\s*/, '')
1462
- .trim();
1463
- return text.length > 100 ? `${text.slice(0, 100)}...` : text;
1464
- }
1465
- if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
1466
- }
1467
- if (jsdocEnd >= 0) {
1468
- // Find opening /**
1469
- for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
1470
- if (fileLines[i].trim().startsWith('/**')) {
1471
- // Extract first non-tag, non-empty line
1472
- for (let j = i + 1; j <= jsdocEnd; j++) {
1473
- const docLine = fileLines[j]
1474
- .trim()
1475
- .replace(/^\*\s?/, '')
1476
- .trim();
1477
- if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
1478
- return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
1479
- }
1480
- }
1481
- break;
1482
- }
1483
- }
1484
- }
1485
- return null;
1486
- }
1487
-
1488
- function extractSignature(fileLines, line) {
1489
- if (!fileLines || !line) return null;
1490
- const idx = line - 1;
1491
- // Gather up to 5 lines to handle multi-line params
1492
- const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
1493
-
1494
- // JS/TS: function name(params) or (params) => or async function
1495
- let m = chunk.match(
1496
- /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
1497
- );
1498
- if (m) {
1499
- return {
1500
- params: m[1].trim() || null,
1501
- returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
1502
- };
1503
- }
1504
- // Arrow: const name = (params) => or (params):ReturnType =>
1505
- m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
1506
- if (m) {
1507
- return {
1508
- params: m[1].trim() || null,
1509
- returnType: m[2] ? m[2].trim() : null,
1510
- };
1511
- }
1512
- // Python: def name(params) -> return:
1513
- m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
1514
- if (m) {
1515
- return {
1516
- params: m[1].trim() || null,
1517
- returnType: m[2] ? m[2].trim() : null,
1518
- };
1519
- }
1520
- // Go: func (recv) name(params) (returns)
1521
- m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
1522
- if (m) {
1523
- return {
1524
- params: m[1].trim() || null,
1525
- returnType: (m[2] || m[3] || '').trim() || null,
1526
- };
1527
- }
1528
- // Rust: fn name(params) -> ReturnType
1529
- m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
1530
- if (m) {
1531
- return {
1532
- params: m[1].trim() || null,
1533
- returnType: m[2] ? m[2].trim() : null,
1534
- };
1535
- }
1536
- return null;
1537
- }
1538
-
1539
- // ─── contextData ────────────────────────────────────────────────────────
1540
-
1541
- export function contextData(name, customDbPath, opts = {}) {
1542
- const db = openReadonlyOrFail(customDbPath);
1543
- try {
1544
- const depth = opts.depth || 0;
1545
- const noSource = opts.noSource || false;
1546
- const noTests = opts.noTests || false;
1547
- const includeTests = opts.includeTests || false;
1548
-
1549
- const dbPath = findDbPath(customDbPath);
1550
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
1551
-
1552
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1553
- if (nodes.length === 0) {
1554
- return { name, results: [] };
1555
- }
1556
-
1557
- // No hardcoded slice — pagination handles bounding via limit/offset
1558
-
1559
- // File-lines cache to avoid re-reading the same file
1560
- const fileCache = new Map();
1561
- function getFileLines(file) {
1562
- if (fileCache.has(file)) return fileCache.get(file);
1563
- try {
1564
- const absPath = safePath(repoRoot, file);
1565
- if (!absPath) {
1566
- fileCache.set(file, null);
1567
- return null;
1568
- }
1569
- const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1570
- fileCache.set(file, lines);
1571
- return lines;
1572
- } catch (e) {
1573
- debug(`getFileLines failed for ${file}: ${e.message}`);
1574
- fileCache.set(file, null);
1575
- return null;
1576
- }
1577
- }
1578
-
1579
- const results = nodes.map((node) => {
1580
- const fileLines = getFileLines(node.file);
1581
-
1582
- // Source
1583
- const source = noSource
1584
- ? null
1585
- : readSourceRange(repoRoot, node.file, node.line, node.end_line);
1586
-
1587
- // Signature
1588
- const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1589
-
1590
- // Callees
1591
- const calleeRows = findCallees(db, node.id);
1592
- const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
1593
-
1594
- const callees = filteredCallees.map((c) => {
1595
- const cLines = getFileLines(c.file);
1596
- const summary = cLines ? extractSummary(cLines, c.line) : null;
1597
- let calleeSource = null;
1598
- if (depth >= 1) {
1599
- calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
1600
- }
1601
- return {
1602
- name: c.name,
1603
- kind: c.kind,
1604
- file: c.file,
1605
- line: c.line,
1606
- endLine: c.end_line || null,
1607
- summary,
1608
- source: calleeSource,
1609
- };
1610
- });
1611
-
1612
- // Deep callee expansion via BFS (depth > 1, capped at 5)
1613
- if (depth > 1) {
1614
- const visited = new Set(filteredCallees.map((c) => c.id));
1615
- visited.add(node.id);
1616
- let frontier = filteredCallees.map((c) => c.id);
1617
- const maxDepth = Math.min(depth, 5);
1618
- for (let d = 2; d <= maxDepth; d++) {
1619
- const nextFrontier = [];
1620
- for (const fid of frontier) {
1621
- const deeper = findCallees(db, fid);
1622
- for (const c of deeper) {
1623
- if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
1624
- visited.add(c.id);
1625
- nextFrontier.push(c.id);
1626
- const cLines = getFileLines(c.file);
1627
- callees.push({
1628
- name: c.name,
1629
- kind: c.kind,
1630
- file: c.file,
1631
- line: c.line,
1632
- endLine: c.end_line || null,
1633
- summary: cLines ? extractSummary(cLines, c.line) : null,
1634
- source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
1635
- });
1636
- }
1637
- }
1638
- }
1639
- frontier = nextFrontier;
1640
- if (frontier.length === 0) break;
1641
- }
1642
- }
1643
-
1644
- // Callers
1645
- let callerRows = findCallers(db, node.id);
1646
-
1647
- // Method hierarchy resolution
1648
- if (node.kind === 'method' && node.name.includes('.')) {
1649
- const methodName = node.name.split('.').pop();
1650
- const relatedMethods = resolveMethodViaHierarchy(db, methodName);
1651
- for (const rm of relatedMethods) {
1652
- if (rm.id === node.id) continue;
1653
- const extraCallers = findCallers(db, rm.id);
1654
- callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
1655
- }
1656
- }
1657
- if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
1658
-
1659
- const callers = callerRows.map((c) => ({
1660
- name: c.name,
1661
- kind: c.kind,
1662
- file: c.file,
1663
- line: c.line,
1664
- viaHierarchy: c.viaHierarchy || undefined,
1665
- }));
1666
-
1667
- // Related tests: callers that live in test files
1668
- const testCallerRows = findCallers(db, node.id);
1669
- const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
1670
-
1671
- const testsByFile = new Map();
1672
- for (const tc of testCallers) {
1673
- if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
1674
- testsByFile.get(tc.file).push(tc);
1675
- }
1676
-
1677
- const relatedTests = [];
1678
- for (const [file] of testsByFile) {
1679
- const tLines = getFileLines(file);
1680
- const testNames = [];
1681
- if (tLines) {
1682
- for (const tl of tLines) {
1683
- const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
1684
- if (tm) testNames.push(tm[1]);
1685
- }
1686
- }
1687
- const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
1688
- relatedTests.push({
1689
- file,
1690
- testCount: testNames.length,
1691
- testNames,
1692
- source: testSource,
1693
- });
1694
- }
1695
-
1696
- // Complexity metrics
1697
- let complexityMetrics = null;
1698
- try {
1699
- const cRow = getComplexityForNode(db, node.id);
1700
- if (cRow) {
1701
- complexityMetrics = {
1702
- cognitive: cRow.cognitive,
1703
- cyclomatic: cRow.cyclomatic,
1704
- maxNesting: cRow.max_nesting,
1705
- maintainabilityIndex: cRow.maintainability_index || 0,
1706
- halsteadVolume: cRow.halstead_volume || 0,
1707
- };
1708
- }
1709
- } catch {
1710
- /* table may not exist */
1711
- }
1712
-
1713
- // Children (parameters, properties, constants)
1714
- let nodeChildren = [];
1715
- try {
1716
- nodeChildren = findNodeChildren(db, node.id).map((c) => ({
1717
- name: c.name,
1718
- kind: c.kind,
1719
- line: c.line,
1720
- endLine: c.end_line || null,
1721
- }));
1722
- } catch {
1723
- /* parent_id column may not exist */
1724
- }
1725
-
1726
- return {
1727
- name: node.name,
1728
- kind: node.kind,
1729
- file: node.file,
1730
- line: node.line,
1731
- role: node.role || null,
1732
- endLine: node.end_line || null,
1733
- source,
1734
- signature,
1735
- complexity: complexityMetrics,
1736
- children: nodeChildren.length > 0 ? nodeChildren : undefined,
1737
- callees,
1738
- callers,
1739
- relatedTests,
1740
- };
1741
- });
1742
-
1743
- const base = { name, results };
1744
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
1745
- } finally {
1746
- db.close();
1747
- }
1748
- }
1749
-
1750
- // ─── childrenData ───────────────────────────────────────────────────────
1751
-
1752
- export function childrenData(name, customDbPath, opts = {}) {
1753
- const db = openReadonlyOrFail(customDbPath);
1754
- try {
1755
- const noTests = opts.noTests || false;
1756
-
1757
- const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1758
- if (nodes.length === 0) {
1759
- return { name, results: [] };
1760
- }
1761
-
1762
- const results = nodes.map((node) => {
1763
- let children;
1764
- try {
1765
- children = findNodeChildren(db, node.id);
1766
- } catch {
1767
- children = [];
1768
- }
1769
- if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file));
1770
- return {
1771
- name: node.name,
1772
- kind: node.kind,
1773
- file: node.file,
1774
- line: node.line,
1775
- children: children.map((c) => ({
1776
- name: c.name,
1777
- kind: c.kind,
1778
- line: c.line,
1779
- endLine: c.end_line || null,
1780
- })),
1781
- };
1782
- });
1783
-
1784
- const base = { name, results };
1785
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
1786
- } finally {
1787
- db.close();
1788
- }
1789
- }
1790
-
1791
- // ─── explainData ────────────────────────────────────────────────────────
1792
-
1793
- function isFileLikeTarget(target) {
1794
- if (target.includes('/') || target.includes('\\')) return true;
1795
- const ext = path.extname(target).toLowerCase();
1796
- if (!ext) return false;
1797
- for (const entry of LANGUAGE_REGISTRY) {
1798
- if (entry.extensions.includes(ext)) return true;
1799
- }
1800
- return false;
1801
- }
1802
-
1803
- function explainFileImpl(db, target, getFileLines) {
1804
- const fileNodes = findFileNodes(db, `%${target}%`);
1805
- if (fileNodes.length === 0) return [];
1806
-
1807
- return fileNodes.map((fn) => {
1808
- const symbols = findNodesByFile(db, fn.file);
1809
-
1810
- // IDs of symbols that have incoming calls from other files (public)
1811
- const publicIds = findCrossFileCallTargets(db, fn.file);
1812
-
1813
- const fileLines = getFileLines(fn.file);
1814
- const mapSymbol = (s) => ({
1815
- name: s.name,
1816
- kind: s.kind,
1817
- line: s.line,
1818
- role: s.role || null,
1819
- summary: fileLines ? extractSummary(fileLines, s.line) : null,
1820
- signature: fileLines ? extractSignature(fileLines, s.line) : null,
1821
- });
1822
-
1823
- const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol);
1824
- const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol);
1825
-
1826
- // Imports / importedBy
1827
- const imports = findImportTargets(db, fn.id).map((r) => ({ file: r.file }));
1828
-
1829
- const importedBy = findImportSources(db, fn.id).map((r) => ({ file: r.file }));
1830
-
1831
- // Intra-file data flow
1832
- const intraEdges = findIntraFileCallEdges(db, fn.file);
1833
-
1834
- const dataFlowMap = new Map();
1835
- for (const edge of intraEdges) {
1836
- if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []);
1837
- dataFlowMap.get(edge.caller_name).push(edge.callee_name);
1838
- }
1839
- const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({
1840
- caller,
1841
- callees,
1842
- }));
1843
-
1844
- // Line count: prefer node_metrics (actual), fall back to MAX(end_line)
1845
- const metric = db
1846
- .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`)
1847
- .get(fn.id);
1848
- let lineCount = metric?.line_count || null;
1849
- if (!lineCount) {
1850
- const maxLine = db
1851
- .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`)
1852
- .get(fn.file);
1853
- lineCount = maxLine?.max_end || null;
1854
- }
1855
-
1856
- return {
1857
- file: fn.file,
1858
- lineCount,
1859
- symbolCount: symbols.length,
1860
- publicApi,
1861
- internal,
1862
- imports,
1863
- importedBy,
1864
- dataFlow,
1865
- };
1866
- });
1867
- }
1868
-
1869
- function explainFunctionImpl(db, target, noTests, getFileLines) {
1870
- let nodes = db
1871
- .prepare(
1872
- `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module') ORDER BY file, line`,
1873
- )
1874
- .all(`%${target}%`);
1875
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1876
- if (nodes.length === 0) return [];
1877
-
1878
- const hc = new Map();
1879
- return nodes.slice(0, 10).map((node) => {
1880
- const fileLines = getFileLines(node.file);
1881
- const lineCount = node.end_line ? node.end_line - node.line + 1 : null;
1882
- const summary = fileLines ? extractSummary(fileLines, node.line) : null;
1883
- const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1884
-
1885
- const callees = findCallees(db, node.id).map((c) => ({
1886
- name: c.name,
1887
- kind: c.kind,
1888
- file: c.file,
1889
- line: c.line,
1890
- }));
1891
-
1892
- let callers = findCallers(db, node.id).map((c) => ({
1893
- name: c.name,
1894
- kind: c.kind,
1895
- file: c.file,
1896
- line: c.line,
1897
- }));
1898
- if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
1899
-
1900
- const testCallerRows = findCallers(db, node.id);
1901
- const seenFiles = new Set();
1902
- const relatedTests = testCallerRows
1903
- .filter((r) => isTestFile(r.file) && !seenFiles.has(r.file) && seenFiles.add(r.file))
1904
- .map((r) => ({ file: r.file }));
1905
-
1906
- // Complexity metrics
1907
- let complexityMetrics = null;
1908
- try {
1909
- const cRow = getComplexityForNode(db, node.id);
1910
- if (cRow) {
1911
- complexityMetrics = {
1912
- cognitive: cRow.cognitive,
1913
- cyclomatic: cRow.cyclomatic,
1914
- maxNesting: cRow.max_nesting,
1915
- maintainabilityIndex: cRow.maintainability_index || 0,
1916
- halsteadVolume: cRow.halstead_volume || 0,
1917
- };
1918
- }
1919
- } catch {
1920
- /* table may not exist */
1921
- }
1922
-
1923
- return {
1924
- ...normalizeSymbol(node, db, hc),
1925
- lineCount,
1926
- summary,
1927
- signature,
1928
- complexity: complexityMetrics,
1929
- callees,
1930
- callers,
1931
- relatedTests,
1932
- };
1933
- });
1934
- }
1935
-
1936
- export function explainData(target, customDbPath, opts = {}) {
1937
- const db = openReadonlyOrFail(customDbPath);
1938
- try {
1939
- const noTests = opts.noTests || false;
1940
- const depth = opts.depth || 0;
1941
- const kind = isFileLikeTarget(target) ? 'file' : 'function';
1942
-
1943
- const dbPath = findDbPath(customDbPath);
1944
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
1945
-
1946
- const fileCache = new Map();
1947
- function getFileLines(file) {
1948
- if (fileCache.has(file)) return fileCache.get(file);
1949
- try {
1950
- const absPath = safePath(repoRoot, file);
1951
- if (!absPath) {
1952
- fileCache.set(file, null);
1953
- return null;
1954
- }
1955
- const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1956
- fileCache.set(file, lines);
1957
- return lines;
1958
- } catch (e) {
1959
- debug(`getFileLines failed for ${file}: ${e.message}`);
1960
- fileCache.set(file, null);
1961
- return null;
1962
- }
1963
- }
1964
-
1965
- const results =
1966
- kind === 'file'
1967
- ? explainFileImpl(db, target, getFileLines)
1968
- : explainFunctionImpl(db, target, noTests, getFileLines);
1969
-
1970
- // Recursive dependency explanation for function targets
1971
- if (kind === 'function' && depth > 0 && results.length > 0) {
1972
- const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`));
1973
-
1974
- function explainCallees(parentResults, currentDepth) {
1975
- if (currentDepth <= 0) return;
1976
- for (const r of parentResults) {
1977
- const newCallees = [];
1978
- for (const callee of r.callees) {
1979
- const key = `${callee.name}:${callee.file}:${callee.line}`;
1980
- if (visited.has(key)) continue;
1981
- visited.add(key);
1982
- const calleeResults = explainFunctionImpl(db, callee.name, noTests, getFileLines);
1983
- const exact = calleeResults.find(
1984
- (cr) => cr.file === callee.file && cr.line === callee.line,
1985
- );
1986
- if (exact) {
1987
- exact._depth = (r._depth || 0) + 1;
1988
- newCallees.push(exact);
1989
- }
1990
- }
1991
- if (newCallees.length > 0) {
1992
- r.depDetails = newCallees;
1993
- explainCallees(newCallees, currentDepth - 1);
1994
- }
1995
- }
1996
- }
1997
-
1998
- explainCallees(results, depth);
1999
- }
2000
-
2001
- const base = { target, kind, results };
2002
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2003
- } finally {
2004
- db.close();
2005
- }
2006
- }
2007
-
2008
- // ─── whereData ──────────────────────────────────────────────────────────
2009
-
2010
- function getFileHash(db, file) {
2011
- const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file);
2012
- return row ? row.hash : null;
2013
- }
2014
-
2015
- /**
2016
- * Normalize a raw DB/query row into the stable 7-field symbol shape.
2017
- * @param {object} row - Raw row (from SELECT * or explicit columns)
2018
- * @param {object} [db] - Open DB handle; when null, fileHash will be null
2019
- * @param {Map} [hashCache] - Optional per-file cache to avoid repeated getFileHash calls
2020
- * @returns {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string|null, fileHash: string|null }}
2021
- */
2022
- export function normalizeSymbol(row, db, hashCache) {
2023
- let fileHash = null;
2024
- if (db) {
2025
- if (hashCache) {
2026
- if (!hashCache.has(row.file)) {
2027
- hashCache.set(row.file, getFileHash(db, row.file));
2028
- }
2029
- fileHash = hashCache.get(row.file);
2030
- } else {
2031
- fileHash = getFileHash(db, row.file);
2032
- }
2033
- }
2034
- return {
2035
- name: row.name,
2036
- kind: row.kind,
2037
- file: row.file,
2038
- line: row.line,
2039
- endLine: row.end_line ?? row.endLine ?? null,
2040
- role: row.role ?? null,
2041
- fileHash,
2042
- };
2043
- }
2044
-
2045
- function whereSymbolImpl(db, target, noTests) {
2046
- const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
2047
- let nodes = db
2048
- .prepare(
2049
- `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
2050
- )
2051
- .all(`%${target}%`, ...ALL_SYMBOL_KINDS);
2052
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
2053
-
2054
- const hc = new Map();
2055
- return nodes.map((node) => {
2056
- const crossCount = countCrossFileCallers(db, node.id, node.file);
2057
- const exported = crossCount > 0;
2058
-
2059
- let uses = findCallers(db, node.id);
2060
- if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
2061
-
2062
- return {
2063
- ...normalizeSymbol(node, db, hc),
2064
- exported,
2065
- uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
2066
- };
2067
- });
2068
- }
2069
-
2070
- function whereFileImpl(db, target) {
2071
- const fileNodes = findFileNodes(db, `%${target}%`);
2072
- if (fileNodes.length === 0) return [];
2073
-
2074
- return fileNodes.map((fn) => {
2075
- const symbols = findNodesByFile(db, fn.file);
2076
-
2077
- const imports = findImportTargets(db, fn.id).map((r) => r.file);
2078
-
2079
- const importedBy = findImportSources(db, fn.id).map((r) => r.file);
2080
-
2081
- const exportedIds = findCrossFileCallTargets(db, fn.file);
2082
-
2083
- const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name);
2084
-
2085
- return {
2086
- file: fn.file,
2087
- fileHash: getFileHash(db, fn.file),
2088
- symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })),
2089
- imports,
2090
- importedBy,
2091
- exported,
2092
- };
2093
- });
2094
- }
2095
-
2096
- export function whereData(target, customDbPath, opts = {}) {
2097
- const db = openReadonlyOrFail(customDbPath);
2098
- try {
2099
- const noTests = opts.noTests || false;
2100
- const fileMode = opts.file || false;
2101
-
2102
- const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
2103
-
2104
- const base = { target, mode: fileMode ? 'file' : 'symbol', results };
2105
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2106
- } finally {
2107
- db.close();
2108
- }
2109
- }
2110
-
2111
- // ─── rolesData ──────────────────────────────────────────────────────────
2112
-
2113
- export function rolesData(customDbPath, opts = {}) {
2114
- const db = openReadonlyOrFail(customDbPath);
2115
- try {
2116
- const noTests = opts.noTests || false;
2117
- const filterRole = opts.role || null;
2118
- const filterFile = opts.file || null;
2119
-
2120
- const conditions = ['role IS NOT NULL'];
2121
- const params = [];
2122
-
2123
- if (filterRole) {
2124
- conditions.push('role = ?');
2125
- params.push(filterRole);
2126
- }
2127
- if (filterFile) {
2128
- conditions.push('file LIKE ?');
2129
- params.push(`%${filterFile}%`);
2130
- }
2131
-
2132
- let rows = db
2133
- .prepare(
2134
- `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
2135
- )
2136
- .all(...params);
2137
-
2138
- if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
2139
-
2140
- const summary = {};
2141
- for (const r of rows) {
2142
- summary[r.role] = (summary[r.role] || 0) + 1;
2143
- }
2144
-
2145
- const hc = new Map();
2146
- const symbols = rows.map((r) => normalizeSymbol(r, db, hc));
2147
- const base = { count: symbols.length, summary, symbols };
2148
- return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset });
2149
- } finally {
2150
- db.close();
2151
- }
2152
- }
2153
-
2154
- // ─── exportsData ─────────────────────────────────────────────────────
2155
-
2156
- function exportsFileImpl(db, target, noTests, getFileLines, unused) {
2157
- const fileNodes = findFileNodes(db, `%${target}%`);
2158
- if (fileNodes.length === 0) return [];
2159
-
2160
- // Detect whether exported column exists
2161
- let hasExportedCol = false;
2162
- try {
2163
- db.prepare('SELECT exported FROM nodes LIMIT 0').raw();
2164
- hasExportedCol = true;
2165
- } catch {
2166
- /* old DB without exported column */
2167
- }
2168
-
2169
- return fileNodes.map((fn) => {
2170
- const symbols = findNodesByFile(db, fn.file);
2171
-
2172
- let exported;
2173
- if (hasExportedCol) {
2174
- // Use the exported column populated during build
2175
- exported = db
2176
- .prepare(
2177
- "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line",
2178
- )
2179
- .all(fn.file);
2180
- } else {
2181
- // Fallback: symbols that have incoming calls from other files
2182
- const exportedIds = findCrossFileCallTargets(db, fn.file);
2183
- exported = symbols.filter((s) => exportedIds.has(s.id));
2184
- }
2185
- const internalCount = symbols.length - exported.length;
2186
-
2187
- const results = exported.map((s) => {
2188
- const fileLines = getFileLines(fn.file);
2189
-
2190
- let consumers = db
2191
- .prepare(
2192
- `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
2193
- WHERE e.target_id = ? AND e.kind = 'calls'`,
2194
- )
2195
- .all(s.id);
2196
- if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file));
2197
-
2198
- return {
2199
- name: s.name,
2200
- kind: s.kind,
2201
- line: s.line,
2202
- endLine: s.end_line ?? null,
2203
- role: s.role || null,
2204
- signature: fileLines ? extractSignature(fileLines, s.line) : null,
2205
- summary: fileLines ? extractSummary(fileLines, s.line) : null,
2206
- consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })),
2207
- consumerCount: consumers.length,
2208
- };
2209
- });
2210
-
2211
- const totalUnused = results.filter((r) => r.consumerCount === 0).length;
2212
-
2213
- // Files that re-export this file (barrel → this file)
2214
- const reexports = db
2215
- .prepare(
2216
- `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
2217
- WHERE e.target_id = ? AND e.kind = 'reexports'`,
2218
- )
2219
- .all(fn.id)
2220
- .map((r) => ({ file: r.file }));
2221
-
2222
- let filteredResults = results;
2223
- if (unused) {
2224
- filteredResults = results.filter((r) => r.consumerCount === 0);
2225
- }
2226
-
2227
- return {
2228
- file: fn.file,
2229
- results: filteredResults,
2230
- reexports,
2231
- totalExported: exported.length,
2232
- totalInternal: internalCount,
2233
- totalUnused,
2234
- };
2235
- });
2236
- }
2237
-
2238
- export function exportsData(file, customDbPath, opts = {}) {
2239
- const db = openReadonlyOrFail(customDbPath);
2240
- try {
2241
- const noTests = opts.noTests || false;
2242
-
2243
- const dbFilePath = findDbPath(customDbPath);
2244
- const repoRoot = path.resolve(path.dirname(dbFilePath), '..');
2245
-
2246
- const fileCache = new Map();
2247
- function getFileLines(file) {
2248
- if (fileCache.has(file)) return fileCache.get(file);
2249
- try {
2250
- const absPath = safePath(repoRoot, file);
2251
- if (!absPath) {
2252
- fileCache.set(file, null);
2253
- return null;
2254
- }
2255
- const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
2256
- fileCache.set(file, lines);
2257
- return lines;
2258
- } catch {
2259
- fileCache.set(file, null);
2260
- return null;
2261
- }
2262
- }
2263
-
2264
- const unused = opts.unused || false;
2265
- const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused);
2266
-
2267
- if (fileResults.length === 0) {
2268
- return paginateResult(
2269
- { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 },
2270
- 'results',
2271
- { limit: opts.limit, offset: opts.offset },
2272
- );
2273
- }
2274
-
2275
- // For single-file match return flat; for multi-match return first (like explainData)
2276
- const first = fileResults[0];
2277
- const base = {
2278
- file: first.file,
2279
- results: first.results,
2280
- reexports: first.reexports,
2281
- totalExported: first.totalExported,
2282
- totalInternal: first.totalInternal,
2283
- totalUnused: first.totalUnused,
2284
- };
2285
- return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2286
- } finally {
2287
- db.close();
2288
- }
2289
- }