@grafema/cli 0.2.3-beta → 0.2.5-beta

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 (84) hide show
  1. package/README.md +73 -0
  2. package/dist/cli.js +1 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/commands/analyze.d.ts +9 -0
  5. package/dist/commands/analyze.d.ts.map +1 -1
  6. package/dist/commands/analyze.js +136 -52
  7. package/dist/commands/analyze.js.map +1 -0
  8. package/dist/commands/check.d.ts +2 -6
  9. package/dist/commands/check.d.ts.map +1 -1
  10. package/dist/commands/check.js +32 -46
  11. package/dist/commands/check.js.map +1 -0
  12. package/dist/commands/coverage.js +1 -0
  13. package/dist/commands/coverage.js.map +1 -0
  14. package/dist/commands/doctor/checks.d.ts.map +1 -1
  15. package/dist/commands/doctor/checks.js +9 -5
  16. package/dist/commands/doctor/checks.js.map +1 -0
  17. package/dist/commands/doctor/output.js +1 -0
  18. package/dist/commands/doctor/output.js.map +1 -0
  19. package/dist/commands/doctor/types.js +1 -0
  20. package/dist/commands/doctor/types.js.map +1 -0
  21. package/dist/commands/doctor.js +1 -0
  22. package/dist/commands/doctor.js.map +1 -0
  23. package/dist/commands/explain.js +1 -0
  24. package/dist/commands/explain.js.map +1 -0
  25. package/dist/commands/explore.d.ts.map +1 -1
  26. package/dist/commands/explore.js +9 -4
  27. package/dist/commands/explore.js.map +1 -0
  28. package/dist/commands/get.d.ts.map +1 -1
  29. package/dist/commands/get.js +7 -0
  30. package/dist/commands/get.js.map +1 -0
  31. package/dist/commands/impact.js +1 -0
  32. package/dist/commands/impact.js.map +1 -0
  33. package/dist/commands/init.d.ts.map +1 -1
  34. package/dist/commands/init.js +7 -1
  35. package/dist/commands/init.js.map +1 -0
  36. package/dist/commands/ls.d.ts.map +1 -1
  37. package/dist/commands/ls.js +7 -0
  38. package/dist/commands/ls.js.map +1 -0
  39. package/dist/commands/overview.d.ts.map +1 -1
  40. package/dist/commands/overview.js +1 -0
  41. package/dist/commands/overview.js.map +1 -0
  42. package/dist/commands/query.d.ts.map +1 -1
  43. package/dist/commands/query.js +68 -1
  44. package/dist/commands/query.js.map +1 -0
  45. package/dist/commands/schema.js +1 -0
  46. package/dist/commands/schema.js.map +1 -0
  47. package/dist/commands/server.d.ts +2 -1
  48. package/dist/commands/server.d.ts.map +1 -1
  49. package/dist/commands/server.js +50 -3
  50. package/dist/commands/server.js.map +1 -0
  51. package/dist/commands/stats.js +1 -0
  52. package/dist/commands/stats.js.map +1 -0
  53. package/dist/commands/trace.js +1 -0
  54. package/dist/commands/trace.js.map +1 -0
  55. package/dist/commands/types.js +1 -0
  56. package/dist/commands/types.js.map +1 -0
  57. package/dist/utils/codePreview.js +1 -0
  58. package/dist/utils/codePreview.js.map +1 -0
  59. package/dist/utils/errorFormatter.js +1 -0
  60. package/dist/utils/errorFormatter.js.map +1 -0
  61. package/dist/utils/formatNode.js +1 -0
  62. package/dist/utils/formatNode.js.map +1 -0
  63. package/dist/utils/progressRenderer.d.ts +119 -0
  64. package/dist/utils/progressRenderer.d.ts.map +1 -0
  65. package/dist/utils/progressRenderer.js +245 -0
  66. package/dist/utils/progressRenderer.js.map +1 -0
  67. package/dist/utils/spinner.d.ts +39 -0
  68. package/dist/utils/spinner.d.ts.map +1 -0
  69. package/dist/utils/spinner.js +84 -0
  70. package/dist/utils/spinner.js.map +1 -0
  71. package/package.json +5 -4
  72. package/src/commands/analyze.ts +150 -55
  73. package/src/commands/check.ts +36 -68
  74. package/src/commands/doctor/checks.ts +8 -5
  75. package/src/commands/explore.tsx +8 -4
  76. package/src/commands/get.ts +8 -0
  77. package/src/commands/impact.ts +1 -1
  78. package/src/commands/init.ts +6 -2
  79. package/src/commands/ls.ts +8 -0
  80. package/src/commands/overview.ts +0 -4
  81. package/src/commands/query.ts +77 -1
  82. package/src/commands/server.ts +57 -3
  83. package/src/utils/progressRenderer.ts +288 -0
  84. package/src/utils/spinner.ts +94 -0
