@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.
- package/README.md +3 -2
- package/package.json +7 -7
- package/src/ast-analysis/engine.js +252 -258
- package/src/ast-analysis/shared.js +0 -12
- package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
- package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
- package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
- package/src/cli/commands/ast.js +2 -1
- package/src/cli/commands/audit.js +2 -1
- package/src/cli/commands/batch.js +2 -1
- package/src/cli/commands/brief.js +12 -0
- package/src/cli/commands/cfg.js +2 -1
- package/src/cli/commands/check.js +20 -23
- package/src/cli/commands/children.js +6 -1
- package/src/cli/commands/complexity.js +2 -1
- package/src/cli/commands/context.js +6 -1
- package/src/cli/commands/dataflow.js +2 -1
- package/src/cli/commands/deps.js +8 -3
- package/src/cli/commands/flow.js +2 -1
- package/src/cli/commands/fn-impact.js +6 -1
- package/src/cli/commands/owners.js +4 -2
- package/src/cli/commands/query.js +6 -1
- package/src/cli/commands/roles.js +2 -1
- package/src/cli/commands/search.js +8 -2
- package/src/cli/commands/sequence.js +2 -1
- package/src/cli/commands/triage.js +38 -27
- package/src/db/connection.js +18 -12
- package/src/db/migrations.js +41 -64
- package/src/db/query-builder.js +60 -4
- package/src/db/repository/in-memory-repository.js +27 -16
- package/src/db/repository/nodes.js +8 -10
- package/src/domain/analysis/brief.js +155 -0
- package/src/domain/analysis/context.js +174 -190
- package/src/domain/analysis/dependencies.js +200 -146
- package/src/domain/analysis/exports.js +3 -2
- package/src/domain/analysis/impact.js +267 -152
- package/src/domain/analysis/module-map.js +247 -221
- package/src/domain/analysis/roles.js +8 -5
- package/src/domain/analysis/symbol-lookup.js +7 -5
- package/src/domain/graph/builder/helpers.js +1 -1
- package/src/domain/graph/builder/incremental.js +116 -90
- package/src/domain/graph/builder/pipeline.js +106 -80
- package/src/domain/graph/builder/stages/build-edges.js +318 -239
- package/src/domain/graph/builder/stages/detect-changes.js +198 -177
- package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
- package/src/domain/graph/watcher.js +2 -2
- package/src/domain/parser.js +20 -11
- package/src/domain/queries.js +1 -0
- package/src/domain/search/search/filters.js +9 -5
- package/src/domain/search/search/keyword.js +12 -5
- package/src/domain/search/search/prepare.js +13 -5
- package/src/extractors/csharp.js +224 -207
- package/src/extractors/go.js +176 -172
- package/src/extractors/hcl.js +94 -78
- package/src/extractors/java.js +213 -207
- package/src/extractors/javascript.js +274 -304
- package/src/extractors/php.js +234 -221
- package/src/extractors/python.js +252 -250
- package/src/extractors/ruby.js +192 -185
- package/src/extractors/rust.js +182 -167
- package/src/features/ast.js +5 -3
- package/src/features/audit.js +4 -2
- package/src/features/boundaries.js +98 -83
- package/src/features/cfg.js +134 -143
- package/src/features/communities.js +68 -53
- package/src/features/complexity.js +143 -132
- package/src/features/dataflow.js +146 -149
- package/src/features/export.js +3 -3
- package/src/features/graph-enrichment.js +2 -2
- package/src/features/manifesto.js +9 -6
- package/src/features/owners.js +4 -3
- package/src/features/sequence.js +152 -141
- package/src/features/shared/find-nodes.js +31 -0
- package/src/features/structure.js +130 -99
- package/src/features/triage.js +83 -68
- package/src/graph/classifiers/risk.js +3 -2
- package/src/graph/classifiers/roles.js +6 -3
- package/src/index.js +1 -0
- package/src/mcp/server.js +65 -56
- package/src/mcp/tool-registry.js +13 -0
- package/src/mcp/tools/brief.js +8 -0
- package/src/mcp/tools/index.js +2 -0
- package/src/presentation/brief.js +51 -0
- package/src/presentation/queries-cli/exports.js +21 -14
- package/src/presentation/queries-cli/impact.js +55 -39
- package/src/presentation/queries-cli/inspect.js +184 -189
- package/src/presentation/queries-cli/overview.js +57 -58
- package/src/presentation/queries-cli/path.js +36 -29
- package/src/presentation/table.js +0 -8
- package/src/shared/generators.js +7 -3
- package/src/shared/kinds.js +1 -1
package/src/features/sequence.js
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
+
}
|