archgraph 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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/bin/bgx.js +2 -0
  4. package/docs/examples/executors.example.json +15 -0
  5. package/docs/examples/view-spec.yaml +6 -0
  6. package/docs/release.md +15 -0
  7. package/docs/view-spec.md +28 -0
  8. package/integrations/README.md +20 -0
  9. package/integrations/claude/.claude/skills/backend-graphing/SKILL.md +56 -0
  10. package/integrations/claude/.claude/skills/backend-graphing-describe/SKILL.md +50 -0
  11. package/integrations/claude/.claude-plugin/marketplace.json +18 -0
  12. package/integrations/claude/.claude-plugin/plugin.json +9 -0
  13. package/integrations/claude/skills/backend-graphing/SKILL.md +56 -0
  14. package/integrations/claude/skills/backend-graphing-describe/SKILL.md +50 -0
  15. package/integrations/codex/skills/backend-graphing/SKILL.md +56 -0
  16. package/integrations/codex/skills/backend-graphing-describe/SKILL.md +50 -0
  17. package/package.json +49 -0
  18. package/packages/cli/src/index.js +415 -0
  19. package/packages/core/src/analyze-project.js +1238 -0
  20. package/packages/core/src/export.js +77 -0
  21. package/packages/core/src/index.js +4 -0
  22. package/packages/core/src/types.js +37 -0
  23. package/packages/core/src/view.js +86 -0
  24. package/packages/viewer/public/app.js +226 -0
  25. package/packages/viewer/public/canvas.js +181 -0
  26. package/packages/viewer/public/comments.js +193 -0
  27. package/packages/viewer/public/index.html +95 -0
  28. package/packages/viewer/public/layout.js +72 -0
  29. package/packages/viewer/public/minimap.js +92 -0
  30. package/packages/viewer/public/render.js +366 -0
  31. package/packages/viewer/public/sidebar.js +107 -0
  32. package/packages/viewer/public/styles.css +728 -0
  33. package/packages/viewer/public/theme.js +19 -0
  34. package/packages/viewer/public/tooltip.js +44 -0
  35. package/packages/viewer/src/index.js +590 -0
