@optave/codegraph 3.0.4 → 3.1.1

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 (49) hide show
  1. package/README.md +59 -52
  2. package/grammars/tree-sitter-go.wasm +0 -0
  3. package/package.json +9 -10
  4. package/src/ast-analysis/rules/csharp.js +201 -0
  5. package/src/ast-analysis/rules/go.js +182 -0
  6. package/src/ast-analysis/rules/index.js +82 -0
  7. package/src/ast-analysis/rules/java.js +175 -0
  8. package/src/ast-analysis/rules/javascript.js +246 -0
  9. package/src/ast-analysis/rules/php.js +219 -0
  10. package/src/ast-analysis/rules/python.js +196 -0
  11. package/src/ast-analysis/rules/ruby.js +204 -0
  12. package/src/ast-analysis/rules/rust.js +173 -0
  13. package/src/ast-analysis/shared.js +223 -0
  14. package/src/ast.js +15 -28
  15. package/src/audit.js +4 -5
  16. package/src/boundaries.js +1 -1
  17. package/src/branch-compare.js +84 -79
  18. package/src/builder.js +274 -159
  19. package/src/cfg.js +111 -341
  20. package/src/check.js +3 -3
  21. package/src/cli.js +122 -167
  22. package/src/cochange.js +1 -1
  23. package/src/communities.js +13 -16
  24. package/src/complexity.js +196 -1239
  25. package/src/cycles.js +1 -1
  26. package/src/dataflow.js +274 -697
  27. package/src/db/connection.js +88 -0
  28. package/src/db/migrations.js +312 -0
  29. package/src/db/query-builder.js +280 -0
  30. package/src/db/repository.js +134 -0
  31. package/src/db.js +19 -392
  32. package/src/embedder.js +145 -141
  33. package/src/export.js +1 -1
  34. package/src/flow.js +160 -228
  35. package/src/index.js +36 -2
  36. package/src/kinds.js +49 -0
  37. package/src/manifesto.js +3 -8
  38. package/src/mcp.js +97 -20
  39. package/src/owners.js +132 -132
  40. package/src/parser.js +58 -131
  41. package/src/queries-cli.js +866 -0
  42. package/src/queries.js +1356 -2261
  43. package/src/resolve.js +11 -2
  44. package/src/result-formatter.js +21 -0
  45. package/src/sequence.js +364 -0
  46. package/src/structure.js +200 -199
  47. package/src/test-filter.js +7 -0
  48. package/src/triage.js +120 -162
  49. package/src/viewer.js +1 -1
