rails-ai-context 4.3.0 → 4.3.2

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -8
  3. data/CLAUDE.md +4 -4
  4. data/CONTRIBUTING.md +1 -1
  5. data/README.md +7 -7
  6. data/SECURITY.md +2 -1
  7. data/docs/GUIDE.md +3 -3
  8. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
  9. data/lib/rails_ai_context/configuration.rb +4 -2
  10. data/lib/rails_ai_context/doctor.rb +6 -1
  11. data/lib/rails_ai_context/fingerprinter.rb +24 -0
  12. data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
  13. data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
  14. data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
  15. data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
  16. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
  17. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
  20. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
  21. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
  22. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  23. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
  24. data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
  25. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
  26. data/lib/rails_ai_context/server.rb +8 -1
  27. data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
  28. data/lib/rails_ai_context/tools/base_tool.rb +78 -1
  29. data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
  30. data/lib/rails_ai_context/tools/diagnose.rb +135 -8
  31. data/lib/rails_ai_context/tools/generate_test.rb +87 -7
  32. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  33. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  34. data/lib/rails_ai_context/tools/get_context.rb +71 -8
  35. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  36. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  37. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  38. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  39. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  40. data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
  41. data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
  42. data/lib/rails_ai_context/tools/get_schema.rb +1 -0
  43. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  44. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  45. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  46. data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
  47. data/lib/rails_ai_context/tools/onboard.rb +413 -27
  48. data/lib/rails_ai_context/tools/performance_check.rb +45 -28
  49. data/lib/rails_ai_context/tools/query.rb +28 -2
  50. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  51. data/lib/rails_ai_context/tools/review_changes.rb +27 -17
  52. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  53. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  54. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  55. data/lib/rails_ai_context/tools/session_context.rb +137 -0
  56. data/lib/rails_ai_context/tools/validate.rb +5 -0
  57. data/lib/rails_ai_context/version.rb +1 -1
  58. metadata +6 -4
@@ -43,22 +43,39 @@ module RailsAiContext
43
43
 
44
44
  def compose_quick(ctx)
45
45
  app = ctx[:app_name] || "This Rails app"
46
- parts = [ "**#{app}** is a Rails #{ctx[:rails_version]} application running Ruby #{ctx[:ruby_version]}" ]
46
+ purpose = infer_app_purpose(ctx)
47
47
 
48
+ parts = [ "**#{app}** is a Rails #{ctx[:rails_version]} / Ruby #{ctx[:ruby_version]}" ]
49
+ parts << purpose if purpose
50
+
51
+ # Stats: tables, models, jobs
52
+ stats = []
48
53
  schema = ctx[:schema]
49
54
  if schema.is_a?(Hash) && !schema[:error]
50
- parts << "on #{schema[:adapter]} with #{schema[:total_tables]} tables"
55
+ table_count = schema[:total_tables] || 0
56
+ stats << "#{table_count} tables" if table_count > 0
51
57
  end
52
58
 
53
59
  models = ctx[:models]
54
60
  if models.is_a?(Hash) && !models[:error] && models.any?
55
- top = central_models(models, 3).join(", ")
56
- parts << "— #{models.size} models (key: #{top})"
61
+ stats << "#{models.size} models"
57
62
  end
58
63
 
64
+ jobs = ctx[:jobs]
65
+ if jobs.is_a?(Hash) && !jobs[:error]
66
+ job_count = (jobs[:jobs] || []).size
67
+ stats << "#{job_count} jobs" if job_count > 0
68
+ end
69
+
70
+ parts << "— #{stats.join(', ')}" if stats.any?
71
+
72
+ # Frontend and testing
73
+ frontend_desc = quick_frontend_summary(ctx)
74
+ parts << "— #{frontend_desc}" if frontend_desc
75
+
59
76
  tests = ctx[:tests]
60
77
  if tests.is_a?(Hash) && !tests[:error]
61
- parts << "tested with #{tests[:framework] || 'unknown framework'}"
78
+ parts << "tested with #{tests[:framework] || 'unknown framework'}"
62
79
  end
63
80
 
64
81
  parts.join(" ") + "."
@@ -107,7 +124,13 @@ module RailsAiContext
107
124
  def section_stack(ctx)
108
125
  lines = [ "## Stack", "" ]
109
126
  schema = ctx[:schema]