@@ -0,0 +1,1238 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const METHOD_NAMES = new Set(['get', 'post', 'put', 'patch', 'delete']);
6
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
7
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.turbo', '.next']);
8
+
9
+ function slug(value) {
10
+ return value
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '')
14
+ .slice(0, 80);
15
+ }
16
+
17
+ function normalizeSlashes(value) {
18
+ return value.replaceAll('\\', '/');
19
+ }
20
+
21
+ function joinPath(base, leaf) {
22
+ const baseNorm = base === '/' ? '' : base;
23
+ const leafNorm = leaf.startsWith('/') ? leaf : `/${leaf}`;
24
+ const raw = `${baseNorm}${leafNorm}`.replace(/\/+/g, '/');
25
+ if (raw.length > 1 && raw.endsWith('/')) {
26
+ return raw.slice(0, -1);
27
+ }
28
+ return raw;
29
+ }
30
+
31
+ function inferPurpose(label) {
32
+ const text = label.toLowerCase();
33
+ if (text.includes('search') || text.includes('discover') || text.includes('influencer') || text.includes('keyword')) {
34
+ return 'discovery';
35
+ }
36
+ if (text.includes('analysis') || text.includes('fit') || text.includes('score') || text.includes('deep')) {
37
+ return 'analysis';
38
+ }
39
+ if (text.includes('dm') || text.includes('email') || text.includes('outreach') || text.includes('send')) {
40
+ return 'outreach';
41
+ }
42
+ if (text.includes('auto') || text.includes('schedule') || text.includes('cron')) {
43
+ return 'automation';
44
+ }
45
+ if (text.includes('chat') || text.includes('stream') || text.includes('session')) {
46
+ return 'chat';
47
+ }
48
+ return null;
49
+ }
50
+
51
+ class GraphStore {
52
+ constructor(projectPath) {
53
+ this.projectPath = projectPath;
54
+ this.nodes = [];
55
+ this.edges = [];
56
+ this.groups = [];
57
+ this.nodeKeyToId = new Map();
58
+ this.edgeKeySet = new Set();
59
+ this.groupKeySet = new Set();
60
+ this.idCount = 0;
61
+ }
62
+
63
+ #nextId(prefix) {
64
+ this.idCount += 1;
65
+ return `${prefix}:${String(this.idCount).padStart(6, '0')}`;
66
+ }
67
+
68
+ addNode({ kind, label, file, symbol, external = false, tags = [], meta = {}, keyHint }) {
69
+ const key = keyHint ?? `${kind}|${file ?? ''}|${symbol ?? ''}|${label}`;
70
+ const existing = this.nodeKeyToId.get(key);
71
+ if (existing) {
72
+ return existing;
73
+ }
74
+
75
+ const id = this.#nextId(slug(kind) || 'node');
76
+ this.nodeKeyToId.set(key, id);
77
+ this.nodes.push({
78
+ id,
79
+ kind,
80
+ label,
81
+ file,
82
+ symbol,
83
+ external,
84
+ tags,
85
+ meta,
86
+ });
87
+ return id;
88
+ }
89
+
90
+ addEdge({ kind, from, to, label = '', meta = {} }) {
91
+ if (!from || !to) return null;
92
+ const key = `${kind}|${from}|${to}|${label}`;
93
+ if (this.edgeKeySet.has(key)) return key;
94
+ this.edgeKeySet.add(key);
95
+ this.edges.push({ id: this.#nextId('edge'), kind, from, to, label: label || undefined, meta });
96
+ return key;
97
+ }
98
+
99
+ addGroup({ id, kind, name, memberNodeIds }) {
100
+ const key = `${kind}|${name}`;
101
+ if (this.groupKeySet.has(key)) return;
102
+ this.groupKeySet.add(key);
103
+ this.groups.push({ id, kind, name, memberNodeIds: [...new Set(memberNodeIds)] });
104
+ }
105
+
106
+ toBundle() {
107
+ const byKind = {};
108
+ const byFile = {};
109
+
110
+ for (const node of this.nodes) {
111
+ if (!byKind[node.kind]) byKind[node.kind] = [];
112
+ byKind[node.kind].push(node.id);
113
+
114
+ if (node.file) {
115
+ const f = normalizeSlashes(node.file);
116
+ if (!byFile[f]) byFile[f] = [];
117
+ byFile[f].push(node.id);
118
+ }
119
+ }
120
+
121
+ const strings = [...new Set(this.nodes.map((n) => n.label).concat(this.edges.map((e) => e.kind)))];
122
+
123
+ return {
124
+ schemaVersion: '1.1.0',
125
+ createdAt: new Date().toISOString(),
126
+ project: {
127
+ rootPath: this.projectPath,
128
+ name: basename(this.projectPath),
129
+ },
130
+ strings,
131
+ nodes: this.nodes,
132
+ edges: this.edges,
133
+ groups: this.groups,
134
+ indexes: {
135
+ byKind,
136
+ byFile,
137
+ },
138
+ };
139
+ }
140
+ }
141
+
142
+ async function walkFiles(root) {
143
+ const out = [];
144
+
145
+ async function walk(dir) {
146
+ const entries = await readdir(dir, { withFileTypes: true });
147
+ for (const entry of entries) {
148
+ if (entry.isDirectory()) {
149
+ if (SKIP_DIRS.has(entry.name)) continue;
150
+ await walk(join(dir, entry.name));
151
+ continue;
152
+ }
153
+ if (!entry.isFile()) continue;
154
+ const ext = extname(entry.name);
155
+ if (!EXTENSIONS.has(ext)) continue;
156
+ out.push(join(dir, entry.name));
157
+ }
158
+ }
159
+
160
+ await walk(root);
161
+ out.sort();
162
+ return out;
163
+ }
164
+
165
+ function normalizeImportPath(specifier) {
166
+ return specifier.replace(/\.(js|mjs|cjs)$/, '');
167
+ }
168
+
169
+ function resolveModulePath(filePath, specifier, knownFiles) {
170
+ if (!specifier.startsWith('.')) return null;
171
+ const fromDir = dirname(filePath);
172
+ const base = resolve(fromDir, normalizeImportPath(specifier));
173
+
174
+ const candidates = [
175
+ base,
176
+ `${base}.ts`,
177
+ `${base}.tsx`,
178
+ `${base}.js`,
179
+ `${base}.jsx`,
180
+ join(base, 'index.ts'),
181
+ join(base, 'index.tsx'),
182
+ join(base, 'index.js'),
183
+ join(base, 'index.jsx'),
184
+ ];
185
+
186
+ for (const candidate of candidates) {
187
+ if (knownFiles.has(candidate)) return candidate;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ async function loadTypeScript() {
193
+ const candidates = [
194
+ 'typescript',
195
+ '/Users/sangsoolee/GitHub/scout-manager-backend/node_modules/typescript/lib/typescript.js',
196
+ '/Users/sangsoolee/GitHub/scout-manager/node_modules/typescript/lib/typescript.js',
197
+ ];
198
+
199
+ for (const candidate of candidates) {
200
+ try {
201
+ if (candidate.startsWith('/')) {
202
+ const mod = await import(pathToFileURL(candidate).href);
203
+ return mod.default ?? mod;
204
+ }
205
+ const mod = await import(candidate);
206
+ return mod.default ?? mod;
207
+ } catch {
208
+ // continue
209
+ }
210
+ }
211
+
212
+ return null;
213
+ }
214
+
215
+ function createSourceFile(ts, filePath, text) {
216
+ if (!ts) return null;
217
+ const kind = filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
218
+ return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, kind);
219
+ }
220
+
221
+ function getLiteralText(ts, node) {
222
+ if (!node || !ts) return null;
223
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
224
+ return node.text;
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function getIdentifierName(ts, node) {
230
+ if (!node || !ts) return null;
231
+ if (ts.isIdentifier(node)) return node.text;
232
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) {
233
+ return node.name.text;
234
+ }
235
+ return null;
236
+ }
237
+
238
+ function getSourceText(sourceFile, node) {
239
+ if (!sourceFile || !node) return '';
240
+ return sourceFile.text.slice(node.getStart(sourceFile), node.end);
241
+ }
242
+
243
+ function buildLoc(ts, sourceFile, node) {
244
+ if (!ts || !sourceFile || !node) return null;
245
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
246
+ const end = sourceFile.getLineAndCharacterOfPosition(node.end);
247
+ return {
248
+ startLine: start.line + 1,
249
+ startCol: start.character + 1,
250
+ endLine: end.line + 1,
251
+ endCol: end.character + 1,
252
+ };
253
+ }
254
+
255
+ function buildSignature(ts, sourceFile, node, name, kind = 'function') {
256
+ if (!ts || !sourceFile || !node || !name) return null;
257
+ const params = (node.parameters ?? [])
258
+ .map((param) => {
259
+ if (param.name && typeof param.name.getText === 'function') {
260
+ return param.name.getText(sourceFile);
261
+ }
262
+ return 'arg';
263
+ })
264
+ .join(', ');
265
+ return kind === 'method' ? `${name}(${params})` : `function ${name}(${params})`;
266
+ }
267
+
268
+ function parseImports(ts, sourceFile, filePath, knownFiles) {
269
+ const imports = new Map();
270
+ if (!ts || !sourceFile) return imports;
271
+
272
+ for (const stmt of sourceFile.statements) {
273
+ if (!ts.isImportDeclaration(stmt) || !stmt.moduleSpecifier) continue;
274
+ const specifier = getLiteralText(ts, stmt.moduleSpecifier);
275
+ if (!specifier) continue;
276
+
277
+ const resolvedFile = resolveModulePath(filePath, specifier, knownFiles);
278
+ const external = !specifier.startsWith('.');
279
+
280
+ const clause = stmt.importClause;
281
+ if (!clause) continue;
282
+
283
+ if (clause.name) {
284
+ imports.set(clause.name.text, {
285
+ local: clause.name.text,
286
+ imported: 'default',
287
+ source: specifier,
288
+ resolvedFile,
289
+ external,
290
+ });
291
+ }
292
+
293
+ if (clause.namedBindings) {
294
+ if (ts.isNamespaceImport(clause.namedBindings)) {
295
+ const local = clause.namedBindings.name.text;
296
+ imports.set(local, {
297
+ local,
298
+ imported: '*',
299
+ source: specifier,
300
+ resolvedFile,
301
+ external,
302
+ });
303
+ } else if (ts.isNamedImports(clause.namedBindings)) {
304
+ for (const element of clause.namedBindings.elements) {
305
+ const local = element.name.text;
306
+ const imported = element.propertyName?.text ?? element.name.text;
307
+ imports.set(local, {
308
+ local,
309
+ imported,
310
+ source: specifier,
311
+ resolvedFile,
312
+ external,
313
+ });
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ return imports;
320
+ }
321
+
322
+ function parseFileSymbols(ts, sourceFile) {
323
+ const functionRecords = [];
324
+ const callRecords = [];
325
+ const functionRanges = [];
326
+ const routeMethodCalls = [];
327
+ const routeUseCalls = [];
328
+ const appObjects = new Set();
329
+ const routerObjects = new Set();
330
+
331
+ if (!ts || !sourceFile) {
332
+ return {
333
+ functionRecords,
334
+ callRecords,
335
+ functionRanges,
336
+ routeMethodCalls,
337
+ routeUseCalls,
338
+ appObjects,
339
+ routerObjects,
340
+ };
341
+ }
342
+
343
+ function expressionToText(node) {
344
+ if (!node) return '';
345
+ return getSourceText(sourceFile, node).replace(/\s+/g, ' ').trim().slice(0, 180);
346
+ }
347
+
348
+ function addFunction(name, node, kind = 'function') {
349
+ if (!name) return null;
350
+ const record = {
351
+ name,
352
+ kind,
353
+ start: node.pos,
354
+ end: node.end,
355
+ loc: buildLoc(ts, sourceFile, node),
356
+ signature: buildSignature(ts, sourceFile, node, name, kind),
357
+ };
358
+ functionRecords.push(record);
359
+ functionRanges.push(record);
360
+ return record;
361
+ }
362
+
363
+ function addCallRecord(from, callee, condition) {
364
+ if (!from || !callee) return;
365
+ callRecords.push({
366
+ from,
367
+ callee,
368
+ condition: condition
369
+ ? {
370
+ ...condition,
371
+ label: condition.label?.slice(0, 160),
372
+ }
373
+ : null,
374
+ });
375
+ }
376
+
377
+ function scanRouteHandlerReferences(node, knownNames) {
378
+ const found = new Map();
379
+
380
+ function visit(n) {
381
+ function maybeAdd(name, locNode = n, allowUnknown = false) {
382
+ if (!name || found.has(name)) return;
383
+ if (allowUnknown) {
384
+ if (!(knownNames.has(name) || /^[a-z][A-Za-z0-9_]*$/.test(name))) return;
385
+ } else if (!knownNames.has(name)) {
386
+ return;
387
+ }
388
+ found.set(name, {
389
+ name,
390
+ loc: buildLoc(ts, sourceFile, locNode),
391
+ });
392
+ }
393
+
394
+ if (ts.isIdentifier(n)) {
395
+ maybeAdd(n.text, n);
396
+ }
397
+ if (ts.isCallExpression(n)) {
398
+ const name = getIdentifierName(ts, n.expression);
399
+ maybeAdd(name, n.expression, true);
400
+ }
401
+ ts.forEachChild(n, visit);
402
+ }
403
+
404
+ visit(node);
405
+ return [...found.values()];
406
+ }
407
+
408
+ function visit(node, currentFunctionName = null, condition = null) {
409
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
410
+ const localName = node.name.text;
411
+ const init = node.initializer;
412
+
413
+ if (init && ts.isCallExpression(init)) {
414
+ const exprName = getIdentifierName(ts, init.expression);
415
+ if (exprName === 'Router') routerObjects.add(localName);
416
+ if (exprName === 'express') appObjects.add(localName);
417
+ }
418
+
419
+ if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
420
+ addFunction(localName, init, 'function');
421
+ visit(init, localName, condition);
422
+ return;
423
+ }
424
+ }
425
+
426
+ if (ts.isFunctionDeclaration(node) && node.name) {
427
+ addFunction(node.name.text, node, 'function');
428
+ if (node.body) {
429
+ visit(node.body, node.name.text, condition);
430
+ }
431
+ return;
432
+ }
433
+
434
+ if (ts.isMethodDeclaration(node) && node.name) {
435
+ const methodName = getIdentifierName(ts, node.name);
436
+ if (methodName) {
437
+ addFunction(methodName, node, 'method');
438
+ if (node.body) {
439
+ visit(node.body, methodName, condition);
440
+ }
441
+ return;
442
+ }
443
+ }
444
+
445
+ if (ts.isIfStatement(node)) {
446
+ const label = `if ${expressionToText(node.expression)}`;
447
+ const loc = buildLoc(ts, sourceFile, node.expression);
448
+ visit(node.expression, currentFunctionName, condition);
449
+ visit(node.thenStatement, currentFunctionName, { type: 'true', label, loc });
450
+ if (node.elseStatement) {
451
+ visit(node.elseStatement, currentFunctionName, { type: 'false', label, loc });
452
+ }
453
+ return;
454
+ }
455
+
456
+ if (ts.isSwitchStatement(node)) {
457
+ const label = `switch ${expressionToText(node.expression)}`;
458
+ visit(node.expression, currentFunctionName, condition);
459
+ for (const clause of node.caseBlock.clauses) {
460
+ const caseLabel = ts.isCaseClause(clause) ? expressionToText(clause.expression) : 'default';
461
+ const caseLoc = buildLoc(ts, sourceFile, ts.isCaseClause(clause) ? clause.expression : node.expression);
462
+ for (const stmt of clause.statements) {
463
+ visit(stmt, currentFunctionName, { type: 'case', label, caseLabel, loc: caseLoc });
464
+ }
465
+ }
466
+ return;
467
+ }
468
+
469
+ if (ts.isConditionalExpression(node)) {
470
+ const label = `ternary ${expressionToText(node.condition)}`;
471
+ const loc = buildLoc(ts, sourceFile, node.condition);
472
+ visit(node.condition, currentFunctionName, condition);
473
+ visit(node.whenTrue, currentFunctionName, { type: 'true', label, loc });
474
+ visit(node.whenFalse, currentFunctionName, { type: 'false', label, loc });
475
+ return;
476
+ }
477
+
478
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
479
+ const objectExpr = node.expression.expression;
480
+ const objectName = getIdentifierName(ts, objectExpr);
481
+ const methodName = getIdentifierName(ts, node.expression.name);
482
+
483
+ if (METHOD_NAMES.has(methodName)) {
484
+ const [pathArg, ...restArgs] = node.arguments;
485
+ const path = getLiteralText(ts, pathArg);
486
+ if (objectName && path) {
487
+ const knownNames = new Set(functionRecords.map((r) => r.name));
488
+ const handlerRefs = new Map();
489
+
490
+ function addHandlerRef(name, loc) {
491
+ if (!name || handlerRefs.has(name)) return;
492
+ handlerRefs.set(name, { name, loc });
493
+ }
494
+
495
+ for (const arg of restArgs) {
496
+ if (ts.isIdentifier(arg)) {
497
+ addHandlerRef(arg.text, buildLoc(ts, sourceFile, arg));
498
+ }
499
+ for (const ref of scanRouteHandlerReferences(arg, knownNames)) {
500
+ addHandlerRef(ref.name, ref.loc);
501
+ }
502
+ }
503
+
504
+ routeMethodCalls.push({
505
+ objectName,
506
+ method: methodName.toUpperCase(),
507
+ path,
508
+ handlers: [...handlerRefs.keys()],
509
+ handlerRefs: [...handlerRefs.values()],
510
+ loc: buildLoc(ts, sourceFile, node),
511
+ });
512
+ }
513
+ }
514
+
515
+ if (methodName === 'use') {
516
+ const [pathArg, targetArg] = node.arguments;
517
+ const path = getLiteralText(ts, pathArg);
518
+ if (objectName && path && targetArg) {
519
+ const target = getIdentifierName(ts, targetArg);
520
+ if (target) {
521
+ routeUseCalls.push({
522
+ objectName,
523
+ path,
524
+ targetIdentifier: target,
525
+ loc: buildLoc(ts, sourceFile, node),
526
+ });
527
+ }
528
+ }
529
+ }
530
+
531
+ if (currentFunctionName) {
532
+ const callee = getIdentifierName(ts, node.expression);
533
+ if (callee) {
534
+ addCallRecord(currentFunctionName, callee, condition);
535
+ }
536
+ }
537
+ }
538
+
539
+ if (ts.isCallExpression(node) && currentFunctionName && ts.isIdentifier(node.expression)) {
540
+ addCallRecord(currentFunctionName, node.expression.text, condition);
541
+ }
542
+
543
+ ts.forEachChild(node, (child) => visit(child, currentFunctionName, condition));
544
+ }
545
+
546
+ visit(sourceFile);
547
+
548
+ return {
549
+ functionRecords,
550
+ callRecords,
551
+ functionRanges,
552
+ routeMethodCalls,
553
+ routeUseCalls,
554
+ appObjects,
555
+ routerObjects,
556
+ };
557
+ }
558
+
559
+ function parseMcpTools(fileText) {
560
+ const toolNames = new Set();
561
+ const toolArrays = [...fileText.matchAll(/(?:const|export\s+const)\s+([A-Z0-9_]*TOOLS)\s*=\s*\[([\s\S]*?)\]/g)];
562
+ for (const match of toolArrays) {
563
+ const body = match[2] ?? '';
564
+ for (const entry of body.matchAll(/['"`]([^'"`]+)['"`]/g)) {
565
+ toolNames.add(entry[1]);
566
+ }
567
+ }
568
+
569
+ const callToolMatches = [...fileText.matchAll(/\.callTool(?:<[^>]+>)?\(\s*['"`]([^'"`]+)['"`]/g)];
570
+ for (const match of callToolMatches) {
571
+ toolNames.add(match[1]);
572
+ }
573
+
574
+ return {
575
+ tools: [...toolNames],
576
+ callMatches: callToolMatches.map((m) => ({ tool: m[1], index: m.index ?? 0 })),
577
+ };
578
+ }
579
+
580
+ function parseLangGraph(fileText) {
581
+ const nodes = new Set();
582
+ const edges = [];
583
+
584
+ for (const match of fileText.matchAll(/\.addNode\(\s*['"`]([^'"`]+)['"`]/g)) {
585
+ nodes.add(match[1]);
586
+ }
587
+
588
+ for (const match of fileText.matchAll(/\.addEdge\(\s*([^,]+?)\s*,\s*([^)]+?)\)/g)) {
589
+ const rawFrom = match[1].trim();
590
+ const rawTo = match[2].trim();
591
+
592
+ const from = rawFrom.replace(/^['"`]|['"`]$/g, '');
593
+ const to = rawTo.replace(/^['"`]|['"`]$/g, '');
594
+
595
+ if (from && from !== 'START' && from !== 'END') nodes.add(from);
596
+ if (to && to !== 'START' && to !== 'END') nodes.add(to);
597
+ if (from && to && from !== 'START' && to !== 'END') {
598
+ edges.push({ from, to });
599
+ }
600
+ }
601
+
602
+ for (const match of fileText.matchAll(/\.addConditionalEdges\(\s*['"`]([^'"`]+)['"`][\s\S]*?\{([\s\S]*?)\}\s*\)/g)) {
603
+ const from = match[1];
604
+ const body = match[2] ?? '';
605
+ nodes.add(from);
606
+
607
+ for (const m of body.matchAll(/([A-Za-z0-9_]+)\s*:\s*['"`]([^'"`]+)['"`]/g)) {
608
+ const to = m[2];
609
+ if (to === '__end__' || to === 'END') continue;
610
+ nodes.add(to);
611
+ edges.push({ from, to });
612
+ }
613
+ }
614
+
615
+ return {
616
+ nodes: [...nodes],
617
+ edges,
618
+ };
619
+ }
620
+
621
+ function buildAdjacency(edges) {
622
+ const map = new Map();
623
+ for (const edge of edges) {
624
+ if (!map.has(edge.from)) map.set(edge.from, []);
625
+ map.get(edge.from).push(edge.to);
626
+ }
627
+ return map;
628
+ }
629
+
630
+ function collectReachable(start, adjacency, maxDepth = 4) {
631
+ const seen = new Set();
632
+ const queue = [{ id: start, depth: 0 }];
633
+ while (queue.length) {
634
+ const { id, depth } = queue.shift();
635
+ if (seen.has(id)) continue;
636
+ seen.add(id);
637
+ if (depth >= maxDepth) continue;
638
+ const next = adjacency.get(id) ?? [];
639
+ for (const n of next) {
640
+ if (!seen.has(n)) queue.push({ id: n, depth: depth + 1 });
641
+ }
642
+ }
643
+ return seen;
644
+ }
645
+
646
+ export async function analyzeProject(options) {
647
+ const projectPath = resolve(options.projectPath);
648
+ const ts = await loadTypeScript();
649
+ const files = await walkFiles(projectPath);
650
+ const knownFiles = new Set(files);
651
+
652
+ const graph = new GraphStore(projectPath);
653
+
654
+ const importMapByFile = new Map();
655
+ const fileInfoByFile = new Map();
656
+ const fileNodeByFile = new Map();
657
+ const moduleNodeByFile = new Map();
658
+
659
+ const functionNodeByFileAndName = new Map();
660
+ const functionNodeByName = new Map();
661
+ const functionCallsById = new Map();
662
+ const functionMetaById = new Map();
663
+
664
+ const routeMethods = [];
665
+ const routeUses = [];
666
+ const appObjectsByFile = new Map();
667
+ const routerObjectsByFile = new Map();
668
+
669
+ const langgraphNodeIds = new Map();
670
+ const mcpToolNodeIds = new Map();
671
+
672
+ for (const filePath of files) {
673
+ const fileText = await readFile(filePath, 'utf8');
674
+ const sourceFile = createSourceFile(ts, filePath, fileText);
675
+
676
+ const relFile = normalizeSlashes(relative(projectPath, filePath));
677
+ const fileNode = graph.addNode({
678
+ kind: 'file',
679
+ label: relFile,
680
+ file: relFile,
681
+ symbol: relFile,
682
+ keyHint: `file|${relFile}`,
683
+ });
684
+
685
+ const moduleNode = graph.addNode({
686
+ kind: 'module',
687
+ label: relFile,
688
+ file: relFile,
689
+ symbol: relFile,
690
+ keyHint: `module|${relFile}`,
691
+ });
692
+
693
+ fileNodeByFile.set(filePath, fileNode);
694
+ moduleNodeByFile.set(filePath, moduleNode);
695
+
696
+ const imports = parseImports(ts, sourceFile, filePath, knownFiles);
697
+ importMapByFile.set(filePath, imports);
698
+
699
+ const parsed = parseFileSymbols(ts, sourceFile);
700
+ fileInfoByFile.set(filePath, {
701
+ ...parsed,
702
+ text: fileText,
703
+ relFile,
704
+ });
705
+
706
+ appObjectsByFile.set(filePath, parsed.appObjects);
707
+ routerObjectsByFile.set(filePath, parsed.routerObjects);
708
+
709
+ const byName = new Map();
710
+ functionNodeByFileAndName.set(filePath, byName);
711
+
712
+ for (const fn of parsed.functionRecords) {
713
+ const kind = fn.kind === 'method' ? 'method' : 'function';
714
+ const nodeId = graph.addNode({
715
+ kind,
716
+ label: fn.name,
717
+ file: relFile,
718
+ symbol: fn.name,
719
+ meta: {
720
+ loc: fn.loc ?? undefined,
721
+ signature: fn.signature ?? undefined,
722
+ },
723
+ keyHint: `${kind}|${relFile}|${fn.name}`,
724
+ });
725
+ byName.set(fn.name, nodeId);
726
+ if (!functionNodeByName.has(fn.name)) functionNodeByName.set(fn.name, new Set());
727
+ functionNodeByName.get(fn.name).add(nodeId);
728
+ functionMetaById.set(nodeId, {
729
+ name: fn.name,
730
+ file: filePath,
731
+ loc: fn.loc ?? null,
732
+ signature: fn.signature ?? null,
733
+ kind,
734
+ });
735
+ graph.addEdge({ kind: 'imports', from: moduleNode, to: nodeId, label: 'declares' });
736
+ }
737
+
738
+ for (const call of parsed.callRecords) {
739
+ const fromNode = byName.get(call.from);
740
+ if (!fromNode) continue;
741
+ if (!functionCallsById.has(fromNode)) functionCallsById.set(fromNode, []);
742
+ functionCallsById.get(fromNode).push(call);
743
+ }
744
+
745
+ for (const route of parsed.routeMethodCalls) {
746
+ routeMethods.push({ ...route, filePath });
747
+ }
748
+
749
+ for (const useCall of parsed.routeUseCalls) {
750
+ routeUses.push({ ...useCall, filePath });
751
+ }
752
+
753
+ const langgraph = parseLangGraph(fileText);
754
+ for (const name of langgraph.nodes) {
755
+ const nodeId = graph.addNode({
756
+ kind: 'langgraph_node',
757
+ label: name,
758
+ file: relFile,
759
+ symbol: name,
760
+ keyHint: `langgraph_node|${relFile}|${name}`,
761
+ });
762
+ langgraphNodeIds.set(`${relFile}:${name}`, nodeId);
763
+ }
764
+ for (const edge of langgraph.edges) {
765
+ const from = graph.addNode({
766
+ kind: 'langgraph_node',
767
+ label: edge.from,
768
+ file: relFile,
769
+ symbol: edge.from,
770
+ keyHint: `langgraph_node|${relFile}|${edge.from}`,
771
+ });
772
+ const to = graph.addNode({
773
+ kind: 'langgraph_node',
774
+ label: edge.to,
775
+ file: relFile,
776
+ symbol: edge.to,
777
+ keyHint: `langgraph_node|${relFile}|${edge.to}`,
778
+ });
779
+ graph.addEdge({ kind: 'langgraph_edge', from, to, label: `${edge.from}->${edge.to}` });
780
+ }
781
+
782
+ const mcp = parseMcpTools(fileText);
783
+ for (const tool of mcp.tools) {
784
+ const nodeId = graph.addNode({
785
+ kind: 'mcp_tool',
786
+ label: tool,
787
+ file: relFile,
788
+ symbol: tool,
789
+ keyHint: `mcp_tool|${tool}`,
790
+ });
791
+ mcpToolNodeIds.set(tool, nodeId);
792
+ }
793
+
794
+ for (const callMatch of mcp.callMatches) {
795
+ const fnInRange = parsed.functionRanges.find((range) => callMatch.index >= range.start && callMatch.index <= range.end);
796
+ const fromId = fnInRange ? byName.get(fnInRange.name) : moduleNode;
797
+ const toId = mcpToolNodeIds.get(callMatch.tool);
798
+ if (fromId && toId) {
799
+ graph.addEdge({ kind: 'uses_mcp_tool', from: fromId, to: toId });
800
+ }
801
+ }
802
+ }
803
+
804
+ function resolveFunctionNode(filePath, name) {
805
+ function pickKnownByName() {
806
+ const candidates = functionNodeByName.get(name);
807
+ if (!candidates || !candidates.size) return null;
808
+ if (candidates.size === 1) return [...candidates][0];
809
+
810
+ const ranked = [...candidates].sort((a, b) => {
811
+ const aFile = functionMetaById.get(a)?.file ?? '';
812
+ const bFile = functionMetaById.get(b)?.file ?? '';
813
+ const aAgents = aFile.includes('/packages/agents/') ? 0 : 1;
814
+ const bAgents = bFile.includes('/packages/agents/') ? 0 : 1;
815
+ if (aAgents !== bAgents) return aAgents - bAgents;
816
+ return aFile.localeCompare(bFile);
817
+ });
818
+ return ranked[0] ?? null;
819
+ }
820
+
821
+ const byName = functionNodeByFileAndName.get(filePath);
822
+ if (byName?.has(name)) return byName.get(name);
823
+
824
+ const imports = importMapByFile.get(filePath);
825
+ const imported = imports?.get(name);
826
+ if (imported?.resolvedFile) {
827
+ const importedMap = functionNodeByFileAndName.get(imported.resolvedFile);
828
+ if (imported.imported === 'default') {
829
+ // default export handler not resolved symbolically; fallback to same local name
830
+ if (importedMap?.has(name)) return importedMap.get(name);
831
+ }
832
+ if (importedMap?.has(imported.imported)) return importedMap.get(imported.imported);
833
+ if (importedMap?.has(name)) return importedMap.get(name);
834
+ }
835
+
836
+ if (imported?.external && imported.source === '@repo/agents') {
837
+ const known = pickKnownByName();
838
+ if (known) return known;
839
+
840
+ const externalFn = graph.addNode({
841
+ kind: 'function',
842
+ label: name,
843
+ file: imported.source,
844
+ symbol: name,
845
+ external: true,
846
+ keyHint: `function|${imported.source}|${name}`,
847
+ });
848
+
849
+ const agentNode = graph.addNode({
850
+ kind: 'agent',
851
+ label: name,
852
+ file: imported.source,
853
+ symbol: name,
854
+ external: true,
855
+ keyHint: `agent|${imported.source}|${name}`,
856
+ });
857
+ graph.addEdge({ kind: 'invokes_agent', from: externalFn, to: agentNode });
858
+ return externalFn;
859
+ }
860
+
861
+ const known = pickKnownByName();
862
+ if (known) return known;
863
+
864
+ return null;
865
+ }
866
+
867
+ const conditionNodeByFunctionKey = new Map();
868
+
869
+ function ensureConditionNode(fromId, fromMeta, condition) {
870
+ const key = `${fromId}|${condition.type}|${condition.label}|${condition.caseLabel ?? ''}`;
871
+ if (conditionNodeByFunctionKey.has(key)) return conditionNodeByFunctionKey.get(key);
872
+
873
+ const relFile = fromMeta?.file ? normalizeSlashes(relative(projectPath, fromMeta.file)) : undefined;
874
+ const conditionNodeId = graph.addNode({
875
+ kind: 'condition',
876
+ label: condition.caseLabel ? `${condition.label} :: ${condition.caseLabel}` : condition.label,
877
+ file: relFile,
878
+ symbol: condition.label,
879
+ meta: {
880
+ loc: condition.loc ?? undefined,
881
+ branchType: condition.type,
882
+ caseLabel: condition.caseLabel ?? undefined,
883
+ },
884
+ keyHint: `condition|${fromId}|${condition.type}|${condition.label}|${condition.caseLabel ?? ''}`,
885
+ });
886
+ conditionNodeByFunctionKey.set(key, conditionNodeId);
887
+ graph.addEdge({ kind: 'evaluates_condition', from: fromId, to: conditionNodeId, label: condition.label });
888
+ return conditionNodeId;
889
+ }
890
+
891
+ // Resolve call graph edges (direct)
892
+ for (const [fromId, callEntries] of functionCallsById.entries()) {
893
+ const fromMeta = functionMetaById.get(fromId);
894
+ if (!fromMeta) continue;
895
+ for (const callEntry of callEntries) {
896
+ const calleeName = callEntry.callee;
897
+ const toId = resolveFunctionNode(fromMeta.file, calleeName);
898
+ if (!toId) continue;
899
+ graph.addEdge({ kind: 'calls', from: fromId, to: toId, label: calleeName });
900
+
901
+ if (callEntry.condition?.label) {
902
+ const conditionNodeId = ensureConditionNode(fromId, fromMeta, callEntry.condition);
903
+ const conditionalKind =
904
+ callEntry.condition.type === 'case'
905
+ ? 'condition_case'
906
+ : callEntry.condition.type === 'false'
907
+ ? 'condition_false'
908
+ : 'condition_true';
909
+ graph.addEdge({
910
+ kind: conditionalKind,
911
+ from: conditionNodeId,
912
+ to: toId,
913
+ label: callEntry.condition.caseLabel ?? calleeName,
914
+ });
915
+ }
916
+
917
+ const imports = importMapByFile.get(fromMeta.file);
918
+ if (imports?.get(calleeName)?.external && imports.get(calleeName).source === '@repo/agents') {
919
+ const agentNode = graph.addNode({
920
+ kind: 'agent',
921
+ label: calleeName,
922
+ file: '@repo/agents',
923
+ symbol: calleeName,
924
+ external: true,
925
+ keyHint: `agent|@repo/agents|${calleeName}`,
926
+ });
927
+ graph.addEdge({ kind: 'invokes_agent', from: fromId, to: agentNode });
928
+ }
929
+ }
930
+ }
931
+
932
+ // Resolve import relations between modules
933
+ for (const [filePath, imports] of importMapByFile.entries()) {
934
+ const fromModule = moduleNodeByFile.get(filePath);
935
+ for (const imported of imports.values()) {
936
+ if (!fromModule) continue;
937
+ if (imported.resolvedFile) {
938
+ const toModule = moduleNodeByFile.get(imported.resolvedFile);
939
+ if (toModule) {
940
+ graph.addEdge({ kind: 'imports', from: fromModule, to: toModule, label: imported.source });
941
+ }
942
+ }
943
+ }
944
+ }
945
+
946
+ const methodByFileObject = new Map();
947
+ const useByFileObject = new Map();
948
+
949
+ for (const method of routeMethods) {
950
+ const key = `${method.filePath}|${method.objectName}`;
951
+ if (!methodByFileObject.has(key)) methodByFileObject.set(key, []);
952
+ methodByFileObject.get(key).push(method);
953
+ }
954
+
955
+ for (const useCall of routeUses) {
956
+ const key = `${useCall.filePath}|${useCall.objectName}`;
957
+ if (!useByFileObject.has(key)) useByFileObject.set(key, []);
958
+ useByFileObject.get(key).push(useCall);
959
+ }
960
+
961
+ const endpointNodes = [];
962
+ const routeHandlerNodeByKey = new Map();
963
+ const routeHandlerToFunction = new Map();
964
+
965
+ function ensureRouteHandlerNode(name, filePath, meta = null) {
966
+ const relFile = normalizeSlashes(relative(projectPath, filePath));
967
+ const key = `${relFile}|${name}`;
968
+ if (routeHandlerNodeByKey.has(key)) return routeHandlerNodeByKey.get(key);
969
+ const nodeId = graph.addNode({
970
+ kind: 'route_handler',
971
+ label: name,
972
+ file: relFile,
973
+ symbol: name,
974
+ meta: {
975
+ loc: meta?.loc ?? undefined,
976
+ signature: `function ${name}(...)`,
977
+ },
978
+ keyHint: `route_handler|${relFile}|${name}`,
979
+ });
980
+ routeHandlerNodeByKey.set(key, nodeId);
981
+
982
+ const functionId = resolveFunctionNode(filePath, name);
983
+ if (functionId) {
984
+ routeHandlerToFunction.set(nodeId, functionId);
985
+ }
986
+ return nodeId;
987
+ }
988
+
989
+ function resolveImportedRouterFile(filePath, targetIdentifier) {
990
+ const imports = importMapByFile.get(filePath);
991
+ const imp = imports?.get(targetIdentifier);
992
+ if (imp?.resolvedFile) return imp.resolvedFile;
993
+
994
+ // Heuristic for dynamically imported `routes` module pattern:
995
+ // const routes = routesModule.default; app.use('/api', routes);
996
+ if (targetIdentifier === 'routes') {
997
+ const directory = dirname(filePath);
998
+ const candidates = [
999
+ join(directory, 'routes', 'index.ts'),
1000
+ join(directory, 'routes', 'index.tsx'),
1001
+ join(directory, 'routes', 'index.js'),
1002
+ join(directory, 'routes', 'index.jsx'),
1003
+ ];
1004
+ for (const candidate of candidates) {
1005
+ if (knownFiles.has(candidate)) return candidate;
1006
+ }
1007
+ }
1008
+ return null;
1009
+ }
1010
+
1011
+ function pickPrimaryRouterObject(filePath) {
1012
+ const routerSet = routerObjectsByFile.get(filePath);
1013
+ if (routerSet && routerSet.size) return [...routerSet][0];
1014
+
1015
+ for (const method of routeMethods) {
1016
+ if (method.filePath === filePath) return method.objectName;
1017
+ }
1018
+ return 'router';
1019
+ }
1020
+
1021
+ function expandMountedRoutes(filePath, objectName, prefix, seen = new Set()) {
1022
+ const contextKey = `${filePath}|${objectName}|${prefix}`;
1023
+ if (seen.has(contextKey)) return;
1024
+ seen.add(contextKey);
1025
+
1026
+ const methodKey = `${filePath}|${objectName}`;
1027
+ const methods = methodByFileObject.get(methodKey) ?? [];
1028
+ for (const method of methods) {
1029
+ const fullPath = joinPath(prefix, method.path);
1030
+ const endpointLabel = `${method.method} ${fullPath}`;
1031
+ const relFile = normalizeSlashes(relative(projectPath, filePath));
1032
+
1033
+ const endpointId = graph.addNode({
1034
+ kind: 'endpoint',
1035
+ label: endpointLabel,
1036
+ file: relFile,
1037
+ symbol: endpointLabel,
1038
+ meta: {
1039
+ loc: method.loc ?? undefined,
1040
+ signature: endpointLabel,
1041
+ },
1042
+ keyHint: `endpoint|${endpointLabel}`,
1043
+ });
1044
+ endpointNodes.push(endpointId);
1045
+ graph.addEdge({ kind: 'declares_endpoint', from: fileNodeByFile.get(filePath), to: endpointId });
1046
+
1047
+ const handlerRefs = method.handlerRefs?.length
1048
+ ? method.handlerRefs
1049
+ : method.handlers.map((name) => ({ name, loc: method.loc ?? null }));
1050
+ for (const handlerRef of handlerRefs) {
1051
+ const handler = handlerRef.name;
1052
+ const routeHandlerId = ensureRouteHandlerNode(handler, filePath, handlerRef);
1053
+ if (routeHandlerId) {
1054
+ graph.addEdge({ kind: 'handles', from: endpointId, to: routeHandlerId, label: handler });
1055
+ }
1056
+
1057
+ const functionId = resolveFunctionNode(filePath, handler);
1058
+ if (functionId) {
1059
+ graph.addEdge({ kind: 'handles', from: endpointId, to: functionId, label: handler });
1060
+
1061
+ const imports = importMapByFile.get(filePath);
1062
+ if (imports?.get(handler)?.source === '@repo/agents') {
1063
+ const agentNode = graph.addNode({
1064
+ kind: 'agent',
1065
+ label: handler,
1066
+ file: '@repo/agents',
1067
+ symbol: handler,
1068
+ external: true,
1069
+ keyHint: `agent|@repo/agents|${handler}`,
1070
+ });
1071
+ graph.addEdge({ kind: 'invokes_agent', from: functionId, to: agentNode });
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ const uses = useByFileObject.get(methodKey) ?? [];
1078
+ for (const useCall of uses) {
1079
+ const nextPrefix = joinPath(prefix, useCall.path);
1080
+ const targetFile = resolveImportedRouterFile(filePath, useCall.targetIdentifier);
1081
+ if (!targetFile) continue;
1082
+ const targetObject = pickPrimaryRouterObject(targetFile);
1083
+ expandMountedRoutes(targetFile, targetObject, nextPrefix, seen);
1084
+ }
1085
+ }
1086
+
1087
+ for (const [filePath, appObjects] of appObjectsByFile.entries()) {
1088
+ for (const appObject of appObjects) {
1089
+ expandMountedRoutes(filePath, appObject, '');
1090
+ }
1091
+ }
1092
+
1093
+ // Fallback for router-only projects
1094
+ if (!endpointNodes.length) {
1095
+ for (const [filePath, routerObjects] of routerObjectsByFile.entries()) {
1096
+ for (const routerObject of routerObjects) {
1097
+ expandMountedRoutes(filePath, routerObject, '');
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ // Transitive call edges
1103
+ const directCallEdges = graph.edges.filter((edge) => edge.kind === 'calls');
1104
+ const adjacency = buildAdjacency(directCallEdges);
1105
+
1106
+ for (const [fromId] of functionCallsById.entries()) {
1107
+ const seen = collectReachable(fromId, adjacency, 5);
1108
+ for (const toId of seen) {
1109
+ if (toId === fromId) continue;
1110
+ graph.addEdge({ kind: 'calls', from: fromId, to: toId, label: 'transitive' });
1111
+ }
1112
+ }
1113
+
1114
+ // Route handler call expansion
1115
+ for (const [routeHandlerId, functionId] of routeHandlerToFunction.entries()) {
1116
+ graph.addEdge({ kind: 'calls', from: routeHandlerId, to: functionId, label: 'handler_impl' });
1117
+ const reachable = collectReachable(functionId, adjacency, 5);
1118
+ for (const targetId of reachable) {
1119
+ if (targetId === functionId) continue;
1120
+ graph.addEdge({ kind: 'calls', from: routeHandlerId, to: targetId, label: 'transitive' });
1121
+ }
1122
+ }
1123
+
1124
+ // Phase 2 frontend adapter
1125
+ if (options.includeFrontend) {
1126
+ const frontendRoot = options.frontendPath ? resolve(options.frontendPath) : null;
1127
+ if (frontendRoot) {
1128
+ const frontendRouteFile = join(frontendRoot, 'app', 'routes.ts');
1129
+ try {
1130
+ const text = await readFile(frontendRouteFile, 'utf8');
1131
+ const rel = normalizeSlashes(relative(projectPath, frontendRouteFile));
1132
+ const moduleNode = graph.addNode({
1133
+ kind: 'module',
1134
+ label: rel,
1135
+ file: rel,
1136
+ symbol: rel,
1137
+ keyHint: `module|${rel}`,
1138
+ });
1139
+
1140
+ for (const match of text.matchAll(/route\(\s*['"`]([^'"`]+)['"`],\s*['"`]([^'"`]+)['"`]/g)) {
1141
+ const path = `/${match[1].replace(/^\//, '')}`;
1142
+ const targetFile = match[2];
1143
+ const uiRoute = graph.addNode({
1144
+ kind: 'ui_route',
1145
+ label: path,
1146
+ file: rel,
1147
+ symbol: path,
1148
+ keyHint: `ui_route|${path}`,
1149
+ });
1150
+ const component = graph.addNode({
1151
+ kind: 'ui_component',
1152
+ label: targetFile,
1153
+ file: rel,
1154
+ symbol: targetFile,
1155
+ keyHint: `ui_component|${targetFile}`,
1156
+ });
1157
+ graph.addEdge({ kind: 'imports', from: moduleNode, to: uiRoute });
1158
+ graph.addEdge({ kind: 'imports', from: uiRoute, to: component, label: 'renders' });
1159
+ }
1160
+ } catch {
1161
+ // ignore frontend adapter failures in backend-first mode
1162
+ }
1163
+ }
1164
+ }
1165
+
1166
+ // Grouping
1167
+ const graphAdj = buildAdjacency(graph.edges);
1168
+
1169
+ const endpointNodeList = graph.nodes.filter((n) => n.kind === 'endpoint');
1170
+ for (const endpoint of endpointNodeList) {
1171
+ const members = [...collectReachable(endpoint.id, graphAdj, 3)];
1172
+ const groupId = graph.addNode({
1173
+ kind: 'group',
1174
+ label: endpoint.label,
1175
+ symbol: endpoint.label,
1176
+ keyHint: `group|endpoint|${endpoint.label}`,
1177
+ });
1178
+ graph.addGroup({
1179
+ id: groupId,
1180
+ kind: 'endpoint',
1181
+ name: endpoint.label,
1182
+ memberNodeIds: members,
1183
+ });
1184
+ for (const memberId of members) {
1185
+ graph.addEdge({ kind: 'belongs_to_group', from: memberId, to: groupId, label: 'endpoint' });
1186
+ }
1187
+ }
1188
+
1189
+ const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
1190
+ for (const agent of agentNodes) {
1191
+ const members = [...collectReachable(agent.id, graphAdj, 2)];
1192
+ const groupId = graph.addNode({
1193
+ kind: 'group',
1194
+ label: agent.label,
1195
+ symbol: agent.label,
1196
+ keyHint: `group|agent|${agent.label}`,
1197
+ });
1198
+ graph.addGroup({
1199
+ id: groupId,
1200
+ kind: 'agent',
1201
+ name: agent.label,
1202
+ memberNodeIds: members,
1203
+ });
1204
+ for (const memberId of members) {
1205
+ graph.addEdge({ kind: 'belongs_to_group', from: memberId, to: groupId, label: 'agent' });
1206
+ }
1207
+ }
1208
+
1209
+ const purposeBuckets = new Map();
1210
+ for (const node of graph.nodes) {
1211
+ if (node.kind === 'group') continue;
1212
+ const purpose = inferPurpose(node.label);
1213
+ if (!purpose) continue;
1214
+ if (!purposeBuckets.has(purpose)) purposeBuckets.set(purpose, new Set());
1215
+ purposeBuckets.get(purpose).add(node.id);
1216
+ }
1217
+
1218
+ for (const [purpose, nodeSet] of purposeBuckets.entries()) {
1219
+ const members = [...nodeSet];
1220
+ const groupId = graph.addNode({
1221
+ kind: 'group',
1222
+ label: purpose,
1223
+ symbol: purpose,
1224
+ keyHint: `group|purpose|${purpose}`,
1225
+ });
1226
+ graph.addGroup({
1227
+ id: groupId,
1228
+ kind: 'purpose',
1229
+ name: purpose,
1230
+ memberNodeIds: members,
1231
+ });
1232
+ for (const memberId of members) {
1233
+ graph.addEdge({ kind: 'belongs_to_group', from: memberId, to: groupId, label: 'purpose' });
1234
+ }
1235
+ }
1236
+
1237
+ return graph.toBundle();
1238
+ }