package/src/resolve.js CHANGED
@@ -146,8 +146,12 @@ export function computeConfidence(callerFile, targetFile, importedFrom) {
146
146
  /**
147
147
  * Batch resolve multiple imports in a single native call.
148
148
  * Returns Map<"fromFile|importSource", resolvedPath> or null when native unavailable.
149
+ * @param {Array} inputs - Array of { fromFile, importSource }
150
+ * @param {string} rootDir - Project root
151
+ * @param {object} aliases - Path aliases
152
+ * @param {string[]} [knownFiles] - Optional file paths for FS cache (avoids syscalls)
149
153
  */
150
- export function resolveImportsBatch(inputs, rootDir, aliases) {
154
+ export function resolveImportsBatch(inputs, rootDir, aliases, knownFiles) {
151
155
  const native = loadNative();
152
156
  if (!native) return null;
153
157
 
@@ -156,7 +160,12 @@ export function resolveImportsBatch(inputs, rootDir, aliases) {
156
160
  fromFile,
157
161
  importSource,
158
162
  }));
159
- const results = native.resolveImports(nativeInputs, rootDir, convertAliasesForNative(aliases));
163
+ const results = native.resolveImports(
164
+ nativeInputs,
165
+ rootDir,
166
+ convertAliasesForNative(aliases),
167
+ knownFiles || null,
168
+ );
160
169
  const map = new Map();
161
170
  for (const r of results) {
162
171
  map.set(`${r.fromFile}|${r.importSource}`, normalizePath(path.normalize(r.resolvedPath)));
@@ -0,0 +1,21 @@
1
+ import { printNdjson } from './paginate.js';
2
+
3
+ /**
4
+ * Shared JSON / NDJSON output dispatch for CLI wrappers.
5
+ *
6
+ * @param {object} data - Result object from a *Data() function
7
+ * @param {string} field - Array field name for NDJSON streaming (e.g. 'results')
8
+ * @param {object} opts - CLI options ({ json?, ndjson? })
9
+ * @returns {boolean} true if output was handled (caller should return early)
10
+ */
11
+ export function outputResult(data, field, opts) {
12
+ if (opts.ndjson) {
13
+ printNdjson(data, field);
14
+ return true;
15
+ }
16
+ if (opts.json) {
17
+ console.log(JSON.stringify(data, null, 2));
18
+ return true;
19
+ }
20
+ return false;
21
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Sequence diagram generation – Mermaid sequenceDiagram from call graph edges.
3
+ *
4
+ * Participants are files (not individual functions). Calls within the same file
5
+ * become self-messages. This keeps diagrams readable and matches typical
6
+ * sequence-diagram conventions.
7
+ */
8
+
9
+ import { openReadonlyOrFail } from './db.js';
10
+ import { paginateResult } from './paginate.js';
11
+ import { findMatchingNodes, kindIcon } from './queries.js';
12
+ import { outputResult } from './result-formatter.js';
13
+ import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js';
14
+ import { isTestFile } from './test-filter.js';
15
+
16
+ // ─── Alias generation ────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Build short participant aliases from file paths with collision handling.
20
+ * e.g. "src/builder.js" → "builder", but if two files share basename,
21
+ * progressively add parent dirs: "src/builder" vs "lib/builder".
22
+ */
23
+ function buildAliases(files) {
24
+ const aliases = new Map();
25
+ const basenames = new Map();
26
+
27
+ // Group by basename
28
+ for (const file of files) {
29
+ const base = file
30
+ .split('/')
31
+ .pop()
32
+ .replace(/\.[^.]+$/, '');
33
+ if (!basenames.has(base)) basenames.set(base, []);
34
+ basenames.get(base).push(file);
35
+ }
36
+
37
+ for (const [base, paths] of basenames) {
38
+ if (paths.length === 1) {
39
+ aliases.set(paths[0], base);
40
+ } else {
41
+ // Collision — progressively add parent dirs until aliases are unique
42
+ for (let depth = 2; depth <= 10; depth++) {
43
+ const trial = new Map();
44
+ let allUnique = true;
45
+ const seen = new Set();
46
+
47
+ for (const p of paths) {
48
+ const parts = p.replace(/\.[^.]+$/, '').split('/');
49
+ const alias = parts
50
+ .slice(-depth)
51
+ .join('_')
52
+ .replace(/[^a-zA-Z0-9_-]/g, '_');
53
+ trial.set(p, alias);
54
+ if (seen.has(alias)) allUnique = false;
55
+ seen.add(alias);
56
+ }
57
+
58
+ if (allUnique || depth === 10) {
59
+ for (const [p, alias] of trial) {
60
+ aliases.set(p, alias);
61
+ }
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ return aliases;
69
+ }
70
+
71
+ // ─── Core data function ──────────────────────────────────────────────
72
+
73
+ /**
74
+ * Build sequence diagram data by BFS-forward from an entry point.
75
+ *
76
+ * @param {string} name - Symbol name to trace from
77
+ * @param {string} [dbPath]
78
+ * @param {object} [opts]
79
+ * @param {number} [opts.depth=10]
80
+ * @param {boolean} [opts.noTests]
81
+ * @param {string} [opts.file]
82
+ * @param {string} [opts.kind]
83
+ * @param {boolean} [opts.dataflow]
84
+ * @param {number} [opts.limit]
85
+ * @param {number} [opts.offset]
86
+ * @returns {{ entry, participants, messages, depth, totalMessages, truncated }}
87
+ */
88
+ export function sequenceData(name, dbPath, opts = {}) {
89
+ const db = openReadonlyOrFail(dbPath);
90
+ try {
91
+ const maxDepth = opts.depth || 10;
92
+ const noTests = opts.noTests || false;
93
+ const withDataflow = opts.dataflow || false;
94
+
95
+ // Phase 1: Direct LIKE match
96
+ let matchNode = findMatchingNodes(db, 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(db, `${prefix}${name}`, opts)[0] ?? null;
102
+ if (matchNode) break;
103
+ }
104
+ }
105
+
106
+ if (!matchNode) {
107
+ return {
108
+ entry: null,
109
+ participants: [],
110
+ messages: [],
111
+ depth: maxDepth,
112
+ totalMessages: 0,
113
+ truncated: false,
114
+ };
115
+ }
116
+
117
+ const entry = {
118
+ name: matchNode.name,
119
+ file: matchNode.file,
120
+ kind: matchNode.kind,
121
+ line: matchNode.line,
122
+ };
123
+
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
+ const getCallees = db.prepare(
134
+ `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line
135
+ FROM edges e JOIN nodes n ON e.target_id = n.id
136
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
137
+ );
138
+
139
+ for (let d = 1; d <= maxDepth; d++) {
140
+ const nextFrontier = [];
141
+
142
+ for (const fid of frontier) {
143
+ const callees = getCallees.all(fid);
144
+
145
+ const caller = idToNode.get(fid);
146
+
147
+ for (const c of callees) {
148
+ if (noTests && isTestFile(c.file)) continue;
149
+
150
+ // Always record the message (even for visited nodes — different caller path)
151
+ fileSet.add(c.file);
152
+ messages.push({
153
+ from: caller.file,
154
+ to: c.file,
155
+ label: c.name,
156
+ type: 'call',
157
+ depth: d,
158
+ });
159
+
160
+ if (visited.has(c.id)) continue;
161
+
162
+ visited.add(c.id);
163
+ nextFrontier.push(c.id);
164
+ idToNode.set(c.id, c);
165
+ }
166
+ }
167
+
168
+ frontier = nextFrontier;
169
+ if (frontier.length === 0) break;
170
+
171
+ if (d === maxDepth && frontier.length > 0) {
172
+ // Only mark truncated if at least one frontier node has further callees
173
+ const hasMoreCalls = frontier.some((fid) => getCallees.all(fid).length > 0);
174
+ if (hasMoreCalls) truncated = true;
175
+ }
176
+ }
177
+
178
+ // Dataflow annotations: add return arrows
179
+ if (withDataflow && messages.length > 0) {
180
+ const hasTable = db
181
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'")
182
+ .get();
183
+
184
+ if (hasTable) {
185
+ // Build name|file lookup for O(1) target node access
186
+ const nodeByNameFile = new Map();
187
+ for (const n of idToNode.values()) {
188
+ nodeByNameFile.set(`${n.name}|${n.file}`, n);
189
+ }
190
+
191
+ const getReturns = db.prepare(
192
+ `SELECT d.expression FROM dataflow d
193
+ WHERE d.source_id = ? AND d.kind = 'returns'`,
194
+ );
195
+ const getFlowsTo = db.prepare(
196
+ `SELECT d.expression FROM dataflow d
197
+ WHERE d.target_id = ? AND d.kind = 'flows_to'
198
+ ORDER BY d.param_index`,
199
+ );
200
+
201
+ // For each called function, check if it has return edges
202
+ const seenReturns = new Set();
203
+ for (const msg of [...messages]) {
204
+ if (msg.type !== 'call') continue;
205
+ const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`);
206
+ if (!targetNode) continue;
207
+
208
+ const returnKey = `${msg.to}->${msg.from}:${msg.label}`;
209
+ if (seenReturns.has(returnKey)) continue;
210
+
211
+ const returns = getReturns.all(targetNode.id);
212
+
213
+ if (returns.length > 0) {
214
+ seenReturns.add(returnKey);
215
+ const expr = returns[0].expression || 'result';
216
+ messages.push({
217
+ from: msg.to,
218
+ to: msg.from,
219
+ label: expr,
220
+ type: 'return',
221
+ depth: msg.depth,
222
+ });
223
+ }
224
+ }
225
+
226
+ // Annotate call messages with parameter names
227
+ for (const msg of messages) {
228
+ if (msg.type !== 'call') continue;
229
+ const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`);
230
+ if (!targetNode) continue;
231
+
232
+ const params = getFlowsTo.all(targetNode.id);
233
+
234
+ if (params.length > 0) {
235
+ const paramNames = params
236
+ .map((p) => p.expression)
237
+ .filter(Boolean)
238
+ .slice(0, 3);
239
+ if (paramNames.length > 0) {
240
+ msg.label = `${msg.label}(${paramNames.join(', ')})`;
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // Sort messages by depth, then call before return
248
+ messages.sort((a, b) => {
249
+ if (a.depth !== b.depth) return a.depth - b.depth;
250
+ if (a.type === 'call' && b.type === 'return') return -1;
251
+ if (a.type === 'return' && b.type === 'call') return 1;
252
+ return 0;
253
+ });
254
+
255
+ // Build participant list from files
256
+ const aliases = buildAliases([...fileSet]);
257
+ const participants = [...fileSet].map((file) => ({
258
+ id: aliases.get(file),
259
+ label: file.split('/').pop(),
260
+ file,
261
+ }));
262
+
263
+ // Sort participants: entry file first, then alphabetically
264
+ participants.sort((a, b) => {
265
+ if (a.file === entry.file) return -1;
266
+ if (b.file === entry.file) return 1;
267
+ return a.file.localeCompare(b.file);
268
+ });
269
+
270
+ // Replace file paths with alias IDs in messages
271
+ for (const msg of messages) {
272
+ msg.from = aliases.get(msg.from);
273
+ msg.to = aliases.get(msg.to);
274
+ }
275
+
276
+ const base = {
277
+ entry,
278
+ participants,
279
+ messages,
280
+ depth: maxDepth,
281
+ totalMessages: messages.length,
282
+ truncated,
283
+ };
284
+ const result = paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset });
285
+ if (opts.limit !== undefined || opts.offset !== undefined) {
286
+ const activeFiles = new Set(result.messages.flatMap((m) => [m.from, m.to]));
287
+ result.participants = result.participants.filter((p) => activeFiles.has(p.id));
288
+ }
289
+ return result;
290
+ } finally {
291
+ db.close();
292
+ }
293
+ }
294
+
295
+ // ─── Mermaid formatter ───────────────────────────────────────────────
296
+
297
+ /**
298
+ * Escape special Mermaid characters in labels.
299
+ */
300
+ function escapeMermaid(str) {
301
+ return str
302
+ .replace(/</g, '&lt;')
303
+ .replace(/>/g, '&gt;')
304
+ .replace(/:/g, '#colon;')
305
+ .replace(/"/g, '#quot;');
306
+ }
307
+
308
+ /**
309
+ * Convert sequenceData result to Mermaid sequenceDiagram syntax.
310
+ * @param {{ participants, messages, truncated }} seqResult
311
+ * @returns {string}
312
+ */
313
+ export function sequenceToMermaid(seqResult) {
314
+ const lines = ['sequenceDiagram'];
315
+
316
+ for (const p of seqResult.participants) {
317
+ lines.push(` participant ${p.id} as ${escapeMermaid(p.label)}`);
318
+ }
319
+
320
+ for (const msg of seqResult.messages) {
321
+ const arrow = msg.type === 'return' ? '-->>' : '->>';
322
+ lines.push(` ${msg.from}${arrow}${msg.to}: ${escapeMermaid(msg.label)}`);
323
+ }
324
+
325
+ if (seqResult.truncated && seqResult.participants.length > 0) {
326
+ lines.push(
327
+ ` note right of ${seqResult.participants[0].id}: Truncated at depth ${seqResult.depth}`,
328
+ );
329
+ }
330
+
331
+ return lines.join('\n');
332
+ }
333
+
334
+ // ─── CLI formatter ───────────────────────────────────────────────────
335
+
336
+ /**
337
+ * CLI entry point — format sequence data as mermaid, JSON, or ndjson.
338
+ */
339
+ export function sequence(name, dbPath, opts = {}) {
340
+ const data = sequenceData(name, dbPath, opts);
341
+
342
+ if (outputResult(data, 'messages', opts)) return;
343
+
344
+ // Default: mermaid format
345
+ if (!data.entry) {
346
+ console.log(`No matching function found for "${name}".`);
347
+ return;
348
+ }
349
+
350
+ const e = data.entry;
351
+ console.log(`\nSequence from: [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`);
352
+ console.log(`Participants: ${data.participants.length} Messages: ${data.totalMessages}`);
353
+ if (data.truncated) {
354
+ console.log(` (truncated at depth ${data.depth})`);
355
+ }
356
+ console.log();
357
+
358
+ if (data.messages.length === 0) {
359
+ console.log(' (leaf node — no callees)');
360
+ return;
361
+ }
362
+
363
+ console.log(sequenceToMermaid(data));
364
+ }