110
- db = schema.is_a?(Hash) && !schema[:error] ? "#{schema[:adapter]} (#{schema[:total_tables]} tables)" : "unknown"
127
+ if schema.is_a?(Hash) && !schema[:error]
128
+ # Prefer live adapter from config over static_parse from schema introspector
129
+ adapter = resolve_db_adapter(ctx, schema)
130
+ db = "#{adapter} (#{schema[:total_tables]} tables)"
131
+ else
132
+ db = "unknown"
133
+ end
111
134
  lines << "#{ctx[:app_name]} is a Rails #{ctx[:rails_version]} application running Ruby #{ctx[:ruby_version]} on #{db}."
112
135
 
113
136
  gems = ctx[:gems]
@@ -160,22 +183,60 @@ module RailsAiContext
160
183
 
161
184
  def section_auth(ctx)
162
185
  auth = ctx[:auth]
163
- return [] unless auth.is_a?(Hash) && !auth[:error]
164
-
165
- authentication = auth[:authentication] || {}
166
- authorization = auth[:authorization] || {}
167
- return [] if authentication.empty? && authorization.empty?
168
-
169
186
  lines = [ "## Authentication & Authorization", "" ]
170
- if authentication[:method]
171
- lines << "Authentication is handled by #{authentication[:method]}."
187
+ has_content = false
188
+
189
+ if auth.is_a?(Hash) && !auth[:error]
190
+ authentication = auth[:authentication] || {}
191
+ authorization = auth[:authorization] || {}
192
+ if authentication[:method]
193
+ lines << "Authentication is handled by #{authentication[:method]}."
194
+ has_content = true
195
+ end
196
+ if authentication[:model]
197
+ lines << "The #{authentication[:model]} model handles user accounts."
198
+ has_content = true
199
+ end
200
+ if authorization[:method]
201
+ lines << "Authorization uses #{authorization[:method]}."
202
+ has_content = true
203
+ end
172
204
  end
173
- if authentication[:model]
174
- lines << "The #{authentication[:model]} model handles user accounts."
205
+
206
+ # Fallback: detect auth from gems if introspector didn't provide data
207
+ unless has_content
208
+ gems = ctx[:gems]
209
+ if gems.is_a?(Hash) && !gems[:error]
210
+ notable = gems[:notable_gems] || []
211
+ auth_gem_names = %w[devise omniauth rodauth sorcery clearance authlogic]
212
+ auth_gems = notable.select { |g| g.is_a?(Hash) && auth_gem_names.include?(g[:name].to_s) }
213
+ if auth_gems.any?
214
+ lines << "Authentication via #{auth_gems.map { |g| "#{g[:name]}#{g[:version] ? " (#{g[:version]})" : ""}" }.join(', ')}."
215
+ has_content = true
216
+ end
217
+ authz_gem_names = %w[pundit cancancan action_policy rolify]
218
+ authz_gems = notable.select { |g| g.is_a?(Hash) && authz_gem_names.include?(g[:name].to_s) }
219
+ if authz_gems.any?
220
+ lines << "Authorization via #{authz_gems.map { |g| g[:name] }.join(', ')}."
221
+ has_content = true
222
+ end
223
+ end
175
224
  end
176
- if authorization[:method]
177
- lines << "Authorization uses #{authorization[:method]}."
225
+
226
+ # Fallback: detect from conventions (global before_actions like authenticate_user!)
227
+ unless has_content
228
+ conv = ctx[:conventions]
229
+ if conv.is_a?(Hash) && !conv[:error]
230
+ before_acts = Array(conv[:before_actions]).select { |a| a.to_s.match?(/authenticat|authorize/) }
231
+ auth_checks = Array(conv[:authorization_checks]) + before_acts
232
+ if auth_checks.any?
233
+ lines << "Auth checks detected: #{auth_checks.first(5).join(', ')}."
234
+ has_content = true
235
+ end
236
+ end
178
237
  end
238
+
239
+ return [] unless has_content
179
240
  lines << ""
180
241
  lines
181
242
  end
@@ -333,16 +394,42 @@ module RailsAiContext
333
394
  turbo = ctx[:turbo]
334
395
  jobs = ctx[:jobs]
335
396
  channels = (jobs.is_a?(Hash) ? jobs[:channels] : nil) || []
336
- return [] unless (turbo.is_a?(Hash) && !turbo[:error]) || channels.any?
397
+ has_content = false
337
398
 
338
399
  lines = [ "## Real-Time Features", "" ]
