activerecord-graph-extractor 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.
@@ -0,0 +1,627 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'tty-progressbar'
5
+ require 'tty-spinner'
6
+ require 'tty-tree'
7
+ require 'pastel'
8
+ require 'tty-prompt'
9
+
10
+ module ActiveRecordGraphExtractor
11
+ class CLI < Thor
12
+ include Thor::Actions
13
+
14
+ def self.exit_on_failure?
15
+ true
16
+ end
17
+
18
+ desc "version", "Show version information"
19
+ def version
20
+ puts "ActiveRecord Graph Extractor v#{ActiveRecordGraphExtractor::VERSION}"
21
+ end
22
+
23
+ desc "extract MODEL_CLASS ID", "Extract a record and its relationships to JSON"
24
+ option :output, aliases: :o, required: true, desc: "Output file path"
25
+ option :max_depth, type: :numeric, default: 5, desc: "Maximum relationship depth"
26
+ option :include_relationships, type: :array, desc: "Specific relationships to include"
27
+ option :exclude_relationships, type: :array, desc: "Relationships to exclude"
28
+ option :include_models, type: :array, desc: "Specific models to include"
29
+ option :exclude_models, type: :array, desc: "Models to exclude"
30
+ option :batch_size, type: :numeric, default: 1000, desc: "Batch size for processing"
31
+ option :progress, type: :boolean, default: false, desc: "Show progress visualization"
32
+ option :show_graph, type: :boolean, default: false, desc: "Show dependency graph"
33
+ option :stream, type: :boolean, default: false, desc: "Use streaming JSON for large datasets"
34
+
35
+ def extract(model_class_name, id)
36
+ setup_colors
37
+
38
+ begin
39
+ model_class = model_class_name.constantize
40
+ object = model_class.find(id)
41
+ rescue NameError
42
+ error_exit("Model #{model_class_name} not found")
43
+ rescue ActiveRecord::RecordNotFound
44
+ error_exit("#{model_class_name} with ID #{id} not found")
45
+ end
46
+
47
+ config = build_config_from_options(options)
48
+
49
+ if options[:progress]
50
+ config[:on_progress] = method(:handle_progress_update)
51
+ setup_progress_bars
52
+ end
53
+
54
+ @pastel.bright_blue("🔍 Analyzing relationships...")
55
+ puts
56
+
57
+ extractor = Extractor.new(root_object: object, config: config)
58
+
59
+ if options[:show_graph]
60
+ show_dependency_graph(extractor)
61
+ end
62
+
63
+ result = nil
64
+ with_spinner("Extracting records") do |spinner|
65
+ result = extractor.extract_to_file(options[:output])
66
+ spinner.success("(#{result.duration_human})")
67
+ end
68
+
69
+ print_extraction_summary(result)
70
+
71
+ rescue => e
72
+ error_exit("Extraction failed: #{e.message}")
73
+ end
74
+
75
+ desc "import FILE", "Import records from a JSON file"
76
+ option :batch_size, type: :numeric, default: 1000, desc: "Batch size for processing"
77
+ option :skip_validations, type: :boolean, default: false, desc: "Skip ActiveRecord validations"
78
+ option :dry_run, type: :boolean, default: false, desc: "Preview import without saving"
79
+ option :progress, type: :boolean, default: false, desc: "Show progress visualization"
80
+ option :show_graph, type: :boolean, default: false, desc: "Show dependency graph during import"
81
+
82
+ def import(file_path)
83
+ setup_colors
84
+
85
+ unless File.exist?(file_path)
86
+ error_exit("File not found: #{file_path}")
87
+ end
88
+
89
+ config = build_config_from_options(options)
90
+
91
+ if options[:progress]
92
+ config[:on_progress] = method(:handle_import_progress_update)
93
+ setup_import_progress_bars
94
+ end
95
+
96
+ importer = Importer.new(config: config)
97
+
98
+ result = nil
99
+ if options[:dry_run]
100
+ @pastel.yellow("🔍 Performing dry run...")
101
+ puts
102
+ else
103
+ @pastel.bright_blue("📦 Importing records...")
104
+ puts
105
+ end
106
+
107
+ with_spinner("Processing import") do |spinner|
108
+ result = importer.import_from_file(file_path)
109
+ spinner.success("(#{result.duration_human})")
110
+ end
111
+
112
+ print_import_summary(result)
113
+
114
+ rescue => e
115
+ error_exit("Import failed: #{e.message}")
116
+ end
117
+
118
+ desc "analyze FILE", "Analyze a JSON export file"
119
+ def analyze(file_path)
120
+ setup_colors
121
+
122
+ unless File.exist?(file_path)
123
+ error_exit("File not found: #{file_path}")
124
+ end
125
+
126
+ serializer = JSONSerializer.new
127
+
128
+ data = nil
129
+ with_spinner("Loading file") do |spinner|
130
+ data = serializer.deserialize_from_file(file_path)
131
+ spinner.success
132
+ end
133
+
134
+ print_analysis(data, file_path)
135
+ end
136
+
137
+ desc "extract_to_s3 MODEL_CLASS ID", "Extract a record and upload directly to S3"
138
+ option :bucket, aliases: :b, required: true, desc: "S3 bucket name"
139
+ option :key, aliases: :k, desc: "S3 object key (auto-generated if not provided)"
140
+ option :region, default: 'us-east-1', desc: "AWS region"
141
+ option :max_depth, type: :numeric, default: 5, desc: "Maximum relationship depth"
142
+ option :include_relationships, type: :array, desc: "Specific relationships to include"
143
+ option :exclude_relationships, type: :array, desc: "Relationships to exclude"
144
+ option :include_models, type: :array, desc: "Specific models to include"
145
+ option :exclude_models, type: :array, desc: "Models to exclude"
146
+ option :progress, type: :boolean, default: false, desc: "Show progress visualization"
147
+
148
+ def extract_to_s3(model_class_name, id)
149
+ setup_colors
150
+
151
+ begin
152
+ model_class = model_class_name.constantize
153
+ object = model_class.find(id)
154
+ rescue NameError
155
+ error_exit("Model #{model_class_name} not found")
156
+ rescue ActiveRecord::RecordNotFound
157
+ error_exit("#{model_class_name} with ID #{id} not found")
158
+ end
159
+
160
+ @pastel.bright_blue("🔍 Extracting and uploading to S3...")
161
+ puts
162
+
163
+ extractor = Extractor.new
164
+ extraction_options = build_extraction_options_from_options(options)
165
+
166
+ result = nil
167
+ with_spinner("Extracting and uploading") do |spinner|
168
+ result = extractor.extract_and_upload_to_s3(
169
+ object,
170
+ bucket_name: options[:bucket],
171
+ s3_key: options[:key],
172
+ region: options[:region],
173
+ options: extraction_options
174
+ )
175
+ spinner.success
176
+ end
177
+
178
+ print_s3_extraction_summary(result)
179
+
180
+ rescue => e
181
+ error_exit("S3 extraction failed: #{e.message}")
182
+ end
183
+
184
+ desc "s3_list", "List extraction files in S3 bucket"
185
+ option :bucket, aliases: :b, required: true, desc: "S3 bucket name"
186
+ option :prefix, aliases: :p, desc: "S3 key prefix to filter results"
187
+ option :region, default: 'us-east-1', desc: "AWS region"
188
+ option :max_keys, type: :numeric, default: 50, desc: "Maximum number of files to list"
189
+
190
+ def s3_list
191
+ setup_colors
192
+
193
+ @pastel.bright_blue("📋 Listing S3 files...")
194
+ puts
195
+
196
+ s3_client = S3Client.new(bucket_name: options[:bucket], region: options[:region])
197
+
198
+ files = nil
199
+ with_spinner("Fetching file list") do |spinner|
200
+ files = s3_client.list_files(
201
+ prefix: options[:prefix],
202
+ max_keys: options[:max_keys]
203
+ )
204
+ spinner.success
205
+ end
206
+
207
+ print_s3_file_list(files)
208
+
209
+ rescue => e
210
+ error_exit("S3 list failed: #{e.message}")
211
+ end
212
+
213
+ desc "s3_download S3_KEY", "Download an extraction file from S3"
214
+ option :bucket, aliases: :b, required: true, desc: "S3 bucket name"
215
+ option :output, aliases: :o, desc: "Local output file path"
216
+ option :region, default: 'us-east-1', desc: "AWS region"
217
+
218
+ def s3_download(s3_key)
219
+ setup_colors
220
+
221
+ @pastel.bright_blue("⬇️ Downloading from S3...")
222
+ puts
223
+
224
+ s3_client = S3Client.new(bucket_name: options[:bucket], region: options[:region])
225
+
226
+ result = nil
227
+ with_spinner("Downloading file") do |spinner|
228
+ result = s3_client.download_file(s3_key, options[:output])
229
+ spinner.success
230
+ end
231
+
232
+ print_s3_download_summary(result)
233
+
234
+ rescue => e
235
+ error_exit("S3 download failed: #{e.message}")
236
+ end
237
+
238
+ desc "dry_run MODEL_CLASS ID", "Analyze what would be extracted without performing the actual extraction"
239
+ option :max_depth, type: :numeric, desc: "Maximum relationship depth to analyze"
240
+ option :output, aliases: :o, desc: "Output file for analysis report (JSON format)"
241
+
242
+ def dry_run(model_class_name, id)
243
+ setup_colors
244
+
245
+ @pastel.bright_blue("🔍 Performing dry run analysis...")
246
+ puts
247
+ puts " Model: #{@pastel.cyan(model_class_name)}"
248
+ puts " ID: #{@pastel.cyan(id)}"
249
+ puts " Max Depth: #{@pastel.cyan(options[:max_depth] || 'default')}"
250
+ puts
251
+
252
+ begin
253
+ model_class = model_class_name.constantize
254
+ record = model_class.find(id)
255
+
256
+ extraction_options = build_extraction_options_from_options(options)
257
+
258
+ extractor = Extractor.new
259
+ analysis = nil
260
+
261
+ with_spinner("Analyzing object graph") do |spinner|
262
+ analysis = extractor.dry_run(record, extraction_options)
263
+ spinner.success
264
+ end
265
+
266
+ if options[:output]
267
+ File.write(options[:output], JSON.pretty_generate(analysis))
268
+ puts @pastel.green("📄 Analysis report saved to: #{options[:output]}")
269
+ puts
270
+ end
271
+
272
+ print_dry_run_analysis(analysis)
273
+
274
+ rescue NameError => e
275
+ error_exit("Model not found: #{model_class_name}. Make sure the model class exists and is loaded.")
276
+ rescue ActiveRecord::RecordNotFound => e
277
+ error_exit("Record not found: #{model_class_name} with ID #{id}")
278
+ rescue => e
279
+ error_exit("Analysis failed: #{e.message}")
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ def setup_colors
286
+ @pastel = Pastel.new
287
+ end
288
+
289
+ def build_config_from_options(options)
290
+ config = {}
291
+
292
+ config[:max_depth] = options[:max_depth] if options[:max_depth]
293
+ config[:include_relationships] = options[:include_relationships] if options[:include_relationships]
294
+ config[:exclude_relationships] = options[:exclude_relationships] if options[:exclude_relationships]
295
+ config[:include_models] = options[:include_models] if options[:include_models]
296
+ config[:exclude_models] = options[:exclude_models] if options[:exclude_models]
297
+ config[:batch_size] = options[:batch_size] if options[:batch_size]
298
+ config[:stream_json] = options[:stream] if options.key?(:stream)
299
+ config[:skip_validations] = options[:skip_validations] if options.key?(:skip_validations)
300
+ config[:dry_run] = options[:dry_run] if options.key?(:dry_run)
301
+
302
+ config
303
+ end
304
+
305
+ def setup_progress_bars
306
+ @model_progress_bars = {}
307
+ @main_progress_bar = nil
308
+ end
309
+
310
+ def setup_import_progress_bars
311
+ @import_progress_bars = {}
312
+ @main_import_progress_bar = nil
313
+ end
314
+
315
+ def handle_progress_update(stats)
316
+ model = stats[:model]
317
+ phase = stats[:phase]
318
+
319
+ # Update main progress bar
320
+ if @main_progress_bar
321
+ @main_progress_bar.current = stats[:current]
322
+ @main_progress_bar.total = stats[:total] if stats[:total] > 0
323
+ elsif stats[:total] && stats[:total] > 0
324
+ @main_progress_bar = TTY::ProgressBar.new(
325
+ "#{@pastel.bright_blue('Overall')} [:bar] :percent :current/:total (:rate/s) :eta",
326
+ total: stats[:total],
327
+ bar_format: :block
328
+ )
329
+ end
330
+
331
+ # Update model-specific progress bar
332
+ if model && stats[:model_progress] && stats[:model_progress][model]
333
+ model_stats = stats[:model_progress][model]
334
+
335
+ unless @model_progress_bars[model]
336
+ if model_stats[:total] > 0
337
+ color = get_model_color(model)
338
+ @model_progress_bars[model] = TTY::ProgressBar.new(
339
+ "#{@pastel.decorate(model.ljust(12), color)} [:bar] :percent (:current/:total)",
340
+ total: model_stats[:total],
341
+ bar_format: :block
342
+ )
343
+ end
344
+ end
345
+
346
+ if @model_progress_bars[model]
347
+ @model_progress_bars[model].current = model_stats[:current]
348
+
349
+ if stats[:completed]
350
+ @model_progress_bars[model].finish
351
+ @model_progress_bars[model] = @pastel.green("#{model.ljust(12)} ✅ Complete")
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ def handle_import_progress_update(stats)
358
+ phase = stats[:phase]
359
+
360
+ case phase
361
+ when "Importing records"
362
+ handle_progress_update(stats)
363
+ else
364
+ # Handle other phases
365
+ if stats[:total] && stats[:total] > 0
366
+ percentage = (stats[:current].to_f / stats[:total] * 100).round(1)
367
+ print "\r#{@pastel.bright_blue(phase)}: #{percentage}%"
368
+ end
369
+ end
370
+ end
371
+
372
+ def get_model_color(model)
373
+ colors = [:cyan, :magenta, :yellow, :green, :red, :blue]
374
+ colors[model.hash % colors.size]
375
+ end
376
+
377
+ def show_dependency_graph(extractor)
378
+ # This would require access to the dependency graph from the extractor
379
+ # For now, show a simple tree structure
380
+ puts @pastel.bright_blue("📊 Dependency Analysis")
381
+ puts
382
+
383
+ # Sample tree structure - in real implementation, this would be built from actual data
384
+ tree_data = {
385
+ "Order (root)" => {
386
+ "User" => {},
387
+ "Partner" => {},
388
+ "Products" => {
389
+ "Photos" => {},
390
+ "Categories" => {}
391
+ },
392
+ "Address" => {}
393
+ }
394
+ }
395
+
396
+ tree = TTY::Tree.new(tree_data)
397
+ puts tree.render
398
+ puts
399
+ end
400
+
401
+ def with_spinner(message)
402
+ spinner = TTY::Spinner.new("[:spinner] #{message}...", format: :dots)
403
+ spinner.auto_spin
404
+
405
+ result = yield(spinner)
406
+
407
+ spinner.stop
408
+ result
409
+ end
410
+
411
+ def print_extraction_summary(result)
412
+ puts
413
+ puts @pastel.bright_green("✅ Extraction completed successfully!")
414
+ puts
415
+ puts "📊 " + @pastel.bold("Summary:")
416
+ puts " Total records: #{@pastel.cyan(result.total_records)}"
417
+ puts " Models: #{@pastel.cyan(result.models.join(', '))}"
418
+ puts " File size: #{@pastel.cyan(result.file_size_human)}"
419
+ puts " Duration: #{@pastel.cyan(result.duration_human)}"
420
+ puts " Output: #{@pastel.cyan(result.file_path)}"
421
+ puts
422
+ end
423
+
424
+ def print_import_summary(result)
425
+ puts
426
+
427
+ if result.dry_run
428
+ puts @pastel.bright_yellow("🔍 Dry run completed successfully!")
429
+ else
430
+ puts @pastel.bright_green("✅ Import completed successfully!")
431
+ end
432
+
433
+ puts
434
+ puts "📊 " + @pastel.bold("Summary:")
435
+ puts " Total records: #{@pastel.cyan(result.total_records)}"
436
+ puts " Models imported: #{@pastel.cyan(result.models_imported.join(', '))}"
437
+ puts " Duration: #{@pastel.cyan(result.duration_human)}"
438
+ puts " Speed: #{@pastel.cyan("#{result.records_per_second} records/sec")}"
439
+
440
+ if result.mapping_statistics
441
+ puts " ID mappings: #{@pastel.cyan(result.mapping_statistics[:total_mappings])}"
442
+ end
443
+
444
+ puts
445
+ end
446
+
447
+ def print_analysis(data, file_path)
448
+ metadata = data['metadata']
449
+ records = data['records']
450
+
451
+ puts
452
+ puts @pastel.bright_blue("📋 File Analysis: #{File.basename(file_path)}")
453
+ puts
454
+
455
+ puts @pastel.bold("Metadata:")
456
+ puts " Root model: #{@pastel.cyan(metadata['root_model'])}"
457
+ puts " Root ID: #{@pastel.cyan(metadata['root_id'])}"
458
+ puts " Extracted: #{@pastel.cyan(metadata['extracted_at'])}"
459
+ puts " Schema version: #{@pastel.cyan(metadata['schema_version'])}"
460
+ puts " Total records: #{@pastel.cyan(metadata['total_records'])}"
461
+ puts
462
+
463
+ puts @pastel.bold("Models and record counts:")
464
+ metadata['model_counts'].each do |model, count|
465
+ puts " #{model.ljust(20)}: #{@pastel.cyan(count)}"
466
+ end
467
+ puts
468
+
469
+ file_size = File.size(file_path)
470
+ if file_size < 1024 * 1024
471
+ size_human = "#{(file_size / 1024.0).round(1)} KB"
472
+ else
473
+ size_human = "#{(file_size / (1024.0 * 1024)).round(1)} MB"
474
+ end
475
+
476
+ puts @pastel.bold("File information:")
477
+ puts " Size: #{@pastel.cyan(size_human)}"
478
+ puts " Path: #{@pastel.cyan(file_path)}"
479
+ puts
480
+ end
481
+
482
+ def build_extraction_options_from_options(options)
483
+ extraction_options = {}
484
+ extraction_options[:max_depth] = options[:max_depth] if options[:max_depth]
485
+ extraction_options[:include_relationships] = options[:include_relationships] if options[:include_relationships]
486
+ extraction_options[:exclude_relationships] = options[:exclude_relationships] if options[:exclude_relationships]
487
+ extraction_options[:include_models] = options[:include_models] if options[:include_models]
488
+ extraction_options[:exclude_models] = options[:exclude_models] if options[:exclude_models]
489
+ extraction_options
490
+ end
491
+
492
+ def print_s3_extraction_summary(result)
493
+ puts
494
+ puts @pastel.bright_green("✅ S3 extraction completed successfully!")
495
+ puts
496
+ puts "📊 " + @pastel.bold("Summary:")
497
+ puts " Total records: #{@pastel.cyan(result['metadata']['total_records'])}"
498
+ puts " Models: #{@pastel.cyan(result['metadata']['models_extracted'].join(', '))}"
499
+ puts " Duration: #{@pastel.cyan("#{result['metadata']['duration_seconds']}s")}"
500
+ puts
501
+ puts "☁️ " + @pastel.bold("S3 Upload:")
502
+ puts " Bucket: #{@pastel.cyan(result['s3_upload'][:bucket])}"
503
+ puts " Key: #{@pastel.cyan(result['s3_upload'][:key])}"
504
+ puts " Size: #{@pastel.cyan(format_file_size(result['s3_upload'][:size]))}"
505
+ puts " URL: #{@pastel.cyan(result['s3_upload'][:url])}"
506
+ puts
507
+ end
508
+
509
+ def print_s3_file_list(files)
510
+ puts
511
+ if files.empty?
512
+ puts @pastel.yellow("No files found")
513
+ return
514
+ end
515
+
516
+ puts @pastel.bright_blue("📋 S3 Files (#{files.size} found):")
517
+ puts
518
+
519
+ files.each do |file|
520
+ puts "#{@pastel.cyan(file[:key])}"
521
+ puts " Size: #{@pastel.dim(format_file_size(file[:size]))}"
522
+ puts " Modified: #{@pastel.dim(file[:last_modified].strftime('%Y-%m-%d %H:%M:%S'))}"
523
+ puts
524
+ end
525
+ end
526
+
527
+ def print_s3_download_summary(result)
528
+ puts
529
+ puts @pastel.bright_green("✅ S3 download completed successfully!")
530
+ puts
531
+ puts "📊 " + @pastel.bold("Summary:")
532
+ puts " S3 Key: #{@pastel.cyan(result[:key])}"
533
+ puts " Local Path: #{@pastel.cyan(result[:local_path])}"
534
+ puts " Size: #{@pastel.cyan(format_file_size(result[:size]))}"
535
+ puts
536
+ end
537
+
538
+ def format_file_size(size_bytes)
539
+ if size_bytes < 1024
540
+ "#{size_bytes} B"
541
+ elsif size_bytes < 1024 * 1024
542
+ "#{(size_bytes / 1024.0).round(1)} KB"
543
+ elsif size_bytes < 1024 * 1024 * 1024
544
+ "#{(size_bytes / (1024.0 * 1024)).round(1)} MB"
545
+ else
546
+ "#{(size_bytes / (1024.0 * 1024 * 1024)).round(1)} GB"
547
+ end
548
+ end
549
+
550
+ def print_dry_run_analysis(analysis)
551
+ puts
552
+ puts @pastel.bright_green("✅ Dry run analysis completed!")
553
+ puts
554
+
555
+ # Basic info
556
+ puts "📊 " + @pastel.bold("Analysis Summary:")
557
+ puts " Analysis time: #{@pastel.cyan("#{analysis['analysis_time']} seconds")}"
558
+ puts " Root objects: #{@pastel.cyan(analysis['root_objects']['count'])}"
559
+ puts " Models involved: #{@pastel.cyan(analysis['extraction_scope']['total_models'])}"
560
+ puts " Total estimated records: #{@pastel.cyan(analysis['extraction_scope']['total_estimated_records'].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse)}"
561
+ puts " Estimated file size: #{@pastel.cyan(analysis['estimated_file_size']['human_readable'])}"
562
+ puts
563
+
564
+ # Performance estimates
565
+ perf = analysis['performance_estimates']
566
+ puts "⏱️ " + @pastel.bold("Performance Estimates:")
567
+ puts " Extraction time: #{@pastel.cyan(perf['estimated_extraction_time_human'])}"
568
+ puts " Memory usage: #{@pastel.cyan(perf['estimated_memory_usage_human'])}"
569
+ puts
570
+
571
+ # Model breakdown
572
+ puts "📋 " + @pastel.bold("Records by Model:")
573
+ analysis['estimated_counts_by_model'].each do |model, count|
574
+ percentage = (count.to_f / analysis['extraction_scope']['total_estimated_records'] * 100).round(1)
575
+ puts " #{model.ljust(20)} #{@pastel.cyan(count.to_s.rjust(8))} (#{percentage}%)"
576
+ end
577
+ puts
578
+
579
+ # Depth analysis
580
+ if analysis['depth_analysis'].any?
581
+ puts "🌳 " + @pastel.bold("Depth Analysis:")
582
+ analysis['depth_analysis'].each do |depth, models|
583
+ puts " Level #{depth}: #{@pastel.cyan(models.join(', '))}"
584
+ end
585
+ puts
586
+ end
587
+
588
+ # Warnings
589
+ if analysis['warnings'].any?
590
+ puts "⚠️ " + @pastel.bold("Warnings:")
591
+ analysis['warnings'].each do |warning|
592
+ color = case warning['severity']
593
+ when 'high' then :red
594
+ when 'medium' then :yellow
595
+ else :white
596
+ end
597
+ puts " #{@pastel.decorate("#{warning['type'].upcase}:", color)} #{warning['message']}"
598
+ end
599
+ puts
600
+ end
601
+
602
+ # Recommendations
603
+ if analysis['recommendations'].any?
604
+ puts "💡 " + @pastel.bold("Recommendations:")
605
+ analysis['recommendations'].each do |rec|
606
+ puts " #{@pastel.yellow("#{rec['type'].upcase}:")} #{rec['message']}"
607
+ puts " #{@pastel.dim("→ #{rec['action']}")}"
608
+ puts
609
+ end
610
+ end
611
+
612
+ # Circular references
613
+ if analysis['relationship_analysis']['circular_references_count'] > 0
614
+ puts "🔄 " + @pastel.bold("Circular References Detected:")
615
+ analysis['relationship_analysis']['circular_references'].each do |ref|
616
+ puts " #{@pastel.yellow(ref['path'])} (depth #{ref['depth']})"
617
+ end
618
+ puts
619
+ end
620
+ end
621
+
622
+ def error_exit(message)
623
+ puts @pastel.red("❌ Error: #{message}")
624
+ exit(1)
625
+ end
626
+ end
627
+ end