@@ -160,9 +160,10 @@ function Explorer({ backend, startNode, projectPath }: ExplorerProps) {
160
160
  }));
161
161
  }
162
162
  } catch (err) {
163
+ const message = err instanceof Error ? err.message : String(err);
163
164
  setState(s => ({
164
165
  ...s,
165
- error: (err as Error).message,
166
+ error: message,
166
167
  loading: false,
167
168
  }));
168
169
  }
@@ -387,9 +388,10 @@ function Explorer({ backend, startNode, projectPath }: ExplorerProps) {
387
388
  error: results.length === 0 ? `No results for "${query}"` : null,
388
389
  }));
389
390
  } catch (err) {
391
+ const message = err instanceof Error ? err.message : String(err);
390
392
  setState(s => ({
391
393
  ...s,
392
- error: (err as Error).message,
394
+ error: message,
393
395
  loading: false,
394
396
  }));
395
397
  }
@@ -408,9 +410,10 @@ function Explorer({ backend, startNode, projectPath }: ExplorerProps) {
408
410
  loading: false,
409
411
  }));
410
412
  } catch (err) {
413
+ const message = err instanceof Error ? err.message : String(err);
411
414
  setState(s => ({
412
415
  ...s,
413
- error: (err as Error).message,
416
+ error: message,
414
417
  loading: false,
415
418
  }));
416
419
  }
@@ -1010,7 +1013,8 @@ async function runBatchExplore(
1010
1013
  outputResults(callees, 'callees', useJson, projectPath, target);
1011
1014
  }
1012
1015
  } catch (err) {
1013
- exitWithError(`Explore failed: ${(err as Error).message}`);
1016
+ const message = err instanceof Error ? err.message : String(err);
1017
+ exitWithError(`Explore failed: ${message}`);
1014
1018
  }
1015
1019
  }
1016
1020
 
@@ -12,6 +12,7 @@ import { existsSync } from 'fs';
12
12
  import { RFDBServerBackend } from '@grafema/core';
13
13
  import { formatNodeDisplay } from '../utils/formatNode.js';
14
14
  import { exitWithError } from '../utils/errorFormatter.js';
15
+ import { Spinner } from '../utils/spinner.js';
15
16
 