339
400
  if channels.any?
340
401
  names = channels.map { |c| c[:name] || c[:class_name] }.compact
341
402
  lines << "Action Cable channels: #{names.join(', ')}."
403
+ has_content = true
342
404
  end
343
- if turbo.is_a?(Hash) && !turbo[:error] && turbo[:broadcasts]&.any?
344
- lines << "Turbo Stream broadcasts: #{turbo[:broadcasts].size} broadcast points."
405
+
406
+ if turbo.is_a?(Hash) && !turbo[:error]
407
+ broadcasts = turbo[:broadcasts] || turbo[:explicit_broadcasts] || []
408
+ if broadcasts.any?
409
+ lines << "Turbo Stream broadcasts: #{broadcasts.size} broadcast points."
410
+ has_content = true
411
+ end
412
+ streams = turbo[:stream_subscriptions] || turbo[:subscriptions] || []
413
+ if streams.any?
414
+ lines << "Turbo Stream subscriptions: #{streams.size}."
415
+ has_content = true
416
+ end
345
417
  end
418
+
419
+ # Fallback: check for turbo_stream usage in views
420
+ unless has_content
421
+ views = ctx[:view_templates] || ctx[:views]
422
+ if views.is_a?(Hash) && !views[:error]
423
+ templates = Array(views[:templates])
424
+ turbo_views = templates.select { |v| v.is_a?(Hash) && (v[:path].to_s.include?("turbo_stream") || Array(v[:turbo_streams]).any?) }
425
+ if turbo_views.any?
426
+ lines << "Turbo Stream templates: #{turbo_views.size}."
427
+ has_content = true
428
+ end
429
+ end
430
+ end
431
+
432
+ return [] unless has_content
346
433
  lines << ""
347
434
  lines
348
435
  end
@@ -384,13 +471,33 @@ module RailsAiContext
384
471
 
385
472
  def section_devops(ctx)
386
473
  devops = ctx[:devops]
387
- return [] unless devops.is_a?(Hash) && !devops[:error]
388
-
389
474
  lines = [ "## Deployment & DevOps", "" ]
390
- lines << "Dockerfile: #{devops[:dockerfile] ? 'present' : 'not found'}."
391
- lines << "Procfile: #{devops[:procfile] ? 'present' : 'not found'}." if devops.key?(:procfile)
392
- deploy = devops[:deployment_method]
393
- lines << "Deployment: #{deploy}." if deploy
475
+ has_content = false
476
+
477
+ if devops.is_a?(Hash) && !devops[:error]
478
+ lines << "Dockerfile: #{devops[:dockerfile] ? 'present' : 'not found'}."
479
+ lines << "Procfile: #{devops[:procfile] ? 'present' : 'not found'}." if devops.key?(:procfile)
480
+ deploy = devops[:deployment_method]
481
+ lines << "Deployment: #{deploy}." if deploy
482
+ has_content = true
483
+ end
484
+
485
+ # Fallback: check for Dockerfile/Procfile directly
486
+ unless has_content
487
+ root = Rails.root.to_s
488
+ has_dockerfile = File.exist?(File.join(root, "Dockerfile")) || File.exist?(File.join(root, "Dockerfile.dev"))
489
+ has_procfile = File.exist?(File.join(root, "Procfile")) || File.exist?(File.join(root, "Procfile.dev"))
490
+ has_ci = Dir.exist?(File.join(root, ".github", "workflows")) || File.exist?(File.join(root, ".gitlab-ci.yml"))
491
+
492
+ if has_dockerfile || has_procfile || has_ci
493
+ lines << "Dockerfile: #{has_dockerfile ? 'present' : 'not found'}."
494
+ lines << "Procfile: #{has_procfile ? 'present' : 'not found'}."
495
+ lines << "CI: #{has_ci ? 'detected' : 'not found'}."
496
+ has_content = true
497
+ end
498
+ end
499
+
500
+ return [] unless has_content
394
501
  lines << ""
395
502
  lines
396
503
  end
@@ -440,6 +547,33 @@ module RailsAiContext
440
547
 
441
548
  # ── Helpers ──────────────────────────────────────────────────────
442
549
 
