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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -8
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +2 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/configuration.rb +4 -2
- data/lib/rails_ai_context/doctor.rb +6 -1
- data/lib/rails_ai_context/fingerprinter.rb +24 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
- data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
- data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
- data/lib/rails_ai_context/server.rb +8 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
- data/lib/rails_ai_context/tools/base_tool.rb +78 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
- data/lib/rails_ai_context/tools/diagnose.rb +135 -8
- data/lib/rails_ai_context/tools/generate_test.rb +87 -7
- data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
- data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
- data/lib/rails_ai_context/tools/get_context.rb +71 -8
- data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
- data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
- data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
- data/lib/rails_ai_context/tools/get_env.rb +51 -24
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
- data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
- data/lib/rails_ai_context/tools/get_schema.rb +1 -0
- data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
- data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
- data/lib/rails_ai_context/tools/get_view.rb +65 -9
- data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
- data/lib/rails_ai_context/tools/onboard.rb +413 -27
- data/lib/rails_ai_context/tools/performance_check.rb +45 -28
- data/lib/rails_ai_context/tools/query.rb +28 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +27 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
- data/lib/rails_ai_context/tools/search_code.rb +23 -4
- data/lib/rails_ai_context/tools/security_scan.rb +7 -1
- data/lib/rails_ai_context/tools/session_context.rb +137 -0
- data/lib/rails_ai_context/tools/validate.rb +5 -0
- data/lib/rails_ai_context/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 << "
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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("
|
|
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
|
-
|
|
57
|
-
|
|
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: #{
|
|
62
|
-
lines << "- Missing counter_cache: #{
|
|
63
|
-
lines << "- Missing FK indexes: #{
|
|
64
|
-
lines << "- Model.all in controllers: #{
|
|
65
|
-
lines << "- Eager load candidates: #{
|
|
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
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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})", "" ]
|