@grafema/cli 0.1.1-alpha

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 (66) hide show
  1. package/LICENSE +190 -0
  2. package/dist/cli.d.ts +6 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +36 -0
  5. package/dist/commands/analyze.d.ts +6 -0
  6. package/dist/commands/analyze.d.ts.map +1 -0
  7. package/dist/commands/analyze.js +209 -0
  8. package/dist/commands/check.d.ts +10 -0
  9. package/dist/commands/check.d.ts.map +1 -0
  10. package/dist/commands/check.js +295 -0
  11. package/dist/commands/coverage.d.ts +11 -0
  12. package/dist/commands/coverage.d.ts.map +1 -0
  13. package/dist/commands/coverage.js +96 -0
  14. package/dist/commands/explore.d.ts +6 -0
  15. package/dist/commands/explore.d.ts.map +1 -0
  16. package/dist/commands/explore.js +633 -0
  17. package/dist/commands/get.d.ts +10 -0
  18. package/dist/commands/get.d.ts.map +1 -0
  19. package/dist/commands/get.js +189 -0
  20. package/dist/commands/impact.d.ts +10 -0
  21. package/dist/commands/impact.d.ts.map +1 -0
  22. package/dist/commands/impact.js +313 -0
  23. package/dist/commands/init.d.ts +6 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +94 -0
  26. package/dist/commands/overview.d.ts +6 -0
  27. package/dist/commands/overview.d.ts.map +1 -0
  28. package/dist/commands/overview.js +91 -0
  29. package/dist/commands/query.d.ts +13 -0
  30. package/dist/commands/query.d.ts.map +1 -0
  31. package/dist/commands/query.js +340 -0
  32. package/dist/commands/server.d.ts +11 -0
  33. package/dist/commands/server.d.ts.map +1 -0
  34. package/dist/commands/server.js +300 -0
  35. package/dist/commands/stats.d.ts +6 -0
  36. package/dist/commands/stats.d.ts.map +1 -0
  37. package/dist/commands/stats.js +52 -0
  38. package/dist/commands/trace.d.ts +10 -0
  39. package/dist/commands/trace.d.ts.map +1 -0
  40. package/dist/commands/trace.js +270 -0
  41. package/dist/utils/codePreview.d.ts +28 -0
  42. package/dist/utils/codePreview.d.ts.map +1 -0
  43. package/dist/utils/codePreview.js +51 -0
  44. package/dist/utils/errorFormatter.d.ts +24 -0
  45. package/dist/utils/errorFormatter.d.ts.map +1 -0
  46. package/dist/utils/errorFormatter.js +32 -0
  47. package/dist/utils/formatNode.d.ts +53 -0
  48. package/dist/utils/formatNode.d.ts.map +1 -0
  49. package/dist/utils/formatNode.js +49 -0
  50. package/package.json +54 -0
  51. package/src/cli.ts +41 -0
  52. package/src/commands/analyze.ts +271 -0
  53. package/src/commands/check.ts +379 -0
  54. package/src/commands/coverage.ts +108 -0
  55. package/src/commands/explore.tsx +1056 -0
  56. package/src/commands/get.ts +265 -0
  57. package/src/commands/impact.ts +400 -0
  58. package/src/commands/init.ts +112 -0
  59. package/src/commands/overview.ts +108 -0
  60. package/src/commands/query.ts +425 -0
  61. package/src/commands/server.ts +335 -0
  62. package/src/commands/stats.ts +58 -0
  63. package/src/commands/trace.ts +341 -0
  64. package/src/utils/codePreview.ts +77 -0
  65. package/src/utils/errorFormatter.ts +35 -0
  66. package/src/utils/formatNode.ts +88 -0