550
+ # Resolve the DB adapter name, preferring live config over schema introspection
551
+ def resolve_db_adapter(ctx, schema)
552
+ adapter = schema[:adapter]
553
+
554
+ # If the schema introspector returned a non-informative adapter name, try config
555
+ if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
556
+ config_data = ctx[:config]
557
+ if config_data.is_a?(Hash) && !config_data[:error]
558
+ live_adapter = config_data[:database_adapter] || config_data[:adapter]
559
+ adapter = live_adapter if live_adapter
560
+ end
561
+ end
562
+
563
+ # Try to resolve from gems as a fallback
564
+ if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
565
+ gems_data = ctx[:gems]
566
+ if gems_data.is_a?(Hash) && !gems_data[:error]
567
+ notable = gems_data[:notable_gems] || []
568
+ adapter = "PostgreSQL" if notable.any? { |g| g[:name] == "pg" }
569
+ adapter = "MySQL" if notable.any? { |g| g[:name] == "mysql2" }
570
+ adapter = "SQLite" if notable.any? { |g| g[:name] == "sqlite3" }
571
+ end
572
+ end
573
+
574
+ adapter || "unknown"
575
+ end
576
+
443
577
  def central_models(models, limit = 5)
444
578
  models
445
579
  .select { |_, d| d.is_a?(Hash) && !d[:error] }
@@ -447,6 +581,258 @@ module RailsAiContext
447
581
  .first(limit)
448
582
  .map(&:first)
449
583
  end
