@optave/codegraph 3.1.5 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +3 -2
  2. package/package.json +7 -7
  3. package/src/ast-analysis/engine.js +252 -258
  4. package/src/ast-analysis/shared.js +0 -12
  5. package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
  6. package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
  7. package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
  8. package/src/cli/commands/ast.js +2 -1
  9. package/src/cli/commands/audit.js +2 -1
  10. package/src/cli/commands/batch.js +2 -1
  11. package/src/cli/commands/brief.js +12 -0
  12. package/src/cli/commands/cfg.js +2 -1
  13. package/src/cli/commands/check.js +20 -23
  14. package/src/cli/commands/children.js +6 -1
  15. package/src/cli/commands/complexity.js +2 -1
  16. package/src/cli/commands/context.js +6 -1
  17. package/src/cli/commands/dataflow.js +2 -1
  18. package/src/cli/commands/deps.js +8 -3
  19. package/src/cli/commands/flow.js +2 -1
  20. package/src/cli/commands/fn-impact.js +6 -1
  21. package/src/cli/commands/owners.js +4 -2
  22. package/src/cli/commands/query.js +6 -1
  23. package/src/cli/commands/roles.js +2 -1
  24. package/src/cli/commands/search.js +8 -2
  25. package/src/cli/commands/sequence.js +2 -1
  26. package/src/cli/commands/triage.js +38 -27
  27. package/src/db/connection.js +18 -12
  28. package/src/db/migrations.js +41 -64
  29. package/src/db/query-builder.js +60 -4
  30. package/src/db/repository/in-memory-repository.js +27 -16
  31. package/src/db/repository/nodes.js +8 -10
  32. package/src/domain/analysis/brief.js +155 -0
  33. package/src/domain/analysis/context.js +174 -190
  34. package/src/domain/analysis/dependencies.js +200 -146
  35. package/src/domain/analysis/exports.js +3 -2
  36. package/src/domain/analysis/impact.js +267 -152
  37. package/src/domain/analysis/module-map.js +247 -221
  38. package/src/domain/analysis/roles.js +8 -5
  39. package/src/domain/analysis/symbol-lookup.js +7 -5
  40. package/src/domain/graph/builder/helpers.js +1 -1
  41. package/src/domain/graph/builder/incremental.js +116 -90
  42. package/src/domain/graph/builder/pipeline.js +106 -80
  43. package/src/domain/graph/builder/stages/build-edges.js +318 -239
  44. package/src/domain/graph/builder/stages/detect-changes.js +198 -177
  45. package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
  46. package/src/domain/graph/watcher.js +2 -2
  47. package/src/domain/parser.js +20 -11
  48. package/src/domain/queries.js +1 -0
  49. package/src/domain/search/search/filters.js +9 -5
  50. package/src/domain/search/search/keyword.js +12 -5
  51. package/src/domain/search/search/prepare.js +13 -5
  52. package/src/extractors/csharp.js +224 -207
  53. package/src/extractors/go.js +176 -172
  54. package/src/extractors/hcl.js +94 -78
  55. package/src/extractors/java.js +213 -207
  56. package/src/extractors/javascript.js +274 -304
  57. package/src/extractors/php.js +234 -221
  58. package/src/extractors/python.js +252 -250
  59. package/src/extractors/ruby.js +192 -185
  60. package/src/extractors/rust.js +182 -167
  61. package/src/features/ast.js +5 -3
  62. package/src/features/audit.js +4 -2
  63. package/src/features/boundaries.js +98 -83
  64. package/src/features/cfg.js +134 -143
  65. package/src/features/communities.js +68 -53
  66. package/src/features/complexity.js +143 -132
  67. package/src/features/dataflow.js +146 -149
  68. package/src/features/export.js +3 -3
  69. package/src/features/graph-enrichment.js +2 -2
  70. package/src/features/manifesto.js +9 -6
  71. package/src/features/owners.js +4 -3
  72. package/src/features/sequence.js +152 -141
  73. package/src/features/shared/find-nodes.js +31 -0
  74. package/src/features/structure.js +130 -99
  75. package/src/features/triage.js +83 -68
  76. package/src/graph/classifiers/risk.js +3 -2
  77. package/src/graph/classifiers/roles.js +6 -3
  78. package/src/index.js +1 -0
  79. package/src/mcp/server.js +65 -56
  80. package/src/mcp/tool-registry.js +13 -0
  81. package/src/mcp/tools/brief.js +8 -0
  82. package/src/mcp/tools/index.js +2 -0
  83. package/src/presentation/brief.js +51 -0
  84. package/src/presentation/queries-cli/exports.js +21 -14
  85. package/src/presentation/queries-cli/impact.js +55 -39
  86. package/src/presentation/queries-cli/inspect.js +184 -189
  87. package/src/presentation/queries-cli/overview.js +57 -58
  88. package/src/presentation/queries-cli/path.js +36 -29
  89. package/src/presentation/table.js +0 -8
  90. package/src/shared/generators.js +7 -3
  91. package/src/shared/kinds.js +1 -1