@@ -0,0 +1,1056 @@
1
+ /**
2
+ * Explore command - Interactive TUI for graph navigation
3
+ */
4
+
5
+ import { Command } from 'commander';
6
+ import { resolve, join, relative } from 'path';
7
+ import { existsSync } from 'fs';
8
+ import { execSync } from 'child_process';
9
+ import React, { useState, useEffect } from 'react';
10
+ import { render, Box, Text, useInput, useApp } from 'ink';
11
+ import { RFDBServerBackend } from '@grafema/core';
12
+ import { getCodePreview, formatCodePreview } from '../utils/codePreview.js';
13
+ import { exitWithError } from '../utils/errorFormatter.js';
14
+
15
+ // Types
16
+ interface NodeInfo {
17
+ id: string;
18
+ type: string;
19
+ name: string;
20
+ file: string;
21
+ line?: number;
22
+ // Function-specific fields
23
+ async?: boolean;
24
+ exported?: boolean;
25
+ generator?: boolean;
26
+ arrowFunction?: boolean;
27
+ params?: string[];
28
+ paramTypes?: string[];
29
+ returnType?: string;
30
+ signature?: string;
31
+ jsdocSummary?: string;
32
+ }
33
+
34
+ interface ExploreState {
35
+ currentNode: NodeInfo | null;
36
+ callers: NodeInfo[];
37
+ callees: NodeInfo[];
38
+ // For CLASS nodes
39
+ fields: NodeInfo[];
40
+ methods: NodeInfo[];
41
+ // For VARIABLE/field data flow
42
+ dataFlowSources: NodeInfo[];
43
+ dataFlowTargets: NodeInfo[];
44
+ breadcrumbs: NodeInfo[];
45
+ selectedIndex: number;
46
+ selectedPanel: 'callers' | 'callees' | 'search' | 'modules' | 'fields' | 'methods' | 'sources' | 'targets';
47
+ searchMode: boolean;
48
+ searchQuery: string;
49
+ searchResults: NodeInfo[];
50
+ modules: NodeInfo[];
51
+ loading: boolean;
52
+ error: string | null;
53
+ visibleCallers: number;
54
+ visibleCallees: number;
55
+ viewMode: 'function' | 'search' | 'modules' | 'class' | 'dataflow';
56
+ // Code preview
57
+ showCodePreview: boolean;
58
+ codePreviewLines: string[];
59
+ }
60
+
61
+ interface ExplorerProps {
62
+ backend: RFDBServerBackend;
63
+ startNode: NodeInfo | null;
64
+ projectPath: string;
65
+ }
66
+
67
+ // Main Explorer Component
68
+ function Explorer({ backend, startNode, projectPath }: ExplorerProps) {
69
+ const { exit } = useApp();
70
+
71
+ const [state, setState] = useState<ExploreState>({
72
+ currentNode: startNode,
73
+ callers: [],
74
+ callees: [],
75
+ fields: [],
76
+ methods: [],
77
+ dataFlowSources: [],
78
+ dataFlowTargets: [],
79
+ breadcrumbs: startNode ? [startNode] : [],
80
+ selectedIndex: 0,
81
+ selectedPanel: 'callers',
82
+ searchMode: false,
83
+ searchQuery: '',
84
+ searchResults: [],
85
+ modules: [],
86
+ loading: true,
87
+ error: null,
88
+ visibleCallers: 10,
89
+ visibleCallees: 10,
90
+ viewMode: 'function',
91
+ showCodePreview: false,
92
+ codePreviewLines: [],
93
+ });
94
+
95
+ // Load data when currentNode changes
96
+ useEffect(() => {
97
+ if (!state.currentNode) return;
98
+
99
+ const load = async () => {
100
+ setState(s => ({ ...s, loading: true }));
101
+
102
+ try {
103
+ const nodeType = state.currentNode!.type;
104
+
105
+ if (nodeType === 'CLASS') {
106
+ // Load fields and methods for class
107
+ const { fields, methods } = await getClassMembers(backend, state.currentNode!.id);
108
+ setState(s => ({
109
+ ...s,
110
+ fields,
111
+ methods,
112
+ viewMode: 'class',
113
+ selectedPanel: 'fields',
114
+ loading: false,
115
+ selectedIndex: 0,
116
+ }));
117
+ } else if (nodeType === 'VARIABLE' || nodeType === 'PARAMETER') {
118
+ // Load data flow for variable
119
+ const { sources, targets } = await getDataFlow(backend, state.currentNode!.id);
120
+ setState(s => ({
121
+ ...s,
122
+ dataFlowSources: sources,
123
+ dataFlowTargets: targets,
124
+ viewMode: 'dataflow',
125
+ selectedPanel: 'sources',
126
+ loading: false,
127
+ selectedIndex: 0,
128
+ }));
129
+ } else {
130
+ // Load callers/callees for function
131
+ const callers = await getCallers(backend, state.currentNode!.id, 50);
132
+ const callees = await getCallees(backend, state.currentNode!.id, 50);
133
+ setState(s => ({
134
+ ...s,
135
+ callers,
136
+ callees,
137
+ viewMode: 'function',
138
+ selectedPanel: 'callers',
139
+ loading: false,
140
+ selectedIndex: 0,
141
+ visibleCallers: 10,
142
+ visibleCallees: 10,
143
+ }));
144
+ }
145
+ } catch (err) {
146
+ setState(s => ({
147
+ ...s,
148
+ error: (err as Error).message,
149
+ loading: false,
150
+ }));
151
+ }
152
+ };
153
+
154
+ load();
155
+ }, [state.currentNode?.id]);
156
+
157
+ // Get current list based on view mode and panel
158
+ const getCurrentList = (): NodeInfo[] => {
159
+ if (state.viewMode === 'search') return state.searchResults;
160
+ if (state.viewMode === 'modules') return state.modules;
161
+ if (state.viewMode === 'class') {
162
+ return state.selectedPanel === 'fields' ? state.fields : state.methods;
163
+ }
164
+ if (state.viewMode === 'dataflow') {
165
+ return state.selectedPanel === 'sources' ? state.dataFlowSources : state.dataFlowTargets;
166
+ }
167
+ return state.selectedPanel === 'callers' ? state.callers : state.callees;
168
+ };
169
+
170
+ // Keyboard input
171
+ useInput((input, key) => {
172
+ if (state.searchMode) {
173
+ if (key.escape) {
174
+ setState(s => ({ ...s, searchMode: false, searchQuery: '' }));
175
+ } else if (key.return) {
176
+ performSearch(state.searchQuery);
177
+ setState(s => ({ ...s, searchMode: false }));
178
+ } else if (key.backspace || key.delete) {
179
+ setState(s => ({ ...s, searchQuery: s.searchQuery.slice(0, -1) }));
180
+ } else if (input && !key.ctrl && !key.meta) {
181
+ setState(s => ({ ...s, searchQuery: s.searchQuery + input }));
182
+ }
183
+ return;
184
+ }
185
+
186
+ // Normal mode
187
+ if (input === 'q') {
188
+ exit();
189
+ return;
190
+ }
191
+
192
+ if (input === '/') {
193
+ setState(s => ({ ...s, searchMode: true, searchQuery: '' }));
194
+ return;
195
+ }
196
+
197
+ if (input === '?') {
198
+ // TODO: show help
199
+ return;
200
+ }
201
+
202
+ // 'm' - show modules view
203
+ if (input === 'm') {
204
+ loadModules();
205
+ return;
206
+ }
207
+
208
+ // Space - toggle code preview
209
+ if (input === ' ') {
210
+ if (state.currentNode && state.currentNode.file && state.currentNode.line) {
211
+ if (state.showCodePreview) {
212
+ // Hide code preview
213
+ setState(s => ({ ...s, showCodePreview: false, codePreviewLines: [] }));
214
+ } else {
215
+ // Show code preview
216
+ const preview = getCodePreview({
217
+ file: state.currentNode.file,
218
+ line: state.currentNode.line,
219
+ });
220
+ if (preview) {
221
+ const formatted = formatCodePreview(preview, state.currentNode.line);
222
+ setState(s => ({ ...s, showCodePreview: true, codePreviewLines: formatted }));
223
+ }
224
+ }
225
+ }
226
+ return;
227
+ }
228
+
229
+ // 'o' - open in editor
230
+ if (input === 'o') {
231
+ if (state.currentNode && state.currentNode.file) {
232
+ const editor = process.env.EDITOR || 'code';
233
+ const file = state.currentNode.file;
234
+ const line = state.currentNode.line;
235
+ try {
236
+ if (editor.includes('code')) {
237
+ // VS Code supports --goto
238
+ execSync(`${editor} --goto "${file}:${line || 1}"`, { stdio: 'ignore' });
239
+ } else {
240
+ // Generic editor
241
+ execSync(`${editor} +${line || 1} "${file}"`, { stdio: 'ignore' });
242
+ }
243
+ } catch {
244
+ // Ignore editor errors
245
+ }
246
+ }
247
+ return;
248
+ }
249
+
250
+ // Arrow keys for panel switching
251
+ if (key.leftArrow || input === 'h') {
252
+ if (state.viewMode === 'function') {
253
+ setState(s => ({ ...s, selectedPanel: 'callers', selectedIndex: 0 }));
254
+ } else if (state.viewMode === 'class') {
255
+ setState(s => ({ ...s, selectedPanel: 'fields', selectedIndex: 0 }));
256
+ } else if (state.viewMode === 'dataflow') {
257
+ setState(s => ({ ...s, selectedPanel: 'sources', selectedIndex: 0 }));
258
+ }
259
+ return;
260
+ }
261
+
262
+ if (key.rightArrow || input === 'l') {
263
+ if (state.viewMode === 'function') {
264
+ setState(s => ({ ...s, selectedPanel: 'callees', selectedIndex: 0 }));
265
+ } else if (state.viewMode === 'class') {
266
+ setState(s => ({ ...s, selectedPanel: 'methods', selectedIndex: 0 }));
267
+ } else if (state.viewMode === 'dataflow') {
268
+ setState(s => ({ ...s, selectedPanel: 'targets', selectedIndex: 0 }));
269
+ }
270
+ return;
271
+ }
272
+
273
+ // Up arrow - works in all modes
274
+ if (key.upArrow || input === 'k') {
275
+ setState(s => ({
276
+ ...s,
277
+ selectedIndex: Math.max(0, s.selectedIndex - 1),
278
+ }));
279
+ return;
280
+ }
281
+
282
+ // Down arrow - works in all modes
283
+ if (key.downArrow || input === 'j') {
284
+ const list = getCurrentList();
285
+ setState(s => {
286
+ const newIndex = Math.min(list.length - 1, s.selectedIndex + 1);
287
+ return { ...s, selectedIndex: newIndex };
288
+ });
289
+ return;
290
+ }
291
+
292
+ // Enter - select item
293
+ if (key.return) {
294
+ const list = getCurrentList();
295
+ const selected = list[state.selectedIndex];
296
+ if (selected) {
297
+ // Don't navigate into recursive calls (same function)
298
+ if (selected.id !== state.currentNode?.id) {
299
+ navigateTo(selected);
300
+ setState(s => ({ ...s, viewMode: 'function', selectedPanel: 'callers' }));
301
+ }
302
+ }
303
+ return;
304
+ }
305
+
306
+ if (key.backspace || key.delete) {
307
+ goBack();
308
+ return;
309
+ }
310
+
311
+ if (input === 'tab') {
312
+ setState(s => ({
313
+ ...s,
314
+ selectedPanel: s.selectedPanel === 'callers' ? 'callees' : 'callers',
315
+ selectedIndex: 0,
316
+ }));
317
+ return;
318
+ }
319
+ });
320
+
321
+ const navigateTo = (node: NodeInfo) => {
322
+ setState(s => ({
323
+ ...s,
324
+ currentNode: node,
325
+ breadcrumbs: [...s.breadcrumbs, node],
326
+ selectedIndex: 0,
327
+ }));
328
+ };
329
+
330
+ const goBack = () => {
331
+ // From search/modules view, go back to function view
332
+ if (state.viewMode !== 'function') {
333
+ setState(s => ({
334
+ ...s,
335
+ viewMode: 'function',
336
+ selectedPanel: 'callers',
337
+ selectedIndex: 0,
338
+ }));
339
+ return;
340
+ }
341
+
342
+ // From function view with breadcrumbs, go back
343
+ if (state.breadcrumbs.length > 1) {
344
+ const newBreadcrumbs = state.breadcrumbs.slice(0, -1);
345
+ const previousNode = newBreadcrumbs[newBreadcrumbs.length - 1];
346
+
347
+ setState(s => ({
348
+ ...s,
349
+ currentNode: previousNode,
350
+ breadcrumbs: newBreadcrumbs,
351
+ selectedIndex: 0,
352
+ }));
353
+ }
354
+ };
355
+
356
+ const performSearch = async (query: string) => {
357
+ if (!query.trim()) return;
358
+
359
+ setState(s => ({ ...s, loading: true }));
360
+
361
+ try {
362
+ const results = await searchNodes(backend, query, 20);
363
+ setState(s => ({
364
+ ...s,
365
+ searchResults: results,
366
+ viewMode: 'search',
367
+ selectedPanel: 'search',
368
+ selectedIndex: 0,
369
+ loading: false,
370
+ error: results.length === 0 ? `No results for "${query}"` : null,
371
+ }));
372
+ } catch (err) {
373
+ setState(s => ({
374
+ ...s,
375
+ error: (err as Error).message,
376
+ loading: false,
377
+ }));
378
+ }
379
+ };
380
+
381
+ const loadModules = async () => {
382
+ setState(s => ({ ...s, loading: true }));
383
+ try {
384
+ const modules = await getModules(backend, 50);
385
+ setState(s => ({
386
+ ...s,
387
+ modules,
388
+ viewMode: 'modules',
389
+ selectedPanel: 'modules',
390
+ selectedIndex: 0,
391
+ loading: false,
392
+ }));
393
+ } catch (err) {
394
+ setState(s => ({
395
+ ...s,
396
+ error: (err as Error).message,
397
+ loading: false,
398
+ }));
399
+ }
400
+ };
401
+
402
+ const formatLoc = (node: NodeInfo) => {
403
+ if (!node.file) return '';
404
+ const rel = relative(projectPath, node.file);
405
+ return node.line ? `${rel}:${node.line}` : rel;
406
+ };
407
+
408
+ // Render
409
+ if (!state.currentNode) {
410
+ return (
411
+ <Box flexDirection="column" padding={1}>
412
+ <Text color="yellow">No function selected.</Text>
413
+ <Text>Press / to search, q to quit.</Text>
414
+ {state.searchMode && (
415
+ <Box marginTop={1}>
416
+ <Text>Search: </Text>
417
+ <Text color="cyan">{state.searchQuery}</Text>
418
+ <Text color="gray">_</Text>
419
+ </Box>
420
+ )}
421
+ </Box>
422
+ );
423
+ }
424
+
425
+ // Build badges for current node
426
+ const badges: string[] = [];
427
+ if (state.currentNode.async) badges.push('async');
428
+ if (state.currentNode.exported) badges.push('exp');
429
+ if (state.currentNode.generator) badges.push('gen');
430
+ if (state.currentNode.arrowFunction) badges.push('arrow');
431
+
432
+ return (
433
+ <Box flexDirection="column" borderStyle="round" borderColor="blue" padding={1}>
434
+ {/* Header with badges */}
435
+ <Box marginBottom={1}>
436
+ <Text bold color="cyan">Grafema Explorer</Text>
437
+ {state.loading && <Text color="yellow"> (loading...)</Text>}
438
+ {badges.length > 0 && (
439
+ <Text>
440
+ {' '}
441
+ {badges.map((badge, i) => (
442
+ <Text key={`badge-${i}`}>
443
+ <Text color="magenta">[{badge}]</Text>
444
+ {i < badges.length - 1 ? ' ' : ''}
445
+ </Text>
446
+ ))}
447
+ </Text>
448
+ )}
449
+ </Box>
450
+
451
+ {/* Breadcrumbs */}
452
+ <Box marginBottom={1}>
453
+ <Text color="gray">
454
+ {state.breadcrumbs.map((b, i) => (
455
+ <Text key={`bc-${i}-${b.id}`}>
456
+ {i > 0 ? ' → ' : ''}
457
+ <Text color={i === state.breadcrumbs.length - 1 ? 'white' : 'gray'}>
458
+ {b.name}
459
+ </Text>
460
+ </Text>
461
+ ))}
462
+ </Text>
463
+ </Box>
464
+
465
+ {/* Current node info with signature */}
466
+ <Box flexDirection="column" marginBottom={1}>
467
+ <Text>
468
+ <Text color="green" bold>{state.currentNode.type}</Text>
469
+ <Text>: </Text>
470
+ <Text bold>{state.currentNode.name}</Text>
471
+ </Text>
472
+ {state.currentNode.signature && (
473
+ <Text color="yellow">{state.currentNode.signature}</Text>
474
+ )}
475
+ {state.currentNode.jsdocSummary && (
476
+ <Text color="gray" italic> {state.currentNode.jsdocSummary}</Text>
477
+ )}
478
+ <Text color="gray">{formatLoc(state.currentNode)}</Text>
479
+ </Box>
480
+
481
+ {/* Content based on view mode */}
482
+ {state.viewMode === 'search' && (
483
+ <Box flexDirection="column">
484
+ <Text bold color="cyan">Search Results ({state.searchResults.length}):</Text>
485
+ {state.searchResults.length === 0 ? (
486
+ <Text color="gray"> No results</Text>
487
+ ) : (
488
+ state.searchResults.map((item, i) => (
489
+ <Text key={`search-${i}-${item.id}`}>
490
+ {i === state.selectedIndex ? (
491
+ <Text color="cyan" bold>{'> '}</Text>
492
+ ) : (
493
+ <Text>{' '}</Text>
494
+ )}
495
+ <Text color={i === state.selectedIndex ? 'white' : 'gray'}>
496
+ <Text color="green">{item.type}</Text> {item.name}
497
+ </Text>
498
+ <Text color="gray" dimColor> {formatLoc(item)}</Text>
499
+ </Text>
500
+ ))
501
+ )}
502
+ </Box>
503
+ )}
504
+
505
+ {state.viewMode === 'modules' && (
506
+ <Box flexDirection="column">
507
+ <Text bold color="cyan">Modules ({state.modules.length}):</Text>
508
+ {state.modules.length === 0 ? (
509
+ <Text color="gray"> No modules</Text>
510
+ ) : (
511
+ state.modules.slice(0, 20).map((mod, i) => (
512
+ <Text key={`mod-${i}-${mod.id}`}>
513
+ {i === state.selectedIndex ? (
514
+ <Text color="cyan" bold>{'> '}</Text>
515
+ ) : (
516
+ <Text>{' '}</Text>
517
+ )}
518
+ <Text color={i === state.selectedIndex ? 'white' : 'gray'}>
519
+ {formatLoc(mod)}
520
+ </Text>
521
+ </Text>
522
+ ))
523
+ )}
524
+ {state.modules.length > 20 && (
525
+ <Text color="gray"> ↓ {state.modules.length - 20} more</Text>
526
+ )}
527
+ </Box>
528
+ )}
529
+
530
+ {state.viewMode === 'function' && (
531
+ <Box>
532
+ {/* Callers column */}
533
+ <Box flexDirection="column" width="50%" paddingRight={1}>
534
+ <Text bold color={state.selectedPanel === 'callers' ? 'cyan' : 'gray'}>
535
+ Called by ({state.callers.length}):
536
+ </Text>
537
+ {state.callers.length === 0 ? (
538
+ <Text color="gray"> (none)</Text>
539
+ ) : (
540
+ state.callers.slice(0, state.visibleCallers).map((caller, i) => {
541
+ const isRecursive = caller.id === state.currentNode?.id;
542
+ return (
543
+ <Text key={`caller-${i}-${caller.id}`}>
544
+ {state.selectedPanel === 'callers' && i === state.selectedIndex ? (
545
+ <Text color="cyan" bold>{'> '}</Text>
546
+ ) : (
547
+ <Text>{' '}</Text>
548
+ )}
549
+ <Text color={isRecursive ? 'yellow' : (state.selectedPanel === 'callers' && i === state.selectedIndex ? 'white' : 'gray')}>
550
+ {caller.name}{isRecursive ? ' ↻' : ''}
551
+ </Text>
552
+ </Text>
553
+ );
554
+ })
555
+ )}
556
+ {state.callers.length > state.visibleCallers && (
557
+ <Text color="gray"> ↓ {state.callers.length - state.visibleCallers} more</Text>
558
+ )}
559
+ </Box>
560
+
561
+ {/* Callees column */}
562
+ <Box flexDirection="column" width="50%" paddingLeft={1}>
563
+ <Text bold color={state.selectedPanel === 'callees' ? 'cyan' : 'gray'}>
564
+ Calls ({state.callees.length}):
565
+ </Text>
566
+ {state.callees.length === 0 ? (
567
+ <Text color="gray"> (none)</Text>
568
+ ) : (
569
+ state.callees.slice(0, state.visibleCallees).map((callee, i) => {
570
+ const isRecursive = callee.id === state.currentNode?.id;
571
+ return (
572
+ <Text key={`callee-${i}-${callee.id}`}>
573
+ {state.selectedPanel === 'callees' && i === state.selectedIndex ? (
574
+ <Text color="cyan" bold>{'> '}</Text>
575
+ ) : (
576
+ <Text>{' '}</Text>
577
+ )}
578
+ <Text color={isRecursive ? 'yellow' : (state.selectedPanel === 'callees' && i === state.selectedIndex ? 'white' : 'gray')}>
579
+ {callee.name}{isRecursive ? ' ↻' : ''}
580
+ </Text>
581
+ </Text>
582
+ );
583
+ })
584
+ )}
585
+ {state.callees.length > state.visibleCallees && (
586
+ <Text color="gray"> ↓ {state.callees.length - state.visibleCallees} more</Text>
587
+ )}
588
+ </Box>
589
+ </Box>
590
+ )}
591
+
592
+ {state.viewMode === 'class' && (
593
+ <Box>
594
+ {/* Fields column */}
595
+ <Box flexDirection="column" width="50%" paddingRight={1}>
596
+ <Text bold color={state.selectedPanel === 'fields' ? 'cyan' : 'gray'}>
597
+ Fields ({state.fields.length}):
598
+ </Text>
599
+ {state.fields.length === 0 ? (
600
+ <Text color="gray"> (none)</Text>
601
+ ) : (
602
+ state.fields.slice(0, 15).map((field, i) => (
603
+ <Text key={`field-${i}-${field.id}`}>
604
+ {state.selectedPanel === 'fields' && i === state.selectedIndex ? (
605
+ <Text color="cyan" bold>{'> '}</Text>
606
+ ) : (
607
+ <Text>{' '}</Text>
608
+ )}
609
+ <Text color={state.selectedPanel === 'fields' && i === state.selectedIndex ? 'white' : 'gray'}>
610
+ {field.name}
611
+ </Text>
612
+ </Text>
613
+ ))
614
+ )}
615
+ {state.fields.length > 15 && (
616
+ <Text color="gray"> ↓ {state.fields.length - 15} more</Text>
617
+ )}
618
+ </Box>
619
+
620
+ {/* Methods column */}
621
+ <Box flexDirection="column" width="50%" paddingLeft={1}>
622
+ <Text bold color={state.selectedPanel === 'methods' ? 'cyan' : 'gray'}>
623
+ Methods ({state.methods.length}):
624
+ </Text>
625
+ {state.methods.length === 0 ? (
626
+ <Text color="gray"> (none)</Text>
627
+ ) : (
628
+ state.methods.slice(0, 15).map((method, i) => (
629
+ <Text key={`method-${i}-${method.id}`}>
630
+ {state.selectedPanel === 'methods' && i === state.selectedIndex ? (
631
+ <Text color="cyan" bold>{'> '}</Text>
632
+ ) : (
633
+ <Text>{' '}</Text>
634
+ )}
635
+ <Text color={state.selectedPanel === 'methods' && i === state.selectedIndex ? 'white' : 'gray'}>
636
+ {method.name}()
637
+ </Text>
638
+ </Text>
639
+ ))
640
+ )}
641
+ {state.methods.length > 15 && (
642
+ <Text color="gray"> ↓ {state.methods.length - 15} more</Text>
643
+ )}
644
+ </Box>
645
+ </Box>
646
+ )}
647
+
648
+ {state.viewMode === 'dataflow' && (
649
+ <Box>
650
+ {/* Sources column (where data comes from) */}
651
+ <Box flexDirection="column" width="50%" paddingRight={1}>
652
+ <Text bold color={state.selectedPanel === 'sources' ? 'cyan' : 'gray'}>
653
+ Data from ({state.dataFlowSources.length}):
654
+ </Text>
655
+ {state.dataFlowSources.length === 0 ? (
656
+ <Text color="gray"> (none)</Text>
657
+ ) : (
658
+ state.dataFlowSources.slice(0, 15).map((src, i) => (
659
+ <Text key={`src-${i}-${src.id}`}>
660
+ {state.selectedPanel === 'sources' && i === state.selectedIndex ? (
661
+ <Text color="cyan" bold>{'> '}</Text>
662
+ ) : (
663
+ <Text>{' '}</Text>
664
+ )}
665
+ <Text color={state.selectedPanel === 'sources' && i === state.selectedIndex ? 'white' : 'gray'}>
666
+ ← {src.name} <Text dimColor>({src.type})</Text>
667
+ </Text>
668
+ </Text>
669
+ ))
670
+ )}
671
+ </Box>
672
+
673
+ {/* Targets column (where data flows to) */}
674
+ <Box flexDirection="column" width="50%" paddingLeft={1}>
675
+ <Text bold color={state.selectedPanel === 'targets' ? 'cyan' : 'gray'}>
676
+ Flows to ({state.dataFlowTargets.length}):
677
+ </Text>
678
+ {state.dataFlowTargets.length === 0 ? (
679
+ <Text color="gray"> (none)</Text>
680
+ ) : (
681
+ state.dataFlowTargets.slice(0, 15).map((tgt, i) => (
682
+ <Text key={`tgt-${i}-${tgt.id}`}>
683
+ {state.selectedPanel === 'targets' && i === state.selectedIndex ? (
684
+ <Text color="cyan" bold>{'> '}</Text>
685
+ ) : (
686
+ <Text>{' '}</Text>
687
+ )}
688
+ <Text color={state.selectedPanel === 'targets' && i === state.selectedIndex ? 'white' : 'gray'}>
689
+ → {tgt.name} <Text dimColor>({tgt.type})</Text>
690
+ </Text>
691
+ </Text>
692
+ ))
693
+ )}
694
+ </Box>
695
+ </Box>
696
+ )}
697
+
698
+ {/* Code Preview Panel */}
699
+ {state.showCodePreview && state.codePreviewLines.length > 0 && (
700
+ <Box flexDirection="column" marginTop={1} borderStyle="single" borderColor="yellow" paddingX={1}>
701
+ <Text bold color="yellow">Code Preview:</Text>
702
+ {state.codePreviewLines.map((line, i) => (
703
+ <Text key={`code-${i}`} color={line.startsWith('>') ? 'white' : 'gray'}>
704
+ {line}
705
+ </Text>
706
+ ))}
707
+ </Box>
708
+ )}
709
+
710
+ {/* Error message */}
711
+ {state.error && (
712
+ <Box marginTop={1}>
713
+ <Text color="red">Error: {state.error}</Text>
714
+ </Box>
715
+ )}
716
+
717
+ {/* Search mode */}
718
+ {state.searchMode && (
719
+ <Box marginTop={1}>
720
+ <Text>Search: </Text>
721
+ <Text color="cyan">{state.searchQuery}</Text>
722
+ <Text color="gray">_</Text>
723
+ </Box>
724
+ )}
725
+
726
+ {/* Help footer */}
727
+ <Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
728
+ <Text color="gray">
729
+ ↑↓: Select | ←→: Panel | Enter: Open | Backspace: Back | /: Search | m: Modules | Space: Code | o: Editor | q: Quit
730
+ </Text>
731
+ </Box>
732
+ </Box>
733
+ );
734
+ }
735
+
736
+ // Helper function to extract NodeInfo with extended fields from a raw node
737
+ function extractNodeInfo(node: any): NodeInfo {
738
+ const nodeType = node.type || node.nodeType || 'UNKNOWN';
739
+ return {
740
+ id: node.id,
741
+ type: nodeType,
742
+ name: node.name || '<anonymous>',
743
+ file: node.file || '',
744
+ line: node.line,
745
+ // Function-specific fields
746
+ async: node.async,
747
+ exported: node.exported,
748
+ generator: node.generator,
749
+ arrowFunction: node.arrowFunction,
750
+ params: node.params,
751
+ paramTypes: node.paramTypes,
752
+ returnType: node.returnType,
753
+ signature: node.signature,
754
+ jsdocSummary: node.jsdocSummary,
755
+ };
756
+ }
757
+
758
+ // Helper functions
759
+ async function getCallers(backend: RFDBServerBackend, nodeId: string, limit: number): Promise<NodeInfo[]> {
760
+ const callers: NodeInfo[] = [];
761
+ const seen = new Set<string>();
762
+
763
+ try {
764
+ const callEdges = await backend.getIncomingEdges(nodeId, ['CALLS']);
765
+
766
+ for (const edge of callEdges) {
767
+ if (callers.length >= limit) break;
768
+
769
+ const callNode = await backend.getNode(edge.src);
770
+ if (!callNode) continue;
771
+
772
+ const containingFunc = await findContainingFunction(backend, callNode.id);
773
+
774
+ if (containingFunc && !seen.has(containingFunc.id)) {
775
+ seen.add(containingFunc.id);
776
+ callers.push(containingFunc);
777
+ }
778
+ }
779
+ } catch {
780
+ // Ignore
781
+ }
782
+
783
+ return callers;
784
+ }
785
+
786
+ async function getCallees(backend: RFDBServerBackend, nodeId: string, limit: number): Promise<NodeInfo[]> {
787
+ const callees: NodeInfo[] = [];
788
+ const seen = new Set<string>();
789
+
790
+ try {
791
+ const callNodes = await findCallsInFunction(backend, nodeId);
792
+
793
+ for (const callNode of callNodes) {
794
+ if (callees.length >= limit) break;
795
+
796
+ const callEdges = await backend.getOutgoingEdges(callNode.id, ['CALLS']);
797
+
798
+ for (const edge of callEdges) {
799
+ if (callees.length >= limit) break;
800
+
801
+ const targetNode = await backend.getNode(edge.dst);
802
+ if (!targetNode || seen.has(targetNode.id)) continue;
803
+
804
+ seen.add(targetNode.id);
805
+ callees.push(extractNodeInfo(targetNode));
806
+ }
807
+ }
808
+ } catch {
809
+ // Ignore
810
+ }
811
+
812
+ return callees;
813
+ }
814
+
815
+ async function findContainingFunction(backend: RFDBServerBackend, nodeId: string): Promise<NodeInfo | null> {
816
+ const visited = new Set<string>();
817
+ const queue: Array<{ id: string; depth: number }> = [{ id: nodeId, depth: 0 }];
818
+
819
+ while (queue.length > 0) {
820
+ const { id, depth } = queue.shift()!;
821
+ if (visited.has(id) || depth > 15) continue;
822
+ visited.add(id);
823
+
824
+ try {
825
+ const edges = await backend.getIncomingEdges(id, null);
826
+
827
+ for (const edge of edges) {
828
+ const edgeType = (edge as any).edgeType || (edge as any).type;
829
+ if (!['CONTAINS', 'HAS_SCOPE', 'DECLARES'].includes(edgeType)) continue;
830
+
831
+ const parent = await backend.getNode(edge.src);
832
+ if (!parent || visited.has(parent.id)) continue;
833
+
834
+ const parentType = (parent as any).type || (parent as any).nodeType;
835
+
836
+ if (parentType === 'FUNCTION' || parentType === 'CLASS' || parentType === 'MODULE') {
837
+ return extractNodeInfo(parent);
838
+ }
839
+
840
+ queue.push({ id: parent.id, depth: depth + 1 });
841
+ }
842
+ } catch {
843
+ // Ignore
844
+ }
845
+ }
846
+
847
+ return null;
848
+ }
849
+
850
+ async function findCallsInFunction(backend: RFDBServerBackend, nodeId: string): Promise<NodeInfo[]> {
851
+ const calls: NodeInfo[] = [];
852
+ const visited = new Set<string>();
853
+ const queue: Array<{ id: string; depth: number }> = [{ id: nodeId, depth: 0 }];
854
+
855
+ while (queue.length > 0) {
856
+ const { id, depth } = queue.shift()!;
857
+ if (visited.has(id) || depth > 10) continue;
858
+ visited.add(id);
859
+
860
+ try {
861
+ const edges = await backend.getOutgoingEdges(id, ['CONTAINS']);
862
+
863
+ for (const edge of edges) {
864
+ const child = await backend.getNode(edge.dst);
865
+ if (!child) continue;
866
+
867
+ const childType = (child as any).type || (child as any).nodeType;
868
+
869
+ if (childType === 'CALL') {
870
+ calls.push({
871
+ id: child.id,
872
+ type: 'CALL',
873
+ name: (child as any).name || '',
874
+ file: (child as any).file || '',
875
+ line: (child as any).line,
876
+ });
877
+ }
878
+
879
+ if (childType !== 'FUNCTION' && childType !== 'CLASS') {
880
+ queue.push({ id: child.id, depth: depth + 1 });
881
+ }
882
+ }
883
+ } catch {
884
+ // Ignore
885
+ }
886
+ }
887
+
888
+ return calls;
889
+ }
890
+
891
+ async function searchNode(backend: RFDBServerBackend, query: string): Promise<NodeInfo | null> {
892
+ const results = await searchNodes(backend, query, 1);
893
+ return results[0] || null;
894
+ }
895
+
896
+ async function searchNodes(backend: RFDBServerBackend, query: string, limit: number): Promise<NodeInfo[]> {
897
+ const results: NodeInfo[] = [];
898
+ const lowerQuery = query.toLowerCase();
899
+
900
+ for (const nodeType of ['FUNCTION', 'CLASS', 'MODULE']) {
901
+ for await (const node of backend.queryNodes({ nodeType: nodeType as any })) {
902
+ const name = ((node as any).name || '').toLowerCase();
903
+ if (name === lowerQuery || name.includes(lowerQuery)) {
904
+ results.push(extractNodeInfo(node));
905
+ if (results.length >= limit) return results;
906
+ }
907
+ }
908
+ }
909
+
910
+ return results;
911
+ }
912
+
913
+ async function getModules(backend: RFDBServerBackend, limit: number): Promise<NodeInfo[]> {
914
+ const modules: NodeInfo[] = [];
915
+
916
+ for await (const node of backend.queryNodes({ nodeType: 'MODULE' as any })) {
917
+ modules.push(extractNodeInfo(node));
918
+ if (modules.length >= limit) break;
919
+ }
920
+
921
+ // Sort by file path for better navigation
922
+ modules.sort((a, b) => a.file.localeCompare(b.file));
923
+
924
+ return modules;
925
+ }
926
+
927
+ async function getClassMembers(
928
+ backend: RFDBServerBackend,
929
+ classId: string
930
+ ): Promise<{ fields: NodeInfo[]; methods: NodeInfo[] }> {
931
+ const fields: NodeInfo[] = [];
932
+ const methods: NodeInfo[] = [];
933
+
934
+ try {
935
+ // Get children via CONTAINS edge
936
+ const edges = await backend.getOutgoingEdges(classId, ['CONTAINS']);
937
+
938
+ for (const edge of edges) {
939
+ const child = await backend.getNode(edge.dst);
940
+ if (!child) continue;
941
+
942
+ const childType = (child as any).type || (child as any).nodeType;
943
+ const nodeInfo = extractNodeInfo(child);
944
+
945
+ if (childType === 'FUNCTION') {
946
+ methods.push(nodeInfo);
947
+ } else if (childType === 'VARIABLE' || childType === 'PARAMETER') {
948
+ fields.push(nodeInfo);
949
+ }
950
+ }
951
+ } catch {
952
+ // Ignore
953
+ }
954
+
955
+ return { fields, methods };
956
+ }
957
+
958
+ async function getDataFlow(
959
+ backend: RFDBServerBackend,
960
+ varId: string
961
+ ): Promise<{ sources: NodeInfo[]; targets: NodeInfo[] }> {
962
+ const sources: NodeInfo[] = [];
963
+ const targets: NodeInfo[] = [];
964
+
965
+ try {
966
+ // Get incoming ASSIGNED_FROM edges (where data comes from)
967
+ const inEdges = await backend.getIncomingEdges(varId, ['ASSIGNED_FROM']);
968
+ for (const edge of inEdges) {
969
+ const src = await backend.getNode(edge.src);
970
+ if (src) {
971
+ sources.push(extractNodeInfo(src));
972
+ }
973
+ }
974
+
975
+ // Get outgoing ASSIGNED_FROM edges (where data flows to)
976
+ const outEdges = await backend.getOutgoingEdges(varId, ['ASSIGNED_FROM']);
977
+ for (const edge of outEdges) {
978
+ const tgt = await backend.getNode(edge.dst);
979
+ if (tgt) {
980
+ targets.push(extractNodeInfo(tgt));
981
+ }
982
+ }
983
+
984
+ // Also check DERIVES_FROM for additional flow info
985
+ const derivesIn = await backend.getIncomingEdges(varId, ['DERIVES_FROM']);
986
+ for (const edge of derivesIn) {
987
+ const src = await backend.getNode(edge.src);
988
+ if (src && !sources.find(s => s.id === src.id)) {
989
+ sources.push(extractNodeInfo(src));
990
+ }
991
+ }
992
+ } catch {
993
+ // Ignore
994
+ }
995
+
996
+ return { sources, targets };
997
+ }
998
+
999
+ async function findStartNode(backend: RFDBServerBackend, startName: string | null): Promise<NodeInfo | null> {
1000
+ if (startName) {
1001
+ return searchNode(backend, startName);
1002
+ }
1003
+
1004
+ // Find first function with most callers
1005
+ let bestNode: NodeInfo | null = null;
1006
+ let bestCallerCount = 0;
1007
+
1008
+ let checked = 0;
1009
+ for await (const node of backend.queryNodes({ nodeType: 'FUNCTION' as any })) {
1010
+ const incoming = await backend.getIncomingEdges(node.id, ['CALLS']);
1011
+ if (incoming.length > bestCallerCount) {
1012
+ bestCallerCount = incoming.length;
1013
+ bestNode = extractNodeInfo(node);
1014
+ }
1015
+
1016
+ checked++;
1017
+ if (checked >= 100) break; // Limit search
1018
+ }
1019
+
1020
+ return bestNode;
1021
+ }
1022
+
1023
+ // Command
1024
+ export const exploreCommand = new Command('explore')
1025
+ .description('Interactive graph navigation')
1026
+ .argument('[start]', 'Starting function name')
1027
+ .option('-p, --project <path>', 'Project path', '.')
1028
+ .action(async (start: string | undefined, options: { project: string }) => {
1029
+ const projectPath = resolve(options.project);
1030
+ const grafemaDir = join(projectPath, '.grafema');
1031
+ const dbPath = join(grafemaDir, 'graph.rfdb');
1032
+
1033
+ if (!existsSync(dbPath)) {
1034
+ exitWithError('No graph database found', ['Run: grafema analyze']);
1035
+ }
1036
+
1037
+ const backend = new RFDBServerBackend({ dbPath });
1038
+
1039
+ try {
1040
+ await backend.connect();
1041
+
1042
+ const startNode = await findStartNode(backend, start || null);
1043
+
1044
+ const { waitUntilExit } = render(
1045
+ <Explorer
1046
+ backend={backend}
1047
+ startNode={startNode}
1048
+ projectPath={projectPath}
1049
+ />
1050
+ );
1051
+
1052
+ await waitUntilExit();
1053
+ } finally {
1054
+ await backend.close();
1055
+ }
1056
+ });