584
+
585
+ # ── Purpose inference ────────────────────────────────────────────
586
+
587
+ # Infer a short description of what the app does from its jobs,
588
+ # services, models, gems, and architecture patterns.
589
+ def infer_app_purpose(ctx)
590
+ signals = collect_purpose_signals(ctx)
591
+ return nil if signals.empty?
592
+
593
+ # Deduplicate and join into a natural phrase
594
+ capabilities = signals.uniq
595
+ return nil if capabilities.empty?
596
+
597
+ "#{capabilities.shift} app#{capabilities.any? ? ' with ' + join_capabilities(capabilities) : ''}"
598
+ end
599
+
600
+ # Collect domain signals from jobs, services, models, gems, and conventions
601
+ def collect_purpose_signals(ctx) # rubocop:disable Metrics
602
+ signals = []
603
+
604
+ # Gather raw names from all sources
605
+ job_names = extract_job_names(ctx)
606
+ service_names = extract_service_names
607
+ model_names = extract_model_names(ctx)
608
+ gem_names = extract_gem_names(ctx)
609
+ architecture = extract_architecture(ctx)
610
+
611
+ # Infer primary domain from model names
612
+ signals.concat(infer_domain(model_names, job_names, service_names))
613
+
614
+ # Infer capabilities from jobs and services
615
+ signals.concat(infer_ingestion_sources(job_names, service_names))
616
+ signals.concat(infer_federation(service_names, gem_names, model_names))
617
+ signals.concat(infer_ai_processing(service_names, job_names, gem_names))
618
+ signals.concat(infer_social_features(model_names, service_names))
619
+ signals.concat(infer_notifications(service_names, job_names))
620
+ signals.concat(infer_search(gem_names, architecture))
621
+ signals.concat(infer_ecommerce(model_names, service_names, gem_names))
622
+ signals.concat(infer_messaging(model_names, job_names))
623
+
624
+ signals
625
+ end
626
+
627
+ def extract_job_names(ctx)
628
+ jobs = ctx[:jobs]
629
+ return [] unless jobs.is_a?(Hash) && !jobs[:error]
630
+ (jobs[:jobs] || []).map { |j| j[:name].to_s }.reject(&:empty?)
631
+ end
632
+
633
+ def extract_service_names
634
+ services_dir = File.join(Rails.root, "app", "services")
635
+ return [] unless Dir.exist?(services_dir)
636
+
637
+ Dir.glob(File.join(services_dir, "**", "*.rb")).filter_map do |path|
638
+ name = File.basename(path, ".rb").camelize
639
+ name unless name == "ApplicationService" || name == "BaseService"
640
+ end
641
+ rescue
642
+ []
643
+ end
644
+
645
+ def extract_model_names(ctx)
646
+ models = ctx[:models]
647
+ return [] unless models.is_a?(Hash) && !models[:error]
648
+ models.keys.map(&:to_s)
649
+ end
650
+
651
+ def extract_gem_names(ctx)
652
+ gems = ctx[:gems]
653
+ return [] unless gems.is_a?(Hash) && !gems[:error]
654
+ (gems[:notable_gems] || []).map { |g| g[:name].to_s }
655
+ end
656
+
657
+ def extract_architecture(ctx)
658
+ conv = ctx[:conventions]
659
+ return [] unless conv.is_a?(Hash) && !conv[:error]
660
+ conv[:architecture] || []
661
+ end
662
+
663
+ # Infer the primary domain of the app (e.g., "news aggregation", "e-commerce")
664
+ def infer_domain(model_names, job_names, service_names)
665
+ all_names = (model_names + job_names + service_names).map(&:downcase).join(" ")
666
+
667
+ # Order matters: more specific patterns first
668
+ if all_names.match?(/article|news|rss|feed/) && all_names.match?(/site|source|feed/)
669
+ [ "news aggregation" ]
670
+ elsif all_names.match?(/article|blog|post/) && all_names.match?(/comment|author/)
671
+ [ "content publishing" ]
672
+ elsif all_names.match?(/product|cart|order|checkout/)
673
+ [ "e-commerce" ]
674
+ elsif all_names.match?(/patient|appointment|doctor|medical/)
675
+ [ "healthcare" ]
676
+ elsif all_names.match?(/course|lesson|student|enrollment/)
677
+ [ "education/LMS" ]
678
+ elsif all_names.match?(/listing|property|booking|reservation/)
679
+ [ "marketplace" ]
680
+ elsif all_names.match?(/ticket|issue|sprint|project/) && all_names.match?(/assign|board/)
681
+ [ "project management" ]
682
+ elsif all_names.match?(/message|conversation|chat|thread/)
683
+ [ "messaging" ]
684
+ elsif all_names.match?(/invoice|payment|subscription|billing/)
685
+ [ "billing/SaaS" ]
686
+ elsif all_names.match?(/post|comment|follow|like|feed/)
687
+ [ "social platform" ]
688
+ elsif all_names.match?(/article|post|page|content/)
689
+ [ "content management" ]
690
+ else
691
+ []
692
+ end
693
+ end
694
+
695
+ # Infer content ingestion sources from job/service names
696
+ def infer_ingestion_sources(job_names, service_names)
697
+ all_names = (job_names + service_names).map(&:downcase)
698
+ sources = []
699
+
700
+ sources << "RSS" if all_names.any? { |n| n.include?("rss") }
701
+ sources << "YouTube" if all_names.any? { |n| n.include?("youtube") }
702
+ sources << "HackerNews" if all_names.any? { |n| n.include?("hackernews") || n.include?("hacker_news") }
703
+ sources << "Reddit" if all_names.any? { |n| n.include?("reddit") }
704
+ sources << "Gmail" if all_names.any? { |n| n.include?("gmail") }
705
+ sources << "Twitter" if all_names.any? { |n| n.include?("twitter") }
706
+
707
+ return [] if sources.empty?
708
+ [ "#{sources.join(', ')} ingestion" ]
709
+ end
710
+
711
+ # Infer ActivityPub/federation features
712
+ def infer_federation(service_names, gem_names, model_names)
713
+ all = (service_names + gem_names + model_names).map(&:downcase)
714
+
715
+ if all.any? { |n| n.match?(/mastodon|activitypub|federails|federation/) }
716
+ [ "ActivityPub federation" ]
717
+ elsif all.any? { |n| n.match?(/fediverse/) }
718
+ [ "Fediverse integration" ]
719
+ else
720
+ []
721
+ end
722
+ end
723
+
724
+ # Infer AI/ML processing features
725
+ def infer_ai_processing(service_names, job_names, gem_names)
726
+ all = (service_names + job_names + gem_names).map(&:downcase)
727
+
728
+ if all.any? { |n| n.match?(/agent|openai|anthropic|llm|ai_|_ai/) }
729
+ [ "AI processing" ]
730
+ elsif all.any? { |n| n.match?(/ml_|machine_learn|predict/) }
731
+ [ "ML processing" ]
732
+ else
733
+ []
734
+ end
735
+ end
736
+
737
+ # Infer social features (follows, likes, etc.)
738
+ def infer_social_features(model_names, service_names)
739
+ all = (model_names + service_names).map(&:downcase)
740
+
741
+ if all.any? { |n| n.match?(/follow|like|mention|social/) } && all.any? { |n| n.match?(/federation|mastodon/) }
742
+ [] # Already covered by federation
743
+ elsif all.any? { |n| n.match?(/oauth|social_media/) }
744
+ [ "social media integration" ]
745
+ else
746
+ []
747
+ end
748
+ end
749
+
750
+ # Infer push/notification features
751
+ def infer_notifications(service_names, job_names)
752
+ all = (service_names + job_names).map(&:downcase)
753
+
754
+ if all.any? { |n| n.match?(/push_notif|web_push|notification/) }
755
+ [ "push notifications" ]
756
+ else
757
+ []
758
+ end
759
+ end
760
+
761
+ # Infer search capabilities
762
+ def infer_search(gem_names, architecture)
763
+ all = (gem_names + architecture).map(&:downcase)
764
+
765
+ if all.any? { |n| n.match?(/elasticsearch|searchkick|meilisearch/) }
766
+ [ "full-text search" ]
767
+ else
768
+ []
769
+ end
770
+ end
771
+
772
+ # Infer e-commerce features
773
+ def infer_ecommerce(model_names, service_names, gem_names)
774
+ all = (model_names + service_names + gem_names).map(&:downcase)
775
+
776
+ if all.any? { |n| n.match?(/stripe|pay\b|braintree/) }
777
+ [ "payment processing" ]
778
+ else
779
+ []
780
+ end
781
+ end
782
+
783
+ # Infer messaging/real-time features
784
+ def infer_messaging(model_names, job_names)
785
+ all = (model_names + job_names).map(&:downcase)
786
+
787
+ if all.any? { |n| n.match?(/conversation|chat|direct_message/) }
788
+ [ "real-time messaging" ]
789
+ else
790
+ []
791
+ end
792
+ end
793
+
794
+ # Quick one-line frontend summary from conventions
795
+ def quick_frontend_summary(ctx)
796
+ conv = ctx[:conventions]
797
+ return nil unless conv.is_a?(Hash) && !conv[:error]
798
+
799
+ arch = conv[:architecture] || []
800
+ parts = []
801
+
802
+ parts << "Hotwire" if arch.include?("hotwire")
803
+ parts << "Phlex" if arch.include?("phlex")
804
+ parts << "ViewComponent" if arch.include?("view_components") && !arch.include?("phlex")
805
+ parts << "Stimulus" if arch.include?("stimulus") && !arch.include?("hotwire")
806
+ parts << "React" if arch.include?("react")
807
+ parts << "Vue" if arch.include?("vue")
808
+
809
+ # Check frontend frameworks introspection too
810
+ frontend = ctx[:frontend_frameworks]
811
+ if frontend.is_a?(Hash) && !frontend[:error]
812
+ frameworks = frontend[:frameworks]
813
+ if frameworks.is_a?(Hash)
814
+ frameworks.each_key do |name|
815
+ n = name.to_s.downcase
816
+ parts << "React" if n.include?("react") && !parts.include?("React")
817
+ parts << "Vue" if n.include?("vue") && !parts.include?("Vue")
818
+ parts << "Angular" if n.include?("angular") && !parts.include?("Angular")
819
+ parts << "Svelte" if n.include?("svelte") && !parts.include?("Svelte")
820
+ end
821
+ end
822
+ end
823
+
824
+ parts.any? ? "#{parts.join(' + ')} frontend" : nil
825
+ end
826
+
827
+ # Join a list of capabilities with commas and "and" before the last
828
+ def join_capabilities(items)
829
+ case items.size
830
+ when 0 then ""
831
+ when 1 then items.first
832
+ when 2 then "#{items[0]} and #{items[1]}"
833
+ else "#{items[0..-2].join(', ')}, and #{items.last}"
834
+ end
835
+ end
450
836
  end