@@ -46,6 +46,61 @@ export function fileDepsData(file, customDbPath, opts = {}) {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * BFS transitive caller traversal starting from `callers` of `nodeId`.
51
+ * Returns an object keyed by depth (2..depth) → array of caller descriptors.
52
+ */
53
+ function buildTransitiveCallers(db, callers, nodeId, depth, noTests) {
54
+ const transitiveCallers = {};
55
+ if (depth <= 1) return transitiveCallers;
56
+
57
+ const visited = new Set([nodeId]);
58
+ let frontier = callers
59
+ .map((c) => {
60
+ const row = db
61
+ .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?')
62
+ .get(c.name, c.kind, c.file, c.line);
63
+ return row ? { ...c, id: row.id } : null;
64
+ })
65
+ .filter(Boolean);
66
+
67
+ for (let d = 2; d <= depth; d++) {
68
+ const nextFrontier = [];
69
+ for (const f of frontier) {
70
+ if (visited.has(f.id)) continue;
71
+ visited.add(f.id);
72
+ const upstream = db
73
+ .prepare(`
74
+ SELECT n.name, n.kind, n.file, n.line
75
+ FROM edges e JOIN nodes n ON e.source_id = n.id
76
+ WHERE e.target_id = ? AND e.kind = 'calls'
77
+ `)
78
+ .all(f.id);
79
+ for (const u of upstream) {
80
+ if (noTests && isTestFile(u.file)) continue;
81
+ const uid = db
82
+ .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?')
83
+ .get(u.name, u.kind, u.file, u.line)?.id;
84
+ if (uid && !visited.has(uid)) {
85
+ nextFrontier.push({ ...u, id: uid });
86
+ }
87
+ }
88
+ }
89
+ if (nextFrontier.length > 0) {
90
+ transitiveCallers[d] = nextFrontier.map((n) => ({
91
+ name: n.name,
92
+ kind: n.kind,
93
+ file: n.file,
94
+ line: n.line,
95
+ }));
96
+ }
97
+ frontier = nextFrontier;
98
+ if (frontier.length === 0) break;
99
+ }
100
+
101
+ return transitiveCallers;
102
+ }
103
+
49
104
  export function fnDepsData(name, customDbPath, opts = {}) {
50
105
  const db = openReadonlyOrFail(customDbPath);
51
106
  try {
@@ -75,55 +130,7 @@ export function fnDepsData(name, customDbPath, opts = {}) {
75
130
  }
76
131
  if (noTests) callers = callers.filter((c) => !isTestFile(c.file));
77
132
 
78
- // Transitive callers
79
- const transitiveCallers = {};
80
- if (depth > 1) {
81
- const visited = new Set([node.id]);
82
- let frontier = callers
83
- .map((c) => {
84
- const row = db
85
- .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?')
86
- .get(c.name, c.kind, c.file, c.line);
87
- return row ? { ...c, id: row.id } : null;
88
- })
89
- .filter(Boolean);
90
-
91
- for (let d = 2; d <= depth; d++) {
92
- const nextFrontier = [];
93
- for (const f of frontier) {
94
- if (visited.has(f.id)) continue;
95
- visited.add(f.id);
96
- const upstream = db
97
- .prepare(`
98
- SELECT n.name, n.kind, n.file, n.line
99
- FROM edges e JOIN nodes n ON e.source_id = n.id
100
- WHERE e.target_id = ? AND e.kind = 'calls'
101
- `)
102
- .all(f.id);
103
- for (const u of upstream) {
104
- if (noTests && isTestFile(u.file)) continue;
105
- const uid = db
106
- .prepare(
107
- 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
108
- )
109
- .get(u.name, u.kind, u.file, u.line)?.id;
110
- if (uid && !visited.has(uid)) {
111
- nextFrontier.push({ ...u, id: uid });
112
- }
113
- }
114
- }
115
- if (nextFrontier.length > 0) {
116
- transitiveCallers[d] = nextFrontier.map((n) => ({
117
- name: n.name,
118
- kind: n.kind,
119
- file: n.file,
120
- line: n.line,
121
- }));
122
- }
123
- frontier = nextFrontier;
124
- if (frontier.length === 0) break;
125
- }
126
- }
133
+ const transitiveCallers = buildTransitiveCallers(db, callers, node.id, depth, noTests);
127
134
 
128
135
  return {
129
136
  ...normalizeSymbol(node, db, hc),
@@ -151,37 +158,40 @@ export function fnDepsData(name, customDbPath, opts = {}) {
151
158
  }
152
159
  }
153
160
 
154
- export function pathData(from, to, customDbPath, opts = {}) {
155
- const db = openReadonlyOrFail(customDbPath);
156
- try {
157
- const noTests = opts.noTests || false;
158
- const maxDepth = opts.maxDepth || 10;
159
- const edgeKinds = opts.edgeKinds || ['calls'];
160
- const reverse = opts.reverse || false;
161
+ /**
162
+ * Resolve from/to symbol names to node records.
163
+ * Returns { sourceNode, targetNode, fromCandidates, toCandidates } on success,
164
+ * or { earlyResult } when a caller-facing error/not-found response should be returned immediately.
165
+ */
166
+ function resolveEndpoints(db, from, to, opts) {
167
+ const { noTests = false } = opts;
161
168
 
162
- const fromNodes = findMatchingNodes(db, from, {
163
- noTests,
164
- file: opts.fromFile,
165
- kind: opts.kind,
166
- });
167
- if (fromNodes.length === 0) {
168
- return {
169
+ const fromNodes = findMatchingNodes(db, from, {
170
+ noTests,
171
+ file: opts.fromFile,
172
+ kind: opts.kind,
173
+ });
174
+ if (fromNodes.length === 0) {
175
+ return {
176
+ earlyResult: {
169
177
  from,
170
178
  to,
171
179
  found: false,
172
180
  error: `No symbol matching "${from}"`,
173
181
  fromCandidates: [],
174
182
  toCandidates: [],
175
- };
176
- }
183
+ },
184
+ };
185
+ }
177
186
 
178
- const toNodes = findMatchingNodes(db, to, {
179
- noTests,
180
- file: opts.toFile,
181
- kind: opts.kind,
182
- });
183
- if (toNodes.length === 0) {
184
- return {
187
+ const toNodes = findMatchingNodes(db, to, {
188
+ noTests,
189
+ file: opts.toFile,
190
+ kind: opts.kind,
191
+ });
192
+ if (toNodes.length === 0) {
193
+ return {
194
+ earlyResult: {
185
195
  from,
186
196
  to,
187
197
  found: false,
@@ -190,18 +200,118 @@ export function pathData(from, to, customDbPath, opts = {}) {
190
200
  .slice(0, 5)
191
201
  .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })),
192
202
  toCandidates: [],
193
- };
203
+ },
204
+ };
205
+ }
206
+
207
+ const fromCandidates = fromNodes
208
+ .slice(0, 5)
209
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
210
+ const toCandidates = toNodes
211
+ .slice(0, 5)
212
+ .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
213
+
214
+ return {
215
+ sourceNode: fromNodes[0],
216
+ targetNode: toNodes[0],
217
+ fromCandidates,
218
+ toCandidates,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * BFS from sourceId toward targetId.
224
+ * Returns { found, parent, alternateCount, foundDepth }.
225
+ * `parent` maps nodeId → { parentId, edgeKind }.
226
+ */
227
+ function bfsShortestPath(db, sourceId, targetId, edgeKinds, reverse, maxDepth, noTests) {
228
+ const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
229
+
230
+ // Forward: source_id → target_id (A calls... calls B)
231
+ // Reverse: target_id → source_id (B is called by... called by A)
232
+ const neighborQuery = reverse
233
+ ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
234
+ FROM edges e JOIN nodes n ON e.source_id = n.id
235
+ WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
236
+ : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
237
+ FROM edges e JOIN nodes n ON e.target_id = n.id
238
+ WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
239
+ const neighborStmt = db.prepare(neighborQuery);
240
+
241
+ const visited = new Set([sourceId]);
242
+ const parent = new Map();
243
+ let queue = [sourceId];
244
+ let found = false;
245
+ let alternateCount = 0;
246
+ let foundDepth = -1;
247
+
248
+ for (let depth = 1; depth <= maxDepth; depth++) {
249
+ const nextQueue = [];
250
+ for (const currentId of queue) {
251
+ const neighbors = neighborStmt.all(currentId, ...edgeKinds);
252
+ for (const n of neighbors) {
253
+ if (noTests && isTestFile(n.file)) continue;
254
+ if (n.id === targetId) {
255
+ if (!found) {
256
+ found = true;
257
+ foundDepth = depth;
258
+ parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
259
+ }
260
+ alternateCount++;
261
+ continue;
262
+ }
263
+ if (!visited.has(n.id)) {
264
+ visited.add(n.id);
265
+ parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
266
+ nextQueue.push(n.id);
267
+ }
268
+ }
194
269
  }
270
+ if (found) break;
271
+ queue = nextQueue;
272
+ if (queue.length === 0) break;
273
+ }
274
+
275
+ return { found, parent, alternateCount, foundDepth };
276
+ }
277
+
278
+ /**
279
+ * Walk the parent map from targetId back to sourceId and return an ordered
280
+ * array of node IDs source → target.
281
+ */
282
+ function reconstructPath(db, pathIds, parent) {
283
+ const nodeCache = new Map();
284
+ const getNode = (id) => {
285
+ if (nodeCache.has(id)) return nodeCache.get(id);
286
+ const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
287
+ nodeCache.set(id, row);
288
+ return row;
289
+ };
290
+
291
+ return pathIds.map((id, idx) => {
292
+ const node = getNode(id);
293
+ const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
294
+ return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
295
+ });
296
+ }
297
+
298
+ export function pathData(from, to, customDbPath, opts = {}) {
299
+ const db = openReadonlyOrFail(customDbPath);
300
+ try {
301
+ const noTests = opts.noTests || false;
302
+ const maxDepth = opts.maxDepth || 10;
303
+ const edgeKinds = opts.edgeKinds || ['calls'];
304
+ const reverse = opts.reverse || false;
195
305
 
196
- const sourceNode = fromNodes[0];
197
- const targetNode = toNodes[0];
306
+ const resolved = resolveEndpoints(db, from, to, {
307
+ noTests,
308
+ fromFile: opts.fromFile,
309
+ toFile: opts.toFile,
310
+ kind: opts.kind,
311
+ });
312
+ if (resolved.earlyResult) return resolved.earlyResult;
198
313
 
199
- const fromCandidates = fromNodes
200
- .slice(0, 5)
201
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
202
- const toCandidates = toNodes
203
- .slice(0, 5)
204
- .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
314
+ const { sourceNode, targetNode, fromCandidates, toCandidates } = resolved;
205
315
 
206
316
  // Self-path
207
317
  if (sourceNode.id === targetNode.id) {
@@ -228,55 +338,12 @@ export function pathData(from, to, customDbPath, opts = {}) {
228
338
  };
229
339
  }
230
340
 
231
- // Build edge kind filter
232
- const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
233
-
234
- // BFS — direction depends on `reverse` flag
235
- // Forward: source_id → target_id (A calls... calls B)
236
- // Reverse: target_id → source_id (B is called by... called by A)
237
- const neighborQuery = reverse
238
- ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
239
- FROM edges e JOIN nodes n ON e.source_id = n.id
240
- WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
241
- : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
242
- FROM edges e JOIN nodes n ON e.target_id = n.id
243
- WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
244
- const neighborStmt = db.prepare(neighborQuery);
245
-
246
- const visited = new Set([sourceNode.id]);
247
- // parent map: nodeId → { parentId, edgeKind }
248
- const parent = new Map();
249
- let queue = [sourceNode.id];
250
- let found = false;
251
- let alternateCount = 0;
252
- let foundDepth = -1;
253
-
254
- for (let depth = 1; depth <= maxDepth; depth++) {
255
- const nextQueue = [];
256
- for (const currentId of queue) {
257
- const neighbors = neighborStmt.all(currentId, ...edgeKinds);
258
- for (const n of neighbors) {
259
- if (noTests && isTestFile(n.file)) continue;
260
- if (n.id === targetNode.id) {
261
- if (!found) {
262
- found = true;
263
- foundDepth = depth;
264
- parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
265
- }
266
- alternateCount++;
267
- continue;
268
- }
269
- if (!visited.has(n.id)) {
270
- visited.add(n.id);
271
- parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
272
- nextQueue.push(n.id);
273
- }
274
- }
275
- }
276
- if (found) break;
277
- queue = nextQueue;
278
- if (queue.length === 0) break;
279
- }
341
+ const {
342
+ found,
343
+ parent,
344
+ alternateCount: rawAlternateCount,
345
+ foundDepth,
346
+ } = bfsShortestPath(db, sourceNode.id, targetNode.id, edgeKinds, reverse, maxDepth, noTests);
280
347
 
281
348
  if (!found) {
282
349
  return {
@@ -294,8 +361,8 @@ export function pathData(from, to, customDbPath, opts = {}) {
294
361
  };
295
362
  }
296
363
 
297
- // alternateCount includes the one we kept; subtract 1 for "alternates"
298
- alternateCount = Math.max(0, alternateCount - 1);
364
+ // rawAlternateCount includes the one we kept; subtract 1 for "alternates"
365
+ const alternateCount = Math.max(0, rawAlternateCount - 1);
299
366
 
300
367
  // Reconstruct path from target back to source
301
368
  const pathIds = [targetNode.id];
@@ -307,20 +374,7 @@ export function pathData(from, to, customDbPath, opts = {}) {
307
374
  }
308
375
  pathIds.reverse();
309
376
 
310
- // Build path with node info
311
- const nodeCache = new Map();
312
- const getNode = (id) => {
313
- if (nodeCache.has(id)) return nodeCache.get(id);
314
- const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
315
- nodeCache.set(id, row);
316
- return row;
317
- };
318
-
319
- const resultPath = pathIds.map((id, idx) => {
320
- const node = getNode(id);
321
- const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
322
- return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
323
- });
377
+ const resultPath = reconstructPath(db, pathIds, parent);
324
378
 
325
379
  return {
326
380
  from,
@@ -6,6 +6,7 @@ import {
6
6
  findNodesByFile,
7
7
  openReadonlyOrFail,
8
8
  } from '../../db/index.js';
9
+ import { debug } from '../../infrastructure/logger.js';
9
10
  import { isTestFile } from '../../infrastructure/test-filter.js';
10
11
  import {
11
12
  createFileLinesReader,
@@ -60,8 +61,8 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) {
60
61
  try {
61
62
  db.prepare('SELECT exported FROM nodes LIMIT 0').raw();
62
63
  hasExportedCol = true;
63
- } catch {
64
- /* old DB without exported column */
64
+ } catch (e) {
65
+ debug(`exported column not available, using fallback: ${e.message}`);
65
66
  }
66
67
 
67
68
  return fileNodes.map((fn) => {