@utopia-ai/cli 0.1.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/.claude/settings.json +1 -0
- package/.claude/settings.local.json +38 -0
- package/bin/utopia.js +20 -0
- package/package.json +46 -0
- package/python/README.md +34 -0
- package/python/instrumenter/instrument.py +1148 -0
- package/python/pyproject.toml +32 -0
- package/python/setup.py +27 -0
- package/python/utopia_runtime/__init__.py +30 -0
- package/python/utopia_runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/utopia_runtime/__pycache__/client.cpython-313.pyc +0 -0
- package/python/utopia_runtime/__pycache__/probe.cpython-313.pyc +0 -0
- package/python/utopia_runtime/client.py +31 -0
- package/python/utopia_runtime/probe.py +446 -0
- package/python/utopia_runtime.egg-info/PKG-INFO +59 -0
- package/python/utopia_runtime.egg-info/SOURCES.txt +10 -0
- package/python/utopia_runtime.egg-info/dependency_links.txt +1 -0
- package/python/utopia_runtime.egg-info/top_level.txt +1 -0
- package/scripts/publish-npm.sh +14 -0
- package/scripts/publish-pypi.sh +17 -0
- package/src/cli/commands/codex.ts +193 -0
- package/src/cli/commands/context.ts +188 -0
- package/src/cli/commands/destruct.ts +237 -0
- package/src/cli/commands/easter-eggs.ts +203 -0
- package/src/cli/commands/init.ts +505 -0
- package/src/cli/commands/instrument.ts +962 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/serve.ts +194 -0
- package/src/cli/commands/status.ts +304 -0
- package/src/cli/commands/validate.ts +328 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/utils/config.ts +54 -0
- package/src/graph/index.ts +687 -0
- package/src/instrumenter/javascript.ts +1798 -0
- package/src/mcp/index.ts +886 -0
- package/src/runtime/js/index.ts +518 -0
- package/src/runtime/js/package-lock.json +30 -0
- package/src/runtime/js/package.json +30 -0
- package/src/runtime/js/tsconfig.json +16 -0
- package/src/server/db/index.ts +26 -0
- package/src/server/db/schema.ts +45 -0
- package/src/server/index.ts +79 -0
- package/src/server/middleware/auth.ts +74 -0
- package/src/server/routes/admin.ts +36 -0
- package/src/server/routes/graph.ts +358 -0
- package/src/server/routes/probes.ts +286 -0
- package/src/types.ts +147 -0
- package/src/utopia-mode/index.ts +206 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Types
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
interface ProbeRecord {
|
|
8
|
+
id: string;
|
|
9
|
+
project_id: string;
|
|
10
|
+
probe_type: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
file: string;
|
|
13
|
+
line: number;
|
|
14
|
+
function_name: string;
|
|
15
|
+
data: string; // JSON string
|
|
16
|
+
metadata: string; // JSON string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface GraphNodeRecord {
|
|
20
|
+
id: string;
|
|
21
|
+
type: string;
|
|
22
|
+
name: string;
|
|
23
|
+
file: string | null;
|
|
24
|
+
metadata: string; // JSON string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GraphEdgeRecord {
|
|
28
|
+
source: string;
|
|
29
|
+
target: string;
|
|
30
|
+
type: string;
|
|
31
|
+
weight: number;
|
|
32
|
+
last_seen: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ImpactResult {
|
|
36
|
+
rootNode: GraphNodeRecord;
|
|
37
|
+
impactedNodes: { node: GraphNodeRecord; depth: number; path: string[] }[];
|
|
38
|
+
edges: GraphEdgeRecord[];
|
|
39
|
+
totalImpacted: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DependencyResult {
|
|
43
|
+
rootNode: GraphNodeRecord;
|
|
44
|
+
dependencies: { node: GraphNodeRecord; depth: number; path: string[] }[];
|
|
45
|
+
edges: GraphEdgeRecord[];
|
|
46
|
+
totalDependencies: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface GraphStats {
|
|
50
|
+
totalNodes: number;
|
|
51
|
+
nodesByType: Record<string, number>;
|
|
52
|
+
totalEdges: number;
|
|
53
|
+
edgesByType: Record<string, number>;
|
|
54
|
+
mostConnected: { node: GraphNodeRecord; connectionCount: number }[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a deterministic node ID from type and name.
|
|
63
|
+
* Format: `type:name`
|
|
64
|
+
*/
|
|
65
|
+
function nodeId(type: string, name: string): string {
|
|
66
|
+
return `${type}:${name}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Derive a stable API node name from a URL string.
|
|
71
|
+
* Strips query parameters, fragments, trailing slashes, and normalises the
|
|
72
|
+
* result to `hostname/path`.
|
|
73
|
+
*/
|
|
74
|
+
function normalizeApiName(raw: string): string {
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(raw);
|
|
77
|
+
// hostname + pathname, strip trailing slash
|
|
78
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '';
|
|
79
|
+
return `${url.hostname}${pathname}`;
|
|
80
|
+
} catch {
|
|
81
|
+
// If URL parsing fails, do a best-effort strip of query/fragment
|
|
82
|
+
const cleaned = raw.split('?')[0].split('#')[0].replace(/\/+$/, '');
|
|
83
|
+
return cleaned;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// ImpactGraph
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
class ImpactGraph {
|
|
92
|
+
private db: Database.Database;
|
|
93
|
+
|
|
94
|
+
// Prepared statements — lazily initialised on first use so that the
|
|
95
|
+
// constructor never throws if the schema hasn't been created yet.
|
|
96
|
+
private stmts!: {
|
|
97
|
+
upsertNode: Database.Statement;
|
|
98
|
+
upsertEdge: Database.Statement;
|
|
99
|
+
getNode: Database.Statement;
|
|
100
|
+
getOutEdges: Database.Statement;
|
|
101
|
+
getInEdges: Database.Statement;
|
|
102
|
+
allNodes: Database.Statement;
|
|
103
|
+
allEdges: Database.Statement;
|
|
104
|
+
allProbes: Database.Statement;
|
|
105
|
+
nodesByType: Database.Statement;
|
|
106
|
+
edgesForNodeSet: (ids: string[]) => Database.Statement;
|
|
107
|
+
countNodes: Database.Statement;
|
|
108
|
+
countEdges: Database.Statement;
|
|
109
|
+
nodeCountsByType: Database.Statement;
|
|
110
|
+
edgeCountsByType: Database.Statement;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
private prepared = false;
|
|
114
|
+
|
|
115
|
+
constructor(db: Database.Database) {
|
|
116
|
+
this.db = db;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------------
|
|
120
|
+
// Statement preparation
|
|
121
|
+
// ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
private ensurePrepared(): void {
|
|
124
|
+
if (this.prepared) return;
|
|
125
|
+
|
|
126
|
+
const db = this.db;
|
|
127
|
+
|
|
128
|
+
this.stmts = {
|
|
129
|
+
upsertNode: db.prepare(`
|
|
130
|
+
INSERT INTO graph_nodes (id, type, name, file, metadata)
|
|
131
|
+
VALUES (@id, @type, @name, @file, @metadata)
|
|
132
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
133
|
+
type = excluded.type,
|
|
134
|
+
name = excluded.name,
|
|
135
|
+
file = excluded.file,
|
|
136
|
+
metadata = excluded.metadata
|
|
137
|
+
`),
|
|
138
|
+
|
|
139
|
+
upsertEdge: db.prepare(`
|
|
140
|
+
INSERT INTO graph_edges (source, target, type, weight, last_seen)
|
|
141
|
+
VALUES (@source, @target, @type, 1, datetime('now'))
|
|
142
|
+
ON CONFLICT(source, target, type) DO UPDATE SET
|
|
143
|
+
weight = graph_edges.weight + 1,
|
|
144
|
+
last_seen = datetime('now')
|
|
145
|
+
`),
|
|
146
|
+
|
|
147
|
+
getNode: db.prepare('SELECT * FROM graph_nodes WHERE id = ?'),
|
|
148
|
+
|
|
149
|
+
getOutEdges: db.prepare('SELECT * FROM graph_edges WHERE source = ?'),
|
|
150
|
+
|
|
151
|
+
getInEdges: db.prepare('SELECT * FROM graph_edges WHERE target = ?'),
|
|
152
|
+
|
|
153
|
+
allNodes: db.prepare('SELECT * FROM graph_nodes'),
|
|
154
|
+
|
|
155
|
+
allEdges: db.prepare('SELECT * FROM graph_edges'),
|
|
156
|
+
|
|
157
|
+
allProbes: db.prepare('SELECT * FROM probes ORDER BY timestamp ASC'),
|
|
158
|
+
|
|
159
|
+
nodesByType: db.prepare('SELECT * FROM graph_nodes WHERE type = ?'),
|
|
160
|
+
|
|
161
|
+
// Dynamic — returns a fresh statement for a given set of IDs
|
|
162
|
+
edgesForNodeSet: (ids: string[]) => {
|
|
163
|
+
const ph = ids.map(() => '?').join(',');
|
|
164
|
+
return db.prepare(
|
|
165
|
+
`SELECT * FROM graph_edges WHERE source IN (${ph}) OR target IN (${ph})`,
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
countNodes: db.prepare('SELECT COUNT(*) as cnt FROM graph_nodes'),
|
|
170
|
+
|
|
171
|
+
countEdges: db.prepare('SELECT COUNT(*) as cnt FROM graph_edges'),
|
|
172
|
+
|
|
173
|
+
nodeCountsByType: db.prepare(
|
|
174
|
+
'SELECT type, COUNT(*) as cnt FROM graph_nodes GROUP BY type',
|
|
175
|
+
),
|
|
176
|
+
|
|
177
|
+
edgeCountsByType: db.prepare(
|
|
178
|
+
'SELECT type, COUNT(*) as cnt FROM graph_edges GROUP BY type',
|
|
179
|
+
),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
this.prepared = true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------------
|
|
186
|
+
// Low-level graph mutations
|
|
187
|
+
// ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
private upsertNode(
|
|
190
|
+
type: string,
|
|
191
|
+
name: string,
|
|
192
|
+
file: string | null,
|
|
193
|
+
metadata: Record<string, unknown> = {},
|
|
194
|
+
): GraphNodeRecord {
|
|
195
|
+
this.ensurePrepared();
|
|
196
|
+
const id = nodeId(type, name);
|
|
197
|
+
this.stmts.upsertNode.run({
|
|
198
|
+
id,
|
|
199
|
+
type,
|
|
200
|
+
name,
|
|
201
|
+
file,
|
|
202
|
+
metadata: JSON.stringify(metadata),
|
|
203
|
+
});
|
|
204
|
+
return { id, type, name, file, metadata: JSON.stringify(metadata) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private upsertEdge(source: string, target: string, type: string): void {
|
|
208
|
+
this.ensurePrepared();
|
|
209
|
+
this.stmts.upsertEdge.run({ source, target, type });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ------------------------------------------------------------------
|
|
213
|
+
// processProbe
|
|
214
|
+
// ------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
processProbe(probe: ProbeRecord): void {
|
|
217
|
+
this.ensurePrepared();
|
|
218
|
+
|
|
219
|
+
let data: Record<string, unknown>;
|
|
220
|
+
try {
|
|
221
|
+
data = JSON.parse(probe.data);
|
|
222
|
+
} catch {
|
|
223
|
+
data = {};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const file = probe.file;
|
|
227
|
+
const functionName = probe.function_name;
|
|
228
|
+
|
|
229
|
+
switch (probe.probe_type) {
|
|
230
|
+
case 'error':
|
|
231
|
+
this.processErrorProbe(file, functionName, data);
|
|
232
|
+
break;
|
|
233
|
+
case 'database':
|
|
234
|
+
this.processDatabaseProbe(file, functionName, data);
|
|
235
|
+
break;
|
|
236
|
+
case 'api':
|
|
237
|
+
this.processApiProbe(file, functionName, data);
|
|
238
|
+
break;
|
|
239
|
+
case 'infra':
|
|
240
|
+
this.processInfraProbe(file, data);
|
|
241
|
+
break;
|
|
242
|
+
case 'function':
|
|
243
|
+
this.processFunctionProbe(file, functionName, data);
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
// Unknown probe type — silently ignore
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private processErrorProbe(
|
|
252
|
+
file: string,
|
|
253
|
+
functionName: string,
|
|
254
|
+
data: Record<string, unknown>,
|
|
255
|
+
): void {
|
|
256
|
+
// Function node for the function that errored
|
|
257
|
+
const fnName = functionName || 'unknown';
|
|
258
|
+
const fnNodeName = `${file}:${fnName}`;
|
|
259
|
+
this.upsertNode('function', fnNodeName, file, {
|
|
260
|
+
errorType: data.errorType,
|
|
261
|
+
message: data.message,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// File node
|
|
265
|
+
this.upsertNode('file', file, file);
|
|
266
|
+
|
|
267
|
+
// Edge: function -> file
|
|
268
|
+
this.upsertEdge(nodeId('function', fnNodeName), nodeId('file', file), 'depends_on');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private processDatabaseProbe(
|
|
272
|
+
file: string,
|
|
273
|
+
functionName: string,
|
|
274
|
+
data: Record<string, unknown>,
|
|
275
|
+
): void {
|
|
276
|
+
// Function node for the caller
|
|
277
|
+
const fnName = functionName || 'unknown';
|
|
278
|
+
const fnNodeName = `${file}:${fnName}`;
|
|
279
|
+
this.upsertNode('function', fnNodeName, file);
|
|
280
|
+
|
|
281
|
+
// Database node — prefer table name, fall back to connection info
|
|
282
|
+
let dbName: string;
|
|
283
|
+
if (data.table && typeof data.table === 'string') {
|
|
284
|
+
dbName = data.table;
|
|
285
|
+
} else if (
|
|
286
|
+
data.connectionInfo &&
|
|
287
|
+
typeof data.connectionInfo === 'object' &&
|
|
288
|
+
(data.connectionInfo as Record<string, unknown>).database
|
|
289
|
+
) {
|
|
290
|
+
dbName = String((data.connectionInfo as Record<string, unknown>).database);
|
|
291
|
+
} else {
|
|
292
|
+
dbName = 'unknown';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const connInfo = data.connectionInfo as Record<string, unknown> | undefined;
|
|
296
|
+
this.upsertNode('database', dbName, null, {
|
|
297
|
+
operation: data.operation,
|
|
298
|
+
connectionType: connInfo?.type,
|
|
299
|
+
host: connInfo?.host,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Edge: function -> database (queries)
|
|
303
|
+
this.upsertEdge(nodeId('function', fnNodeName), nodeId('database', dbName), 'queries');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private processApiProbe(
|
|
307
|
+
file: string,
|
|
308
|
+
functionName: string,
|
|
309
|
+
data: Record<string, unknown>,
|
|
310
|
+
): void {
|
|
311
|
+
// Function node for the caller
|
|
312
|
+
const fnName = functionName || 'unknown';
|
|
313
|
+
const fnNodeName = `${file}:${fnName}`;
|
|
314
|
+
this.upsertNode('function', fnNodeName, file);
|
|
315
|
+
|
|
316
|
+
// API node — derive name from URL
|
|
317
|
+
const rawUrl = (data.url as string) || 'unknown';
|
|
318
|
+
const apiName = normalizeApiName(rawUrl);
|
|
319
|
+
this.upsertNode('api', apiName, null, {
|
|
320
|
+
method: data.method,
|
|
321
|
+
statusCode: data.statusCode,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Edge: function -> api (calls)
|
|
325
|
+
this.upsertEdge(nodeId('function', fnNodeName), nodeId('api', apiName), 'calls');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private processInfraProbe(
|
|
329
|
+
file: string,
|
|
330
|
+
data: Record<string, unknown>,
|
|
331
|
+
): void {
|
|
332
|
+
// Service node — derive service name from provider + serviceType or fallback
|
|
333
|
+
const provider = (data.provider as string) || 'unknown';
|
|
334
|
+
const serviceType = (data.serviceType as string) || 'service';
|
|
335
|
+
const serviceName = `${provider}:${serviceType}`;
|
|
336
|
+
|
|
337
|
+
this.upsertNode('service', serviceName, null, {
|
|
338
|
+
provider,
|
|
339
|
+
region: data.region,
|
|
340
|
+
instanceId: data.instanceId,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// File node for the file the probe was placed in
|
|
344
|
+
this.upsertNode('file', file, file);
|
|
345
|
+
|
|
346
|
+
// Edge: service -> file (serves)
|
|
347
|
+
this.upsertEdge(nodeId('service', serviceName), nodeId('file', file), 'serves');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private processFunctionProbe(
|
|
351
|
+
file: string,
|
|
352
|
+
functionName: string,
|
|
353
|
+
data: Record<string, unknown>,
|
|
354
|
+
): void {
|
|
355
|
+
// The probed function itself
|
|
356
|
+
const fnName = functionName || 'unknown';
|
|
357
|
+
const fnNodeName = `${file}:${fnName}`;
|
|
358
|
+
this.upsertNode('function', fnNodeName, file, {
|
|
359
|
+
duration: data.duration,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Process call stack: each entry is a caller of the next
|
|
363
|
+
const callStack = (data.callStack as string[]) || [];
|
|
364
|
+
if (callStack.length > 0) {
|
|
365
|
+
// The call stack typically goes from outermost to innermost.
|
|
366
|
+
// Create nodes for each stack frame and edges between consecutive
|
|
367
|
+
// callers. We also connect the deepest caller to our function.
|
|
368
|
+
let previousNodeId: string | null = null;
|
|
369
|
+
for (const frame of callStack) {
|
|
370
|
+
// Frame format is usually "file:function" or just a function name
|
|
371
|
+
const frameName = frame.includes(':') ? frame : `${file}:${frame}`;
|
|
372
|
+
const frameFile = frame.includes(':') ? frame.split(':').slice(0, -1).join(':') : file;
|
|
373
|
+
|
|
374
|
+
this.upsertNode('function', frameName, frameFile);
|
|
375
|
+
|
|
376
|
+
if (previousNodeId !== null) {
|
|
377
|
+
this.upsertEdge(previousNodeId, nodeId('function', frameName), 'calls');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
previousNodeId = nodeId('function', frameName);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Connect deepest caller to the probed function
|
|
384
|
+
if (previousNodeId !== null && previousNodeId !== nodeId('function', fnNodeName)) {
|
|
385
|
+
this.upsertEdge(previousNodeId, nodeId('function', fnNodeName), 'calls');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ------------------------------------------------------------------
|
|
391
|
+
// getImpact — BFS outward (following outgoing edges)
|
|
392
|
+
// ------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
getImpact(startNodeId: string, maxDepth: number = 5): ImpactResult {
|
|
395
|
+
this.ensurePrepared();
|
|
396
|
+
|
|
397
|
+
const rootNode = this.stmts.getNode.get(startNodeId) as GraphNodeRecord | undefined;
|
|
398
|
+
if (!rootNode) {
|
|
399
|
+
throw new Error(`Node "${startNodeId}" not found`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const visited = new Set<string>([startNodeId]);
|
|
403
|
+
const collectedEdges: GraphEdgeRecord[] = [];
|
|
404
|
+
const impactedNodes: { node: GraphNodeRecord; depth: number; path: string[] }[] = [];
|
|
405
|
+
|
|
406
|
+
// Map node ID -> shortest path from root
|
|
407
|
+
const pathMap = new Map<string, string[]>();
|
|
408
|
+
pathMap.set(startNodeId, [startNodeId]);
|
|
409
|
+
|
|
410
|
+
let frontier = [startNodeId];
|
|
411
|
+
|
|
412
|
+
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
413
|
+
const nextFrontier: string[] = [];
|
|
414
|
+
|
|
415
|
+
for (const currentId of frontier) {
|
|
416
|
+
const edges = this.stmts.getOutEdges.all(currentId) as GraphEdgeRecord[];
|
|
417
|
+
for (const edge of edges) {
|
|
418
|
+
collectedEdges.push(edge);
|
|
419
|
+
if (!visited.has(edge.target)) {
|
|
420
|
+
visited.add(edge.target);
|
|
421
|
+
nextFrontier.push(edge.target);
|
|
422
|
+
|
|
423
|
+
const parentPath = pathMap.get(currentId) || [currentId];
|
|
424
|
+
pathMap.set(edge.target, [...parentPath, edge.target]);
|
|
425
|
+
|
|
426
|
+
const targetNode = this.stmts.getNode.get(edge.target) as
|
|
427
|
+
| GraphNodeRecord
|
|
428
|
+
| undefined;
|
|
429
|
+
if (targetNode) {
|
|
430
|
+
impactedNodes.push({
|
|
431
|
+
node: targetNode,
|
|
432
|
+
depth,
|
|
433
|
+
path: pathMap.get(edge.target)!,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
frontier = nextFrontier;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Deduplicate edges
|
|
444
|
+
const edgeMap = new Map<string, GraphEdgeRecord>();
|
|
445
|
+
for (const edge of collectedEdges) {
|
|
446
|
+
const key = `${edge.source}|${edge.target}|${edge.type}`;
|
|
447
|
+
edgeMap.set(key, edge);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
rootNode,
|
|
452
|
+
impactedNodes,
|
|
453
|
+
edges: [...edgeMap.values()],
|
|
454
|
+
totalImpacted: impactedNodes.length,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ------------------------------------------------------------------
|
|
459
|
+
// getDependencies — BFS inward (following incoming edges in reverse)
|
|
460
|
+
// ------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
getDependencies(startNodeId: string, maxDepth: number = 5): DependencyResult {
|
|
463
|
+
this.ensurePrepared();
|
|
464
|
+
|
|
465
|
+
const rootNode = this.stmts.getNode.get(startNodeId) as GraphNodeRecord | undefined;
|
|
466
|
+
if (!rootNode) {
|
|
467
|
+
throw new Error(`Node "${startNodeId}" not found`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const visited = new Set<string>([startNodeId]);
|
|
471
|
+
const collectedEdges: GraphEdgeRecord[] = [];
|
|
472
|
+
const dependencies: { node: GraphNodeRecord; depth: number; path: string[] }[] = [];
|
|
473
|
+
|
|
474
|
+
const pathMap = new Map<string, string[]>();
|
|
475
|
+
pathMap.set(startNodeId, [startNodeId]);
|
|
476
|
+
|
|
477
|
+
let frontier = [startNodeId];
|
|
478
|
+
|
|
479
|
+
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
480
|
+
const nextFrontier: string[] = [];
|
|
481
|
+
|
|
482
|
+
for (const currentId of frontier) {
|
|
483
|
+
const edges = this.stmts.getInEdges.all(currentId) as GraphEdgeRecord[];
|
|
484
|
+
for (const edge of edges) {
|
|
485
|
+
collectedEdges.push(edge);
|
|
486
|
+
if (!visited.has(edge.source)) {
|
|
487
|
+
visited.add(edge.source);
|
|
488
|
+
nextFrontier.push(edge.source);
|
|
489
|
+
|
|
490
|
+
const parentPath = pathMap.get(currentId) || [currentId];
|
|
491
|
+
pathMap.set(edge.source, [...parentPath, edge.source]);
|
|
492
|
+
|
|
493
|
+
const sourceNode = this.stmts.getNode.get(edge.source) as
|
|
494
|
+
| GraphNodeRecord
|
|
495
|
+
| undefined;
|
|
496
|
+
if (sourceNode) {
|
|
497
|
+
dependencies.push({
|
|
498
|
+
node: sourceNode,
|
|
499
|
+
depth,
|
|
500
|
+
path: pathMap.get(edge.source)!,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
frontier = nextFrontier;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Deduplicate edges
|
|
511
|
+
const edgeMap = new Map<string, GraphEdgeRecord>();
|
|
512
|
+
for (const edge of collectedEdges) {
|
|
513
|
+
const key = `${edge.source}|${edge.target}|${edge.type}`;
|
|
514
|
+
edgeMap.set(key, edge);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
rootNode,
|
|
519
|
+
dependencies,
|
|
520
|
+
edges: [...edgeMap.values()],
|
|
521
|
+
totalDependencies: dependencies.length,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ------------------------------------------------------------------
|
|
526
|
+
// findNode — search nodes by partial match
|
|
527
|
+
// ------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
findNode(query: {
|
|
530
|
+
file?: string;
|
|
531
|
+
functionName?: string;
|
|
532
|
+
name?: string;
|
|
533
|
+
type?: string;
|
|
534
|
+
}): GraphNodeRecord[] {
|
|
535
|
+
this.ensurePrepared();
|
|
536
|
+
|
|
537
|
+
const conditions: string[] = [];
|
|
538
|
+
const params: string[] = [];
|
|
539
|
+
|
|
540
|
+
if (query.file) {
|
|
541
|
+
conditions.push('file LIKE ?');
|
|
542
|
+
params.push(`%${query.file}%`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (query.functionName) {
|
|
546
|
+
conditions.push('name LIKE ?');
|
|
547
|
+
params.push(`%${query.functionName}%`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (query.name) {
|
|
551
|
+
conditions.push('name LIKE ?');
|
|
552
|
+
params.push(`%${query.name}%`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (query.type) {
|
|
556
|
+
conditions.push('type = ?');
|
|
557
|
+
params.push(query.type);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (conditions.length === 0) {
|
|
561
|
+
return this.stmts.allNodes.all() as GraphNodeRecord[];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const sql = `SELECT * FROM graph_nodes WHERE ${conditions.join(' AND ')}`;
|
|
565
|
+
return this.db.prepare(sql).all(...params) as GraphNodeRecord[];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ------------------------------------------------------------------
|
|
569
|
+
// getFullGraph
|
|
570
|
+
// ------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
getFullGraph(nodeType?: string): { nodes: GraphNodeRecord[]; edges: GraphEdgeRecord[] } {
|
|
573
|
+
this.ensurePrepared();
|
|
574
|
+
|
|
575
|
+
if (!nodeType) {
|
|
576
|
+
return {
|
|
577
|
+
nodes: this.stmts.allNodes.all() as GraphNodeRecord[],
|
|
578
|
+
edges: this.stmts.allEdges.all() as GraphEdgeRecord[],
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const nodes = this.stmts.nodesByType.all(nodeType) as GraphNodeRecord[];
|
|
583
|
+
if (nodes.length === 0) {
|
|
584
|
+
return { nodes: [], edges: [] };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const ids = nodes.map((n) => n.id);
|
|
588
|
+
const edges = this.stmts.edgesForNodeSet(ids).all(...ids, ...ids) as GraphEdgeRecord[];
|
|
589
|
+
|
|
590
|
+
return { nodes, edges };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ------------------------------------------------------------------
|
|
594
|
+
// buildFromProbes — full rebuild from the probes table
|
|
595
|
+
// ------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
buildFromProbes(): void {
|
|
598
|
+
this.ensurePrepared();
|
|
599
|
+
|
|
600
|
+
// Clear existing graph data before rebuilding
|
|
601
|
+
this.db.exec('DELETE FROM graph_edges');
|
|
602
|
+
this.db.exec('DELETE FROM graph_nodes');
|
|
603
|
+
|
|
604
|
+
const probes = this.stmts.allProbes.all() as ProbeRecord[];
|
|
605
|
+
|
|
606
|
+
const processAll = this.db.transaction((probeList: ProbeRecord[]) => {
|
|
607
|
+
for (const probe of probeList) {
|
|
608
|
+
this.processProbe(probe);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
processAll(probes);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ------------------------------------------------------------------
|
|
616
|
+
// getStats
|
|
617
|
+
// ------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
getStats(): GraphStats {
|
|
620
|
+
this.ensurePrepared();
|
|
621
|
+
|
|
622
|
+
const totalNodes = (this.stmts.countNodes.get() as { cnt: number }).cnt;
|
|
623
|
+
const totalEdges = (this.stmts.countEdges.get() as { cnt: number }).cnt;
|
|
624
|
+
|
|
625
|
+
const nodesByType: Record<string, number> = {};
|
|
626
|
+
const nodeTypeRows = this.stmts.nodeCountsByType.all() as { type: string; cnt: number }[];
|
|
627
|
+
for (const row of nodeTypeRows) {
|
|
628
|
+
nodesByType[row.type] = row.cnt;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const edgesByType: Record<string, number> = {};
|
|
632
|
+
const edgeTypeRows = this.stmts.edgeCountsByType.all() as { type: string; cnt: number }[];
|
|
633
|
+
for (const row of edgeTypeRows) {
|
|
634
|
+
edgesByType[row.type] = row.cnt;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Most connected nodes: count outgoing + incoming edges per node, top 10
|
|
638
|
+
const mostConnectedRows = this.db
|
|
639
|
+
.prepare(
|
|
640
|
+
`
|
|
641
|
+
SELECT n.*, (
|
|
642
|
+
(SELECT COUNT(*) FROM graph_edges WHERE source = n.id) +
|
|
643
|
+
(SELECT COUNT(*) FROM graph_edges WHERE target = n.id)
|
|
644
|
+
) as connection_count
|
|
645
|
+
FROM graph_nodes n
|
|
646
|
+
ORDER BY connection_count DESC
|
|
647
|
+
LIMIT 10
|
|
648
|
+
`,
|
|
649
|
+
)
|
|
650
|
+
.all() as (GraphNodeRecord & { connection_count: number })[];
|
|
651
|
+
|
|
652
|
+
const mostConnected = mostConnectedRows.map((row) => ({
|
|
653
|
+
node: {
|
|
654
|
+
id: row.id,
|
|
655
|
+
type: row.type,
|
|
656
|
+
name: row.name,
|
|
657
|
+
file: row.file,
|
|
658
|
+
metadata: row.metadata,
|
|
659
|
+
} as GraphNodeRecord,
|
|
660
|
+
connectionCount: row.connection_count,
|
|
661
|
+
}));
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
totalNodes,
|
|
665
|
+
nodesByType,
|
|
666
|
+
totalEdges,
|
|
667
|
+
edgesByType,
|
|
668
|
+
mostConnected,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Exports
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
export { ImpactGraph };
|
|
678
|
+
export default ImpactGraph;
|
|
679
|
+
|
|
680
|
+
export type {
|
|
681
|
+
ProbeRecord,
|
|
682
|
+
GraphNodeRecord,
|
|
683
|
+
GraphEdgeRecord,
|
|
684
|
+
ImpactResult,
|
|
685
|
+
DependencyResult,
|
|
686
|
+
GraphStats,
|
|
687
|
+
};
|