@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.
- package/README.md +59 -52
- package/grammars/tree-sitter-go.wasm +0 -0
- package/package.json +9 -10
- package/src/ast-analysis/rules/csharp.js +201 -0
- package/src/ast-analysis/rules/go.js +182 -0
- package/src/ast-analysis/rules/index.js +82 -0
- package/src/ast-analysis/rules/java.js +175 -0
- package/src/ast-analysis/rules/javascript.js +246 -0
- package/src/ast-analysis/rules/php.js +219 -0
- package/src/ast-analysis/rules/python.js +196 -0
- package/src/ast-analysis/rules/ruby.js +204 -0
- package/src/ast-analysis/rules/rust.js +173 -0
- package/src/ast-analysis/shared.js +223 -0
- package/src/ast.js +15 -28
- package/src/audit.js +4 -5
- package/src/boundaries.js +1 -1
- package/src/branch-compare.js +84 -79
- package/src/builder.js +274 -159
- package/src/cfg.js +111 -341
- package/src/check.js +3 -3
- package/src/cli.js +122 -167
- package/src/cochange.js +1 -1
- package/src/communities.js +13 -16
- package/src/complexity.js +196 -1239
- package/src/cycles.js +1 -1
- package/src/dataflow.js +274 -697
- package/src/db/connection.js +88 -0
- package/src/db/migrations.js +312 -0
- package/src/db/query-builder.js +280 -0
- package/src/db/repository.js +134 -0
- package/src/db.js +19 -392
- package/src/embedder.js +145 -141
- package/src/export.js +1 -1
- package/src/flow.js +160 -228
- package/src/index.js +36 -2
- package/src/kinds.js +49 -0
- package/src/manifesto.js +3 -8
- package/src/mcp.js +97 -20
- package/src/owners.js +132 -132
- package/src/parser.js +58 -131
- package/src/queries-cli.js +866 -0
- package/src/queries.js +1356 -2261
- package/src/resolve.js +11 -2
- package/src/result-formatter.js +21 -0
- package/src/sequence.js +364 -0
- package/src/structure.js +200 -199
- package/src/test-filter.js +7 -0
- package/src/triage.js +120 -162
- 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(
|
|
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
|
+
}
|
package/src/sequence.js
ADDED
|
@@ -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, '<')
|
|
303
|
+
.replace(/>/g, '>')
|
|
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
|
+
}
|