451
837
  end
452
838
  end
@@ -46,23 +46,40 @@ module RailsAiContext
46
46
  if models_data.is_a?(Hash) && !models_data[:error]
47
47
  model_names = models_data.keys.map(&:to_s)
48
48
  unless model_names.any? { |m| m.downcase == model.downcase }
49
- return not_found_response("model", model, model_names, recovery_tool: "rails_performance_check")
49
+ return not_found_response("Model", model, model_names,
50
+ recovery_tool: "Call rails_performance_check() without model filter to see all issues")
50
51
  end
51
52
  end
52
53
  end
53
54
 
54
55
  lines = [ "# Performance Analysis", "" ]
55
56
 
56
- summary = data[:summary] || {}
57
- lines << "**Total issues found:** #{summary[:total_issues] || 0}"
57
+ # Collect all items then filter, so the count reflects actual displayed results
58
+ all_sections = {}
59
+ all_sections[:n_plus_one] = data[:n_plus_one_risks] || []
60
+ all_sections[:counter_cache] = data[:missing_counter_cache] || []
61
+ all_sections[:indexes] = data[:missing_fk_indexes] || []
62
+ all_sections[:model_all] = data[:model_all_in_controllers] || []
63
+ all_sections[:eager_load] = data[:eager_load_candidates] || []
64
+
65
+ # Apply model filter to count
66
+ filtered_count = if model && !model.empty?
67
+ all_sections.values.sum { |items| filter_items(items, model).size }
68
+ elsif category != "all"
69
+ (all_sections[category.to_sym] || []).size
70
+ else
71
+ all_sections.values.sum(&:size)
72
+ end
73
+
74
+ lines << "**Total issues found:** #{filtered_count}"
58
75
  lines << ""
