@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
@@ -68,6 +68,148 @@ function buildAliases(files) {
68
68
  return aliases;
69
69
  }
70
70
 
71
+ // ─── Helpers ─────────────────────────────────────────────────────────
72
+
73
+ function findEntryNode(repo, name, opts) {
74
+ let matchNode = findMatchingNodes(repo, name, opts)[0] ?? null;
75
+ if (!matchNode) {
76
+ for (const prefix of FRAMEWORK_ENTRY_PREFIXES) {
77
+ matchNode = findMatchingNodes(repo, `${prefix}${name}`, opts)[0] ?? null;
78
+ if (matchNode) break;
79
+ }
80
+ }
81
+ return matchNode;
82
+ }
83
+
84
+ function bfsCallees(repo, matchNode, maxDepth, noTests) {
85
+ const visited = new Set([matchNode.id]);
86
+ let frontier = [matchNode.id];
87
+ const messages = [];
88
+ const fileSet = new Set([matchNode.file]);
89
+ const idToNode = new Map();
90
+ idToNode.set(matchNode.id, matchNode);
91
+ let truncated = false;
92
+
93
+ for (let d = 1; d <= maxDepth; d++) {
94
+ const nextFrontier = [];
95
+
96
+ for (const fid of frontier) {
97
+ const callees = repo.findCallees(fid);
98
+ const caller = idToNode.get(fid);
99
+
100
+ for (const c of callees) {
101
+ if (noTests && isTestFile(c.file)) continue;
102
+
103
+ fileSet.add(c.file);
104
+ messages.push({
105
+ from: caller.file,
106
+ to: c.file,
107
+ label: c.name,
108
+ type: 'call',
109
+ depth: d,
110
+ });
111
+
112
+ if (visited.has(c.id)) continue;
113
+
114
+ visited.add(c.id);
115
+ nextFrontier.push(c.id);
116
+ idToNode.set(c.id, c);
117
+ }
118
+ }
119
+
120
+ frontier = nextFrontier;
121
+ if (frontier.length === 0) break;
122
+
123
+ if (d === maxDepth && frontier.length > 0) {
124
+ const hasMoreCalls = frontier.some((fid) => repo.findCallees(fid).length > 0);
125
+ if (hasMoreCalls) truncated = true;
126
+ }
127
+ }
128
+
129
+ return { messages, fileSet, idToNode, truncated };
130
+ }
131
+
132
+ function annotateDataflow(repo, messages, idToNode) {
133
+ const hasTable = repo.hasDataflowTable();
134
+
135
+ if (!hasTable || !(repo instanceof SqliteRepository)) return;
136
+
137
+ const db = repo.db;
138
+ const nodeByNameFile = new Map();
139
+ for (const n of idToNode.values()) {
140
+ nodeByNameFile.set(`${n.name}|${n.file}`, n);
141
+ }
142
+
143
+ const getReturns = db.prepare(
144
+ `SELECT d.expression FROM dataflow d
145
+ WHERE d.source_id = ? AND d.kind = 'returns'`,
146
+ );
147
+ const getFlowsTo = db.prepare(
148
+ `SELECT d.expression FROM dataflow d
149
+ WHERE d.target_id = ? AND d.kind = 'flows_to'
150
+ ORDER BY d.param_index`,
151
+ );
152
+
153
+ const seenReturns = new Set();
154
+ for (const msg of [...messages]) {
155
+ if (msg.type !== 'call') continue;
156
+ const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`);
157
+ if (!targetNode) continue;
158
+
159
+ const returnKey = `${msg.to}->${msg.from}:${msg.label}`;
160
+ if (seenReturns.has(returnKey)) continue;
161
+
162
+ const returns = getReturns.all(targetNode.id);
163
+
164
+ if (returns.length > 0) {
165
+ seenReturns.add(returnKey);
166
+ const expr = returns[0].expression || 'result';
167
+ messages.push({
168
+ from: msg.to,
169
+ to: msg.from,
170
+ label: expr,
171
+ type: 'return',
172
+ depth: msg.depth,
173
+ });
174
+ }
175
+ }
176
+
177
+ for (const msg of messages) {
178
+ if (msg.type !== 'call') continue;
179
+ const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`);
180
+ if (!targetNode) continue;
181
+
182
+ const params = getFlowsTo.all(targetNode.id);
183
+
184
+ if (params.length > 0) {
185
+ const paramNames = params
186
+ .map((p) => p.expression)
187
+ .filter(Boolean)
188
+ .slice(0, 3);
189
+ if (paramNames.length > 0) {
190
+ msg.label = `${msg.label}(${paramNames.join(', ')})`;
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ function buildParticipants(fileSet, entryFile) {
197
+ const aliases = buildAliases([...fileSet]);
198
+ const participants = [...fileSet].map((file) => ({
199
+ id: aliases.get(file),
200
+ label: file.split('/').pop(),
201
+ file,
202
+ }));
203
+
204
+ participants.sort((a, b) => {
205
+ if (a.file === entryFile) return -1;
206
+ if (b.file === entryFile) return 1;
207
+ return a.file.localeCompare(b.file);
208
+ });
209
+
210
+ return { participants, aliases };
211
+ }
212
+
71
213
  // ─── Core data function ──────────────────────────────────────────────
72
214
 
73
215
  /**
@@ -90,19 +232,8 @@ export function sequenceData(name, dbPath, opts = {}) {
90
232
  try {
91
233
  const maxDepth = opts.depth || 10;
92
234
  const noTests = opts.noTests || false;
93
- const withDataflow = opts.dataflow || false;
94
-
95
- // Phase 1: Direct LIKE match
96
- let matchNode = findMatchingNodes(repo, name, opts)[0] ?? null;
97
-
98
- // Phase 2: Prefix-stripped matching
99
- if (!matchNode) {
100
- for (const prefix of FRAMEWORK_ENTRY_PREFIXES) {
101
- matchNode = findMatchingNodes(repo, `${prefix}${name}`, opts)[0] ?? null;
102
- if (matchNode) break;
103
- }
104
- }
105
235
 
236
+ const matchNode = findEntryNode(repo, name, opts);
106
237
  if (!matchNode) {
107
238
  return {
108
239
  entry: null,
@@ -121,123 +252,17 @@ export function sequenceData(name, dbPath, opts = {}) {
121
252
  line: matchNode.line,
122
253
  };
123
254
 
124
- // BFS forward track edges, not just nodes
125
- const visited = new Set([matchNode.id]);
126
- let frontier = [matchNode.id];
127
- const messages = [];
128
- const fileSet = new Set([matchNode.file]);
129
- const idToNode = new Map();
130
- idToNode.set(matchNode.id, matchNode);
131
- let truncated = false;
132
-
133
- for (let d = 1; d <= maxDepth; d++) {
134
- const nextFrontier = [];
135
-
136
- for (const fid of frontier) {
137
- const callees = repo.findCallees(fid);
138
-
139
- const caller = idToNode.get(fid);
140
-
141
- for (const c of callees) {
142
- if (noTests && isTestFile(c.file)) continue;
143
-
144
- // Always record the message (even for visited nodes — different caller path)
145
- fileSet.add(c.file);
146
- messages.push({
147
- from: caller.file,
148
- to: c.file,
149
- label: c.name,
150
- type: 'call',
151
- depth: d,
152
- });
153
-
154
- if (visited.has(c.id)) continue;
155
-
156
- visited.add(c.id);
157
- nextFrontier.push(c.id);
158
- idToNode.set(c.id, c);
159
- }
160
- }
161
-
162
- frontier = nextFrontier;
163
- if (frontier.length === 0) break;
164
-
165
- if (d === maxDepth && frontier.length > 0) {
166
- // Only mark truncated if at least one frontier node has further callees
167
- const hasMoreCalls = frontier.some((fid) => repo.findCallees(fid).length > 0);
168
- if (hasMoreCalls) truncated = true;
169
- }
170
- }
171
-
172
- // Dataflow annotations: add return arrows
173
- if (withDataflow && messages.length > 0) {
174
- const hasTable = repo.hasDataflowTable();
175
-
176
- if (hasTable && repo instanceof SqliteRepository) {
177
- const db = repo.db;
178
- // Build name|file lookup for O(1) target node access
179
- const nodeByNameFile = new Map();
180
- for (const n of idToNode.values()) {
181
- nodeByNameFile.set(`${n.name}|${n.file}`, n);
182
- }
183
-
184
- const getReturns = db.prepare(
185
- `SELECT d.expression FROM dataflow d
186
- WHERE d.source_id = ? AND d.kind = 'returns'`,
187
- );
188
- const getFlowsTo = db.prepare(
189
- `SELECT d.expression FROM dataflow d
190
- WHERE d.target_id = ? AND d.kind = 'flows_to'
191
- ORDER BY d.param_index`,
192
- );
193
-
194
- // For each called function, check if it has return edges
195
- const seenReturns = new Set();
196
- for (const msg of [...messages]) {
197
- if (msg.type !== 'call') continue;
198
- const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`);
199
- if (!targetNode) continue;
200
-
201
- const returnKey = `${msg.to}->${msg.from}:${msg.label}`;
202
- if (seenReturns.has(returnKey)) continue;
203
-
204
- const returns = getReturns.all(targetNode.id);
205
-
206
- if (returns.length > 0) {
207
- seenReturns.add(returnKey);
208
- const expr = returns[0].expression || 'result';
209
- messages.push({
210
- from: msg.to,
211
- to: msg.from,
212
- label: expr,
213
- type: 'return',
214
- depth: msg.depth,
215
- });
216
- }
217
- }
255
+ const { messages, fileSet, idToNode, truncated } = bfsCallees(
256
+ repo,
257
+ matchNode,
258
+ maxDepth,
259
+ noTests,
260
+ );
218
261
 
219
- // Annotate call messages with parameter names
220
- for (const msg of messages) {
221
- if (msg.type !== 'call') continue;
222
- const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`);
223
- if (!targetNode) continue;
224
-
225
- const params = getFlowsTo.all(targetNode.id);
226
-
227
- if (params.length > 0) {
228
- const paramNames = params
229
- .map((p) => p.expression)
230
- .filter(Boolean)
231
- .slice(0, 3);
232
- if (paramNames.length > 0) {
233
- msg.label = `${msg.label}(${paramNames.join(', ')})`;
234
- }
235
- }
236
- }
237
- }
262
+ if (opts.dataflow && messages.length > 0) {
263
+ annotateDataflow(repo, messages, idToNode);
238
264
  }
239
265
 
240
- // Sort messages by depth, then call before return
241
266
  messages.sort((a, b) => {
242
267
  if (a.depth !== b.depth) return a.depth - b.depth;
243
268
  if (a.type === 'call' && b.type === 'return') return -1;
@@ -245,22 +270,8 @@ export function sequenceData(name, dbPath, opts = {}) {
245
270
  return 0;
246
271
  });
247
272
 
248
- // Build participant list from files
249
- const aliases = buildAliases([...fileSet]);
250
- const participants = [...fileSet].map((file) => ({
251
- id: aliases.get(file),
252
- label: file.split('/').pop(),
253
- file,
254
- }));
255
-
256
- // Sort participants: entry file first, then alphabetically
257
- participants.sort((a, b) => {
258
- if (a.file === entry.file) return -1;
259
- if (b.file === entry.file) return 1;
260
- return a.file.localeCompare(b.file);
261
- });
273
+ const { participants, aliases } = buildParticipants(fileSet, entry.file);
262
274
 
263
- // Replace file paths with alias IDs in messages
264
275
  for (const msg of messages) {
265
276
  msg.from = aliases.get(msg.from);
266
277
  msg.to = aliases.get(msg.to);
@@ -0,0 +1,31 @@
1
+ import { buildFileConditionSQL } from '../../db/query-builder.js';
2
+ import { isTestFile } from '../../infrastructure/test-filter.js';
3
+
4
+ /**
5
+ * Look up node(s) by name with optional file/kind/noTests filtering.
6
+ *
7
+ * @param {object} db - open SQLite database handle
8
+ * @param {string} name - symbol name (partial LIKE match)
9
+ * @param {object} [opts] - { kind, file, noTests }
10
+ * @param {string[]} defaultKinds - fallback kinds when opts.kind is not set
11
+ * @returns {object[]} matching node rows
12
+ */
13
+ export function findNodes(db, name, opts = {}, defaultKinds = []) {
14
+ const kinds = opts.kind ? [opts.kind] : defaultKinds;
15
+ if (kinds.length === 0) throw new Error('findNodes: no kinds specified');
16
+ const placeholders = kinds.map(() => '?').join(', ');
17
+ const params = [`%${name}%`, ...kinds];
18
+
19
+ const fc = buildFileConditionSQL(opts.file, 'file');
20
+ params.push(...fc.params);
21
+
22
+ const rows = db
23
+ .prepare(
24
+ `SELECT * FROM nodes
25
+ WHERE name LIKE ? AND kind IN (${placeholders})${fc.sql}
26
+ ORDER BY file, line`,
27
+ )
28
+ .all(...params);
29
+
30
+ return opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;
31
+ }