16
17
  interface GetOptions {
17
18
  project: string;
@@ -66,11 +67,15 @@ Examples:
66
67
  const backend = new RFDBServerBackend({ dbPath });
67
68
  await backend.connect();
68
69
 
70
+ const spinner = new Spinner('Querying graph...');
71
+ spinner.start();
72
+
69
73
  try {
70
74
  // Retrieve node by semantic ID
71
75
  const node = await backend.getNode(semanticId);
72
76
 
73
77
  if (!node) {
78
+ spinner.stop();
74
79
  exitWithError('Node not found', [
75
80
  `ID: ${semanticId}`,
76
81
  'Try: grafema query "<name>" to search for nodes',
@@ -81,6 +86,8 @@ Examples:
81
86
  const incomingEdges = await backend.getIncomingEdges(semanticId, null);
82
87
  const outgoingEdges = await backend.getOutgoingEdges(semanticId, null);
83
88
 
89
+ spinner.stop();
90
+
84
91
  if (options.json) {
85
92
  await outputJSON(backend, node, incomingEdges, outgoingEdges);
86
93
  } else {
@@ -88,6 +95,7 @@ Examples:
88
95
  }
89
96
 
90
97
  } finally {
98
+ spinner.stop();
91
99
  await backend.close();
92
100
  }
93
101
  });
@@ -10,7 +10,7 @@ import { Command } from 'commander';
10
10
  import { resolve, join, dirname } from 'path';
11
11
  import { relative } from 'path';
12
12
  import { existsSync } from 'fs';
13
- import { RFDBServerBackend, findContainingFunction as findContainingFunctionCore, type CallerInfo } from '@grafema/core';
13
+ import { RFDBServerBackend, findContainingFunction as findContainingFunctionCore } from '@grafema/core';
14
14
  import { formatNodeDisplay, formatNodeInline } from '../utils/formatNode.js';
15
15
  import { exitWithError } from '../utils/errorFormatter.js';
16
16
 
@@ -8,7 +8,6 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
8
8
  import { spawn } from 'child_process';
9
9
  import { createInterface } from 'readline';
10
10
  import { fileURLToPath } from 'url';
11
- import { exitWithError } from '../utils/errorFormatter.js';
12
11
  import { stringify as stringifyYAML } from 'yaml';
13
12
  import { DEFAULT_CONFIG } from '@grafema/core';
14
13
 
@@ -75,7 +74,9 @@ function askYesNo(question: string): Promise<boolean> {
75
74
  function runAnalyze(projectPath: string): Promise<number> {
76
75
  return new Promise((resolve) => {
77
76
  const cliPath = join(__dirname, '..', 'cli.js');
78
- const child = spawn('node', [cliPath, 'analyze', projectPath], {
77
+ // Use process.execPath (absolute path to current Node binary) instead of
78
+ // 'node' to avoid PATH lookup failures when nvm isn't loaded in the shell.
79
+ const child = spawn(process.execPath, [cliPath, 'analyze', projectPath], {
79
80
  stdio: 'inherit', // Pass through all I/O for user to see progress
80
81
  });
81
82
  child.on('close', (code) => resolve(code ?? 1));
@@ -92,6 +93,9 @@ function printNextSteps(): void {
92
93
  console.log(' 1. Review config: code .grafema/config.yaml');
93
94
  console.log(' 2. Build graph: grafema analyze');
94
95
  console.log(' 3. Explore: grafema overview');
96
+ console.log('');
97
+ console.log('For AI-assisted setup, use the Grafema MCP server');
98
+ console.log('with the "onboard_project" prompt.');
95
99
  }
96
100
 
97
101
  /**
@@ -15,6 +15,7 @@ import { resolve, join, relative } from 'path';
15
15
  import { existsSync } from 'fs';
16
16
  import { RFDBServerBackend } from '@grafema/core';
17
17
  import { exitWithError } from '../utils/errorFormatter.js';
18
+ import { Spinner } from '../utils/spinner.js';
18
19
 
19
20
  interface LsOptions {
20
21
  project: string;
@@ -66,6 +67,9 @@ Discover available types:
66
67
  const backend = new RFDBServerBackend({ dbPath });
67
68
  await backend.connect();
68
69
 
70
+ const spinner = new Spinner('Querying graph...');
71
+ spinner.start();
72
+
69
73
  try {
70
74
  const limit = parseInt(options.limit, 10);
71
75
  const nodeType = options.type;
@@ -73,6 +77,7 @@ Discover available types:
73
77
  // Check if type exists in graph
74
78
  const typeCounts = await backend.countNodesByType();
75
79
  if (!typeCounts[nodeType]) {
80
+ spinner.stop();
76
81
  const availableTypes = Object.keys(typeCounts).sort();
77
82
  exitWithError(`No nodes of type "${nodeType}" found`, [
78
83
  'Available types:',
@@ -103,6 +108,8 @@ Discover available types:
103
108
  const totalCount = typeCounts[nodeType];
104
109
  const showing = nodes.length;
105
110
 
111
+ spinner.stop();
112
+
106
113
  if (options.json) {
107
114
  console.log(JSON.stringify({
108
115
  type: nodeType,
@@ -125,6 +132,7 @@ Discover available types:
125
132
  }
126
133
  }
127
134
  } finally {
135
+ spinner.stop();
128
136
  await backend.close();
129
137
  }
130
138
  });
@@ -8,10 +8,6 @@ import { existsSync } from 'fs';
8
8
  import { RFDBServerBackend } from '@grafema/core';
9
9
  import { exitWithError } from '../utils/errorFormatter.js';
10
10
 
11
- interface NodeStats {
12
- type: string;
13
- count: number;
14
- }
15
11
 
16
12
  export const overviewCommand = new Command('overview')
17
13
  .description('Show project overview and statistics')
@@ -15,6 +15,7 @@ import { existsSync } from 'fs';
15
15
  import { RFDBServerBackend, parseSemanticId, findCallsInFunction as findCallsInFunctionCore, findContainingFunction as findContainingFunctionCore } from '@grafema/core';
16
16
  import { formatNodeDisplay, formatNodeInline, formatLocation } from '../utils/formatNode.js';
17
17
  import { exitWithError } from '../utils/errorFormatter.js';
18
+ import { Spinner } from '../utils/spinner.js';
18
19
 
19
20
  interface QueryOptions {
20
21
  project: string;
@@ -130,9 +131,13 @@ Examples:
130
131
  const backend = new RFDBServerBackend({ dbPath });
131
132
  await backend.connect();
132
133
 
134
+ const spinner = new Spinner('Querying graph...');
135
+ spinner.start();
136
+
133
137
  try {
134
138
  // Raw Datalog mode
135
139
  if (options.raw) {
140
+ spinner.stop();
136
141
  await executeRawQuery(backend, pattern, options);
137
142
  return;
138
143
  }
@@ -159,6 +164,8 @@ Examples:
159
164
  // Find matching nodes
160
165
  const nodes = await findNodes(backend, query, limit);
161
166
 
167
+ spinner.stop();
168
+
162
169
  // Check if query has scope constraints for suggestion
163
170
  const hasScope = query.file !== null || query.scopes.length > 0;
164
171
 
@@ -218,6 +225,7 @@ Examples:
218
225
  }
219
226
 
220
227
  } finally {
228
+ spinner.stop();
221
229
  await backend.close();
222
230
  }
223
231
  });
@@ -254,6 +262,11 @@ function parsePattern(pattern: string): { type: string | null; name: string } {
254
262
  emit: 'socketio:emit',
255
263
  on: 'socketio:on',
256
264
  listener: 'socketio:on',
265
+ // Grafema internal
266
+ plugin: 'grafema:plugin',
267
+ // Property access aliases (REG-395)
268
+ property: 'PROPERTY_ACCESS',
269
+ prop: 'PROPERTY_ACCESS',
257
270
  };
258
271
 
259
272
  if (typeMap[typeWord]) {
@@ -551,7 +564,8 @@ async function findNodes(
551
564
  'http:request',
552
565
  'socketio:event',
553
566
  'socketio:emit',
554
- 'socketio:on'
567
+ 'socketio:on',
568
+ 'PROPERTY_ACCESS'
555
569
  ];
556
570
 
557
571
  for (const nodeType of searchTypes) {
@@ -606,6 +620,21 @@ async function findNodes(
606
620
  nodeInfo.handlerName = node.handlerName as string | undefined;
607
621
  }
608
622
 
623
+ // Include plugin-specific fields
624
+ if (nodeType === 'grafema:plugin') {
625
+ nodeInfo.phase = node.phase as string | undefined;
626
+ nodeInfo.priority = node.priority as number | undefined;
627
+ nodeInfo.builtin = node.builtin as boolean | undefined;
628
+ nodeInfo.createsNodes = node.createsNodes as string[] | undefined;
629
+ nodeInfo.createsEdges = node.createsEdges as string[] | undefined;
630
+ nodeInfo.dependencies = node.dependencies as string[] | undefined;
631
+ }
632
+
633
+ // Include objectName for PROPERTY_ACCESS nodes (REG-395)
634
+ if (nodeType === 'PROPERTY_ACCESS') {
635
+ nodeInfo.objectName = node.objectName as string | undefined;
636
+ }
637
+
609
638
  results.push(nodeInfo);
610
639
  if (results.length >= limit) break;
611
640
  }
@@ -735,6 +764,12 @@ async function displayNode(node: NodeInfo, projectPath: string, backend: RFDBSer
735
764
  return;
736
765
  }
737
766
 
767
+ // Special formatting for Grafema plugin nodes
768
+ if (node.type === 'grafema:plugin') {
769
+ console.log(formatPluginDisplay(node, projectPath));
770
+ return;
771
+ }
772
+
738
773
  console.log(formatNodeDisplay(node, { projectPath }));
739
774
 
740
775
  // Add scope context if present
@@ -890,6 +925,47 @@ function formatSocketIONodeDisplay(node: NodeInfo, projectPath: string): string
890
925
  return lines.join('\n');
891
926
  }
892
927
 
928
+ /**
929
+ * Format Grafema plugin node for display.
930
+ *
931
+ * Output:
932
+ * [grafema:plugin] HTTPConnectionEnricher
933
+ * Phase: ENRICHMENT (priority: 50)
934
+ * Creates: edges: INTERACTS_WITH, HTTP_RECEIVES
935
+ * Dependencies: ExpressRouteAnalyzer, FetchAnalyzer, ExpressResponseAnalyzer
936
+ * Source: packages/core/src/plugins/enrichment/HTTPConnectionEnricher.ts
937
+ */
938
+ function formatPluginDisplay(node: NodeInfo, projectPath: string): string {
939
+ const lines: string[] = [];
940
+
941
+ lines.push(`[${node.type}] ${node.name}`);
942
+
943
+ const phase = (node.phase as string) || 'unknown';
944
+ const priority = (node.priority as number) ?? 0;
945
+ lines.push(` Phase: ${phase} (priority: ${priority})`);
946
+
947
+ const createsNodes = (node.createsNodes as string[]) || [];
948
+ const createsEdges = (node.createsEdges as string[]) || [];
949
+ const createsParts: string[] = [];
950
+ if (createsNodes.length > 0) createsParts.push(`nodes: ${createsNodes.join(', ')}`);
951
+ if (createsEdges.length > 0) createsParts.push(`edges: ${createsEdges.join(', ')}`);
952
+ if (createsParts.length > 0) {
953
+ lines.push(` Creates: ${createsParts.join('; ')}`);
954
+ }
955
+
956
+ const deps = (node.dependencies as string[]) || [];
957
+ if (deps.length > 0) {
958
+ lines.push(` Dependencies: ${deps.join(', ')}`);
959
+ }
960
+
961
+ if (node.file) {
962
+ const relPath = relative(projectPath, node.file);
963
+ lines.push(` Source: ${relPath}`);
964
+ }
965
+
966
+ return lines.join('\n');
967
+ }
968
+
893
969
  /**
894
970
  * Execute raw Datalog query (backwards compat)
895
971
  */
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Server command - Manage RFDB server lifecycle
2
+ * Server command - Manage RFDB (Rega Flow Database) server lifecycle
3
3
  *
4
4
  * Provides explicit control over the RFDB server process:
5
5
  * grafema server start - Start detached server
6
6
  * grafema server stop - Stop server gracefully
7
7
  * grafema server status - Check if server is running
8
+ * grafema server graphql - Start GraphQL API server
8
9
  */
9
10
 
10
11
  import { Command } from 'commander';
@@ -13,7 +14,7 @@ import { existsSync, unlinkSync, writeFileSync, readFileSync } from 'fs';
13
14
  import { spawn } from 'child_process';
14
15
  import { fileURLToPath } from 'url';
15
16
  import { setTimeout as sleep } from 'timers/promises';
16
- import { RFDBClient, loadConfig } from '@grafema/core';
17
+ import { RFDBClient, loadConfig, RFDBServerBackend } from '@grafema/core';
17
18
  import { exitWithError } from '../utils/errorFormatter.js';
18
19
 
19
20
  const __filename = fileURLToPath(import.meta.url);
@@ -124,7 +125,7 @@ function getProjectPaths(projectPath: string) {
124
125
 
125
126
  // Create main server command with subcommands
126
127
  export const serverCommand = new Command('server')
127
- .description('Manage RFDB server lifecycle')
128
+ .description('Manage RFDB (Rega Flow Database) server lifecycle')
128
129
  .addHelpText('after', `
129
130
  Examples:
130
131
  grafema server start Start the RFDB server
@@ -395,3 +396,56 @@ serverCommand
395
396
  }
396
397
  }
397
398
  });
399
+
400
+ // grafema server graphql
401
+ serverCommand
402
+ .command('graphql')
403
+ .description('Start GraphQL API server (requires RFDB server running)')
404
+ .option('-p, --project <path>', 'Project path', '.')
405
+ .option('--port <number>', 'Port to listen on', '4000')
406
+ .option('--host <string>', 'Hostname to bind to', 'localhost')
407
+ .action(async (options: { project: string; port: string; host: string }) => {
408
+ const projectPath = resolve(options.project);
409
+ const { socketPath } = getProjectPaths(projectPath);
410
+
411
+ // Check if RFDB server is running
412
+ const status = await isServerRunning(socketPath);
413
+ if (!status.running) {
414
+ exitWithError('RFDB server not running', [
415
+ 'Start the server first: grafema server start',
416
+ 'Or run: grafema analyze (starts server automatically)'
417
+ ]);
418
+ }
419
+
420
+ // Create backend connection
421
+ const backend = new RFDBServerBackend({ socketPath });
422
+ await backend.connect();
423
+
424
+ // Import and start GraphQL server
425
+ const { startServer } = await import('@grafema/api');
426
+ const port = parseInt(options.port, 10);
427
+
428
+ console.log('Starting Grafema GraphQL API...');
429
+ console.log(` RFDB Socket: ${socketPath}`);
430
+ if (status.version) {
431
+ console.log(` RFDB Version: ${status.version}`);
432
+ }
433
+ console.log('');
434
+
435
+ const server = startServer({
436
+ backend,
437
+ port,
438
+ hostname: options.host,
439
+ });
440
+
441
+ // Handle shutdown
442
+ const shutdown = async () => {
443
+ console.log('\nShutting down GraphQL server...');
444
+ server.close();
445
+ await backend.close();
446
+ process.exit(0);
447
+ };
448
+
449
+ process.on('SIGINT', shutdown);
450
+ process.on('SIGTERM', shutdown);
451
+ });
@@ -0,0 +1,288 @@
1
+ /**
2
+ * ProgressRenderer - Formats and displays analysis progress for CLI.
3
+ *
4
+ * Consumes ProgressInfo events from Orchestrator and renders them as
5
+ * user-friendly progress output with phase tracking, elapsed time,
6
+ * and spinner animation.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const renderer = new ProgressRenderer({ isInteractive: true });
11
+ * orchestrator.run({
12
+ * onProgress: (info) => renderer.update(info),
13
+ * });
14
+ * console.log(renderer.finish(elapsed));
15
+ * ```
16
+ */
17
+
18
+ import type { ProgressInfo } from '@grafema/core';
19
+
20
+ /**
21
+ * Options for creating a ProgressRenderer instance.
22
+ */
23
+ export interface ProgressRendererOptions {
24
+ /** Whether output is to a TTY (enables spinner and line overwriting) */
25
+ isInteractive?: boolean;
26
+ /** Minimum milliseconds between display updates (default: 100) */
27
+ throttle?: number;
28
+ /** Custom write function for output (default: process.stdout.write) */
29
+ write?: (text: string) => void;
30
+ }
31
+
32
+ /**
33
+ * ProgressRenderer - Formats and displays analysis progress for CLI.
34
+ *
35
+ * Consumes ProgressInfo events from Orchestrator and renders them as
36
+ * user-friendly progress output with phase tracking, elapsed time,
37
+ * and spinner animation.
38
+ */
39
+ export class ProgressRenderer {
40
+ private phases: string[] = ['discovery', 'indexing', 'analysis', 'enrichment', 'validation'];
41
+ private currentPhaseIndex: number = -1;
42
+ private currentPhase: string = '';
43
+ private currentPlugin: string = '';
44
+ private message: string = '';
45
+ private totalFiles: number = 0;
46
+ private processedFiles: number = 0;
47
+ private servicesAnalyzed: number = 0;
48
+ private spinnerIndex: number = 0;
49
+ private isInteractive: boolean;
50
+ private startTime: number;
51
+ private lastDisplayTime: number = 0;
52
+ private displayThrottle: number;
53
+ private write: (text: string) => void;
54
+ private spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
55
+ private activePlugins: string[] = [];
56
+ private nodeCount: number = 0;
57
+ private edgeCount: number = 0;
58
+
59
+ constructor(options?: ProgressRendererOptions) {
60
+ this.isInteractive = options?.isInteractive ?? process.stdout.isTTY ?? false;
61
+ this.displayThrottle = options?.throttle ?? 100;
62
+ this.startTime = Date.now();
63
+ this.write = options?.write ?? ((text: string) => process.stdout.write(text));
64
+ }
65
+
66
+ /**
67
+ * Process a progress event from Orchestrator.
68
+ * Updates internal state and displays formatted output if throttle allows.
69
+ */
70
+ update(info: ProgressInfo): void {
71
+ // Update phase tracking
72
+ if (info.phase && info.phase !== this.currentPhase) {
73
+ this.currentPhase = info.phase;
74
+ const idx = this.phases.indexOf(info.phase);
75
+ if (idx !== -1) {
76
+ this.currentPhaseIndex = idx;
77
+ }
78
+ // Reset phase-specific state
79
+ this.activePlugins = [];
80
+ }
81
+
82
+ // Update state from progress info
83
+ if (info.currentPlugin !== undefined) {
84
+ this.currentPlugin = info.currentPlugin;
85
+ // Track active plugins for enrichment/validation display
86
+ if ((this.currentPhase === 'enrichment' || this.currentPhase === 'validation') &&
87
+ info.currentPlugin && !this.activePlugins.includes(info.currentPlugin)) {
88
+ this.activePlugins.push(info.currentPlugin);
89
+ }
90
+ }
91
+ if (info.message !== undefined) {
92
+ this.message = info.message;
93
+ }
94
+ if (info.totalFiles !== undefined) {
95
+ this.totalFiles = info.totalFiles;
96
+ }
97
+ if (info.processedFiles !== undefined) {
98
+ this.processedFiles = info.processedFiles;
99
+ }
100
+ if (info.servicesAnalyzed !== undefined) {
101
+ this.servicesAnalyzed = info.servicesAnalyzed;
102
+ }
103
+
104
+ // Update spinner
105
+ this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
106
+
107
+ // Check throttling
108
+ const now = Date.now();
109
+ if (now - this.lastDisplayTime < this.displayThrottle) {
110
+ return;
111
+ }
112
+ this.lastDisplayTime = now;
113
+
114
+ this.display();
115
+ }
116
+
117
+ /**
118
+ * Update graph statistics (called separately from progress events).
119
+ * This allows real-time node/edge count updates.
120
+ */
121
+ setStats(nodeCount: number, edgeCount: number): void {
122
+ this.nodeCount = nodeCount;
123
+ this.edgeCount = edgeCount;
124
+ }
125
+
126
+ /**
127
+ * Format and display current state to console.
128
+ */
129
+ private display(): void {
130
+ const output = this.formatOutput();
131
+
132
+ if (this.isInteractive) {
133
+ // TTY mode: overwrite previous line, pad with spaces to clear old content
134
+ const padded = output.padEnd(80, ' ');
135
+ this.write(`\r${padded}`);
136
+ } else {
137
+ // Non-TTY mode: append newline
138
+ this.write(`${output}\n`);
139
+ }
140
+ }
141
+
142
+ private formatOutput(): string {
143
+ if (this.isInteractive) {
144
+ return this.formatInteractive();
145
+ } else {
146
+ return this.formatNonInteractive();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Format elapsed time as human-readable string.
152
+ */
153
+ private formatElapsed(): string {
154
+ const elapsed = (Date.now() - this.startTime) / 1000;
155
+ if (elapsed < 60) {
156
+ return `${elapsed.toFixed(1)}s`;
157
+ }
158
+ const minutes = Math.floor(elapsed / 60);
159
+ const seconds = Math.floor(elapsed % 60);
160
+ return `${minutes}m${seconds}s`;
161
+ }
162
+
163
+ private formatInteractive(): string {
164
+ const spinner = this.spinnerFrames[this.spinnerIndex];
165
+ const elapsed = this.formatElapsed();
166
+ const phaseLabel = this.getPhaseLabel();
167
+ const progress = this.formatPhaseProgress();
168
+ const stats = this.formatStats();
169
+
170
+ // Format: ⠋ [3/5] Analysis... 150/4047 modules | 12.5s | 1.2M nodes
171
+ return `${spinner} ${phaseLabel}${progress} | ${elapsed}${stats}`;
172
+ }
173
+
174
+ private formatNonInteractive(): string {
175
+ const elapsed = this.formatElapsed();
176
+ return `[${this.currentPhase}] ${this.message || this.formatPhaseProgress()} (${elapsed})`;
177
+ }
178
+
179
+ /**
180
+ * Format node/edge counts if available.
181
+ */
182
+ private formatStats(): string {
183
+ if (this.nodeCount === 0 && this.edgeCount === 0) {
184
+ return '';
185
+ }
186
+ const nodes = this.formatNumber(this.nodeCount);
187
+ const edges = this.formatNumber(this.edgeCount);
188
+ return ` | ${nodes} nodes, ${edges} edges`;
189
+ }
190
+
191
+ /**
192
+ * Format large numbers with K/M suffix.
193
+ */
194
+ private formatNumber(n: number): string {
195
+ if (n >= 1_000_000) {
196
+ return `${(n / 1_000_000).toFixed(1)}M`;
197
+ }
198
+ if (n >= 1_000) {
199
+ return `${(n / 1_000).toFixed(1)}K`;
200
+ }
201
+ return String(n);
202
+ }
203
+
204
+ /**
205
+ * Get formatted phase label with number, e.g., "[3/5] Analysis..."
206
+ */
207
+ private getPhaseLabel(): string {
208
+ const phaseNum = this.currentPhaseIndex + 1;
209
+ const totalPhases = this.phases.length;
210
+ const phaseName = this.currentPhase.charAt(0).toUpperCase() + this.currentPhase.slice(1);
211
+ return `[${phaseNum}/${totalPhases}] ${phaseName}...`;
212
+ }
213
+
214
+ /**
215
+ * Format progress details based on current phase.
216
+ */
217
+ private formatPhaseProgress(): string {
218
+ switch (this.currentPhase) {
219
+ case 'discovery':
220
+ if (this.servicesAnalyzed > 0) {
221
+ return ` ${this.servicesAnalyzed} services found`;
222
+ }
223
+ return '';
224
+ case 'indexing':
225
+ case 'analysis':
226
+ if (this.totalFiles > 0) {
227
+ return ` ${this.processedFiles}/${this.totalFiles} modules`;
228
+ }
229
+ return '';
230
+ case 'enrichment':
231
+ case 'validation':
232
+ if (this.activePlugins.length > 0) {
233
+ return ` (${this.formatPluginList(this.activePlugins)})`;
234
+ }
235
+ return '';
236
+ default:
237
+ return '';
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Format plugin list, truncating if more than 3 plugins.
243
+ */
244
+ private formatPluginList(plugins: string[]): string {
245
+ if (plugins.length <= 3) {
246
+ return plugins.join(', ');
247
+ }
248
+ // Truncate to 3 plugins + "..."
249
+ return plugins.slice(0, 3).join(', ') + ', ...';
250
+ }
251
+
252
+ /**
253
+ * Get final summary message after analysis complete.
254
+ * @param durationSeconds - Total duration of analysis
255
+ * @returns Formatted completion message
256
+ */
257
+ finish(durationSeconds: number): string {
258
+ return `Analysis complete in ${durationSeconds.toFixed(2)}s`;
259
+ }
260
+
261
+ /**
262
+ * Expose internal state for testing.
263
+ * @internal
264
+ */
265
+ getState(): {
266
+ phaseIndex: number;
267
+ phase: string;
268
+ processedFiles: number;
269
+ totalFiles: number;
270
+ servicesAnalyzed: number;
271
+ spinnerIndex: number;
272
+ activePlugins: string[];
273
+ nodeCount: number;
274
+ edgeCount: number;
275
+ } {
276
+ return {
277
+ phaseIndex: this.currentPhaseIndex,
278
+ phase: this.currentPhase,
279
+ processedFiles: this.processedFiles,
280
+ totalFiles: this.totalFiles,
281
+ servicesAnalyzed: this.servicesAnalyzed,
282
+ spinnerIndex: this.spinnerIndex,
283
+ activePlugins: [...this.activePlugins],
284
+ nodeCount: this.nodeCount,
285
+ edgeCount: this.edgeCount,
286
+ };
287
+ }
288
+ }