59
76
 
60
77
  if detail == "summary"
61
- lines << "- N+1 risks: #{summary[:n_plus_one_risks] || 0}"
62
- lines << "- Missing counter_cache: #{summary[:missing_counter_cache] || 0}"
63
- lines << "- Missing FK indexes: #{summary[:missing_fk_indexes] || 0}"
64
- lines << "- Model.all in controllers: #{summary[:model_all_in_controllers] || 0}"
65
- lines << "- Eager load candidates: #{summary[:eager_load_candidates] || 0}"
78
+ lines << "- N+1 risks: #{filter_items(all_sections[:n_plus_one], model).size}"
79
+ lines << "- Missing counter_cache: #{filter_items(all_sections[:counter_cache], model).size}"
80
+ lines << "- Missing FK indexes: #{filter_items(all_sections[:indexes], model).size}"
81
+ lines << "- Model.all in controllers: #{filter_items(all_sections[:model_all], model).size}"
82
+ lines << "- Eager load candidates: #{filter_items(all_sections[:eager_load], model).size}"
66
83
  else
67
84
  if category == "all" || category == "n_plus_one"
68
85
  lines.concat(render_section("N+1 Query Risks", data[:n_plus_one_risks], model, detail))
@@ -81,8 +98,8 @@ module RailsAiContext
81
98
  end
82
99
  end
83
100
 
84
- if summary[:total_issues] == 0
85
- lines << "No performance issues detected. Your app looks good!"
101
+ if filtered_count == 0
102
+ lines << "No performance issues detected#{model && !model.empty? ? " for #{model}" : ""}. Your app looks good!"
86
103
  end
87
104
 
88
105
  text_response(lines.join("\n"))
@@ -91,28 +108,28 @@ module RailsAiContext
91
108
  class << self
92
109
  private
93
110
 
94
- def render_section(title, items, model_filter, detail)
111
+ def filter_items(items, model_filter)
112
+ return (items || []) unless model_filter && !model_filter.empty?
95
113
  return [] unless items&.any?
96
114
 
97
- filtered = if model_filter
98
- filter_lower = model_filter.downcase
99
- # Underscore BEFORE downcase to handle CamelCase → snake_case correctly
100
- # "BrandProfile" → "brand_profile" → "brand_profiles"
101
- table_form = begin
102
- model_filter.underscore.pluralize.downcase
103
- rescue
104
- filter_lower
105
- end
106
- items.select { |i|
107
- (i[:model]&.downcase == filter_lower) ||
108
- (i[:table]&.downcase == table_form) ||
109
- (i[:table]&.downcase == filter_lower) ||
110
- (i[:table]&.downcase == model_filter.underscore.downcase)
111
- }
112
- else
113
- items
115
+ filter_lower = model_filter.downcase
116
+ table_form = begin
117
+ model_filter.underscore.pluralize.downcase
118
+ rescue
119
+ filter_lower
114
120
  end
121
+ items.select { |i|
122
+ (i[:model]&.downcase == filter_lower) ||
123
+ (i[:table]&.downcase == table_form) ||
124
+ (i[:table]&.downcase == filter_lower) ||
125
+ (i[:table]&.downcase == model_filter.underscore.downcase)
126
+ }
127
+ end
128
+
129
+ def render_section(title, items, model_filter, detail)
130
+ return [] unless items&.any?
115
131
 
132
+ filtered = filter_items(items, model_filter)
116
133
  return [] if filtered.empty?
117
134
 
118
135
  lines = [ "## #{title} (#{filtered.size})", "" ]