rails-ai-context 4.2.3 → 4.3.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  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 +18 -10
  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 +10 -19
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
  20. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
  21. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  22. data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
  23. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
  24. data/lib/rails_ai_context/server.rb +12 -1
  25. data/lib/rails_ai_context/tools/base_tool.rb +63 -1
  26. data/lib/rails_ai_context/tools/diagnose.rb +436 -0
  27. data/lib/rails_ai_context/tools/generate_test.rb +571 -0
  28. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  29. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  30. data/lib/rails_ai_context/tools/get_context.rb +70 -8
  31. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  32. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  33. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  34. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  35. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  36. data/lib/rails_ai_context/tools/get_model_details.rb +19 -0
  37. data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
  38. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  39. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  40. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  41. data/lib/rails_ai_context/tools/migration_advisor.rb +4 -0
  42. data/lib/rails_ai_context/tools/onboard.rb +755 -0
  43. data/lib/rails_ai_context/tools/query.rb +4 -2
  44. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  45. data/lib/rails_ai_context/tools/review_changes.rb +299 -0
  46. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  47. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  48. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  49. data/lib/rails_ai_context/tools/session_context.rb +132 -0
  50. data/lib/rails_ai_context/version.rb +1 -1
  51. metadata +10 -4
@@ -0,0 +1,755 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class Onboard < BaseTool
6
+ tool_name "rails_onboard"
7
+ description "Get a narrative walkthrough of the Rails application — stack, data model, authentication, key flows, " \
8
+ "background jobs, frontend, testing, and getting started instructions. " \
9
+ "Use when: first encountering a project, onboarding a new developer, or orienting an AI agent. " \
10
+ "Key params: detail (quick/standard/full)."
11
+
12
+ input_schema(
13
+ properties: {
14
+ detail: {
15
+ type: "string",
16
+ enum: %w[quick standard full],
17
+ description: "Detail level. quick: 1-paragraph overview. standard: structured walkthrough (default). full: comprehensive with all subsystems."
18
+ }
19
+ }
20
+ )
21
+
22
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
23
+
24
+ def self.call(detail: "standard", server_context: nil)
25
+ ctx = cached_context
26
+
27
+ case detail
28
+ when "quick"
29
+ text_response(compose_quick(ctx))
30
+ when "full"
31
+ text_response(compose_full(ctx))
32
+ else
33
+ text_response(compose_standard(ctx))
34
+ end
35
+ rescue => e
36
+ text_response("Onboard error: #{e.message}")
37
+ end
38
+
39
+ class << self
40
+ private
41
+
42
+ # ── Quick: single paragraph ──────────────────────────────────────
43
+
44
+ def compose_quick(ctx)
45
+ app = ctx[:app_name] || "This Rails app"
46
+ purpose = infer_app_purpose(ctx)
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 = []
53
+ schema = ctx[:schema]
54
+ if schema.is_a?(Hash) && !schema[:error]
55
+ table_count = schema[:total_tables] || 0
56
+ stats << "#{table_count} tables" if table_count > 0
57
+ end
58
+
59
+ models = ctx[:models]
60
+ if models.is_a?(Hash) && !models[:error] && models.any?
61
+ stats << "#{models.size} models"
62
+ end
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
+
76
+ tests = ctx[:tests]
77
+ if tests.is_a?(Hash) && !tests[:error]
78
+ parts << "tested with #{tests[:framework] || 'unknown framework'}"
79
+ end
80
+
81
+ parts.join(" ") + "."
82
+ end
83
+
84
+ # ── Standard: structured walkthrough ─────────────────────────────
85
+
86
+ def compose_standard(ctx) # rubocop:disable Metrics
87
+ lines = [ "# Welcome to #{ctx[:app_name] || 'This Rails App'}", "" ]
88
+ lines.concat(section_stack(ctx))
89
+ lines.concat(section_data_model(ctx))
90
+ lines.concat(section_auth(ctx))
91
+ lines.concat(section_key_flows(ctx))
92
+ lines.concat(section_jobs(ctx))
93
+ lines.concat(section_frontend(ctx))
94
+ lines.concat(section_testing(ctx))
95
+ lines.concat(section_getting_started(ctx))
96
+ lines.join("\n")
97
+ end
98
+
99
+ # ── Full: standard + all subsystems ──────────────────────────────
100
+
101
+ def compose_full(ctx) # rubocop:disable Metrics
102
+ lines = [ "# Welcome to #{ctx[:app_name] || 'This Rails App'} (Full Walkthrough)", "" ]
103
+ lines.concat(section_stack(ctx))
104
+ lines.concat(section_data_model(ctx))
105
+ lines.concat(section_auth(ctx))
106
+ lines.concat(section_key_flows(ctx))
107
+ lines.concat(section_jobs(ctx))
108
+ lines.concat(section_frontend(ctx))
109
+ lines.concat(section_payments(ctx))
110
+ lines.concat(section_realtime(ctx))
111
+ lines.concat(section_storage(ctx))
112
+ lines.concat(section_api(ctx))
113
+ lines.concat(section_devops(ctx))
114
+ lines.concat(section_i18n(ctx))
115
+ lines.concat(section_engines(ctx))
116
+ lines.concat(section_env(ctx))
117
+ lines.concat(section_testing(ctx))
118
+ lines.concat(section_getting_started(ctx))
119
+ lines.join("\n")
120
+ end
121
+
122
+ # ── Section builders ─────────────────────────────────────────────
123
+
124
+ def section_stack(ctx)
125
+ lines = [ "## Stack", "" ]
126
+ schema = ctx[:schema]
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
134
+ lines << "#{ctx[:app_name]} is a Rails #{ctx[:rails_version]} application running Ruby #{ctx[:ruby_version]} on #{db}."
135
+
136
+ gems = ctx[:gems]
137
+ if gems.is_a?(Hash) && !gems[:error]
138
+ notable = gems[:notable_gems] || []
139
+ if notable.any?
140
+ by_cat = notable.group_by { |g| g[:category]&.to_s || "other" }
141
+ gem_parts = by_cat.first(5).map { |cat, list| "#{cat}: #{list.map { |g| g[:name] }.join(', ')}" }
142
+ lines << "Notable gems — #{gem_parts.join('; ')}."
143
+ end
144
+ end
145
+
146
+ conv = ctx[:conventions]
147
+ if conv.is_a?(Hash) && !conv[:error]
148
+ arch = conv[:architecture] || []
149
+ lines << "Architecture: #{arch.join(', ')}." if arch.any?
150
+ end
151
+
152
+ lines << ""
153
+ lines
154
+ end
155
+
156
+ def section_data_model(ctx)
157
+ models = ctx[:models]
158
+ return [] unless models.is_a?(Hash) && !models[:error] && models.any?
159
+
160
+ lines = [ "## Data Model", "" ]
161
+ top = central_models(models, 7)
162
+ lines << "The app has #{models.size} models. The central ones are:"
163
+ lines << ""
164
+
165
+ top.each do |name|
166
+ data = models[name]
167
+ next unless data.is_a?(Hash) && !data[:error]
168
+ assocs = (data[:associations] || []).map { |a| "#{a[:type]} :#{a[:name]}" }
169
+ val_count = (data[:validations] || []).size
170
+ desc = "**#{name}**"
171
+ desc += " (table: `#{data[:table_name]}`)" if data[:table_name]
172
+ desc += " — #{assocs.first(4).join(', ')}" if assocs.any?
173
+ desc += ", +#{assocs.size - 4} more" if assocs.size > 4
174
+ desc += ". #{val_count} validations." if val_count > 0
175
+ lines << "- #{desc}"
176
+ end
177
+
178
+ remaining = models.size - top.size
179
+ lines << "- _...and #{remaining} more models._" if remaining > 0
180
+ lines << ""
181
+ lines
182
+ end
183
+
184
+ def section_auth(ctx)
185
+ auth = ctx[:auth]
186
+ return [] unless auth.is_a?(Hash) && !auth[:error]
187
+
188
+ authentication = auth[:authentication] || {}
189
+ authorization = auth[:authorization] || {}
190
+ return [] if authentication.empty? && authorization.empty?
191
+
192
+ lines = [ "## Authentication & Authorization", "" ]
193
+ if authentication[:method]
194
+ lines << "Authentication is handled by #{authentication[:method]}."
195
+ end
196
+ if authentication[:model]
197
+ lines << "The #{authentication[:model]} model handles user accounts."
198
+ end
199
+ if authorization[:method]
200
+ lines << "Authorization uses #{authorization[:method]}."
201
+ end
202
+ lines << ""
203
+ lines
204
+ end
205
+
206
+ def section_key_flows(ctx)
207
+ routes = ctx[:routes]
208
+ controllers = ctx[:controllers]
209
+ return [] unless routes.is_a?(Hash) && !routes[:error]
210
+
211
+ lines = [ "## Key Flows", "" ]
212
+ by_ctrl = routes[:by_controller] || {}
213
+
214
+ # Find controllers with most actions (most important flows)
215
+ internal_prefixes = %w[action_mailbox/ active_storage/ rails/ conductor/ devise/ turbo/]
216
+ app_ctrls = by_ctrl.reject { |k, _| internal_prefixes.any? { |p| k.downcase.start_with?(p) } }
217
+ top_ctrls = app_ctrls.sort_by { |_, routes_list| -routes_list.size }.first(5)
218
+
219
+ top_ctrls.each do |ctrl, ctrl_routes|
220
+ actions = ctrl_routes.map { |r| r[:action] }.compact.uniq
221
+ verbs = ctrl_routes.map { |r| "#{r[:verb]} #{r[:path]}" }.first(3)
222
+ lines << "- **#{ctrl}** — #{actions.join(', ')} (#{verbs.join(', ')})"
223
+ end
224
+
225
+ lines << ""
226
+ lines << "Total: #{routes[:total_routes]} routes across #{app_ctrls.size} controllers."
227
+ lines << ""
228
+ lines
229
+ end
230
+
231
+ def section_jobs(ctx)
232
+ jobs = ctx[:jobs]
233
+ return [] unless jobs.is_a?(Hash) && !jobs[:error]
234
+
235
+ job_list = jobs[:jobs] || []
236
+ mailers = jobs[:mailers] || []
237
+ channels = jobs[:channels] || []
238
+ return [] if job_list.empty? && mailers.empty? && channels.empty?
239
+
240
+ lines = [ "## Background Jobs & Async", "" ]
241
+ if job_list.any?
242
+ names = job_list.map { |j| j[:name] || j[:class_name] }.compact.first(8)
243
+ lines << "#{job_list.size} background jobs: #{names.join(', ')}#{job_list.size > 8 ? ', ...' : ''}."
244
+ end
245
+ lines << "#{mailers.size} mailers." if mailers.any?
246
+ lines << "#{channels.size} Action Cable channels." if channels.any?
247
+ lines << ""
248
+ lines
249
+ end
250
+
251
+ def section_frontend(ctx)
252
+ frontend = ctx[:frontend_frameworks]
253
+ stimulus = ctx[:stimulus]
254
+ turbo = ctx[:turbo]
255
+
256
+ lines = []
257
+ has_content = false
258
+
259
+ if frontend.is_a?(Hash) && !frontend[:error] && frontend[:frameworks]&.any?
260
+ lines << "## Frontend" << ""
261
+ frameworks = frontend[:frameworks]
262
+ if frameworks.is_a?(Hash)
263
+ frameworks.each { |name, version| lines << "- #{name} #{version}".strip }
264
+ elsif frameworks.is_a?(Array)
265
+ frameworks.each { |fw| lines << "- #{fw.is_a?(Hash) ? "#{fw[:name]} #{fw[:version]}" : fw}".strip }
266
+ end
267
+ has_content = true
268
+ end
269
+
270
+ if stimulus.is_a?(Hash) && !stimulus[:error]
271
+ count = stimulus[:total_controllers] || stimulus[:controllers]&.size || 0
272
+ if count > 0
273
+ lines << "## Frontend" << "" unless has_content
274
+ lines << "Stimulus: #{count} controllers for interactive behavior."
275
+ has_content = true
276
+ end
277
+ end
278
+
279
+ if turbo.is_a?(Hash) && !turbo[:error]
280
+ frames = turbo[:frames]&.size || 0
281
+ streams = turbo[:streams]&.size || 0
282
+ if frames > 0 || streams > 0
283
+ lines << "## Frontend" << "" unless has_content
284
+ parts = []
285
+ parts << "#{frames} Turbo Frames" if frames > 0
286
+ parts << "#{streams} Turbo Streams" if streams > 0
287
+ lines << "Hotwire: #{parts.join(', ')}."
288
+ has_content = true
289
+ end
290
+ end
291
+
292
+ lines << "" if has_content
293
+ lines
294
+ end
295
+
296
+ def section_testing(ctx)
297
+ tests = ctx[:tests]
298
+ return [] unless tests.is_a?(Hash) && !tests[:error]
299
+
300
+ lines = [ "## Testing", "" ]
301
+ framework = tests[:framework] || "unknown"
302
+ lines << "Framework: #{framework}."
303
+
304
+ factories = tests[:factories]
305
+ fixtures = tests[:fixtures]
306
+ lines << "Data setup: #{factories ? "FactoryBot (#{factories[:count]} factories)" : fixtures ? "fixtures (#{fixtures[:count]} files)" : "inline"}."
307
+
308
+ ci = tests[:ci_config]
309
+ lines << "CI: #{ci.join(', ')}." if ci&.any?
310
+
311
+ coverage = tests[:coverage]
312
+ lines << "Coverage: #{coverage}." if coverage
313
+
314
+ test_cmd = framework == "rspec" ? "bundle exec rspec" : "rails test"
315
+ lines << "" << "Run tests: `#{test_cmd}`"
316
+ lines << ""
317
+ lines
318
+ end
319
+
320
+ def section_getting_started(ctx)
321
+ test_cmd = (ctx[:tests].is_a?(Hash) && ctx[:tests][:framework] == "rspec") ? "bundle exec rspec" : "rails test"
322
+ [
323
+ "## Getting Started", "",
324
+ "```bash",
325
+ "git clone <repo-url>",
326
+ "cd #{ctx[:app_name]&.underscore || 'app'}",
327
+ "bundle install",
328
+ "rails db:setup",
329
+ "bin/dev # or rails server",
330
+ "#{test_cmd} # verify everything works",
331
+ "```", ""
332
+ ]
333
+ end
334
+
335
+ # ── Full-only sections ───────────────────────────────────────────
336
+
337
+ def section_payments(ctx)
338
+ gems = ctx[:gems]
339
+ models = ctx[:models]
340
+ return [] unless gems.is_a?(Hash) && !gems[:error] && models.is_a?(Hash)
341
+
342
+ payment_gems = %w[stripe pay braintree paddle_pay]
343
+ notable = gems[:notable_gems] || []
344
+ found = notable.select { |g| payment_gems.include?(g[:name]) }
345
+ payment_models = models.keys.select { |m| m.downcase.match?(/payment|subscription|charge|invoice|plan|billing/) }
346
+ return [] if found.empty? && payment_models.empty?
347
+
348
+ lines = [ "## Payments", "" ]
349
+ lines << "Payment gems: #{found.map { |g| g[:name] }.join(', ')}." if found.any?
350
+ lines << "Payment-related models: #{payment_models.join(', ')}." if payment_models.any?
351
+ lines << ""
352
+ lines
353
+ end
354
+
355
+ def section_realtime(ctx)
356
+ turbo = ctx[:turbo]
357
+ jobs = ctx[:jobs]
358
+ channels = (jobs.is_a?(Hash) ? jobs[:channels] : nil) || []
359
+ return [] unless (turbo.is_a?(Hash) && !turbo[:error]) || channels.any?
360
+
361
+ lines = [ "## Real-Time Features", "" ]
362
+ if channels.any?
363
+ names = channels.map { |c| c[:name] || c[:class_name] }.compact
364
+ lines << "Action Cable channels: #{names.join(', ')}."
365
+ end
366
+ if turbo.is_a?(Hash) && !turbo[:error] && turbo[:broadcasts]&.any?
367
+ lines << "Turbo Stream broadcasts: #{turbo[:broadcasts].size} broadcast points."
368
+ end
369
+ lines << ""
370
+ lines
371
+ end
372
+
373
+ def section_storage(ctx)
374
+ storage = ctx[:active_storage]
375
+ text = ctx[:action_text]
376
+ return [] unless (storage.is_a?(Hash) && !storage[:error]) || (text.is_a?(Hash) && !text[:error])
377
+
378
+ lines = [ "## File Storage & Rich Text", "" ]
379
+ if storage.is_a?(Hash) && !storage[:error] && storage[:attachments]&.any?
380
+ lines << "Active Storage: #{storage[:attachments].size} attachment(s) across models."
381
+ end
382
+ if text.is_a?(Hash) && !text[:error] && text[:models]&.any?
383
+ lines << "Action Text: #{text[:models].size} model(s) with rich text fields."
384
+ end
385
+ lines << ""
386
+ lines
387
+ end
388
+
389
+ def section_api(ctx)
390
+ api = ctx[:api]
391
+ return [] unless api.is_a?(Hash) && !api[:error]
392
+ return [] if api.empty? || (api[:endpoints]&.empty? && api[:graphql].nil?)
393
+
394
+ lines = [ "## API", "" ]
395
+ if api[:graphql]
396
+ lines << "GraphQL API detected."
397
+ end
398
+ if api[:endpoints]&.any?
399
+ lines << "#{api[:endpoints].size} API endpoint(s)."
400
+ end
401
+ if api[:serializers]&.any?
402
+ lines << "Serializers: #{api[:serializers].size}."
403
+ end
404
+ lines << ""
405
+ lines
406
+ end
407
+
408
+ def section_devops(ctx)
409
+ devops = ctx[:devops]
410
+ return [] unless devops.is_a?(Hash) && !devops[:error]
411
+
412
+ lines = [ "## Deployment & DevOps", "" ]
413
+ lines << "Dockerfile: #{devops[:dockerfile] ? 'present' : 'not found'}."
414
+ lines << "Procfile: #{devops[:procfile] ? 'present' : 'not found'}." if devops.key?(:procfile)
415
+ deploy = devops[:deployment_method]
416
+ lines << "Deployment: #{deploy}." if deploy
417
+ lines << ""
418
+ lines
419
+ end
420
+
421
+ def section_i18n(ctx)
422
+ i18n = ctx[:i18n]
423
+ return [] unless i18n.is_a?(Hash) && !i18n[:error]
424
+
425
+ locales = i18n[:locales] || []
426
+ return [] if locales.empty?
427
+
428
+ [ "## Internationalization", "", "Locales: #{locales.join(', ')}.", "" ]
429
+ end
430
+
431
+ def section_engines(ctx)
432
+ engines = ctx[:engines]
433
+ return [] unless engines.is_a?(Hash) && !engines[:error]
434
+
435
+ mounted = engines[:engines] || engines[:mounted] || []
436
+ return [] if mounted.empty?
437
+
438
+ lines = [ "## Mounted Engines", "" ]
439
+ mounted.each do |e|
440
+ name = e[:name] || e[:engine]
441
+ path = e[:path] || e[:mount_path]
442
+ lines << "- **#{name}** at `#{path}`" if name
443
+ end
444
+ lines << ""
445
+ lines
446
+ end
447
+
448
+ def section_env(ctx)
449
+ # Summarize from models that have encrypts, and auth/payment-related env patterns
450
+ models = ctx[:models]
451
+ return [] unless models.is_a?(Hash) && !models[:error]
452
+
453
+ encrypted = models.select { |_, d| d.is_a?(Hash) && d[:encrypts]&.any? }
454
+ return [] if encrypted.empty?
455
+
456
+ lines = [ "## Encrypted Data", "" ]
457
+ encrypted.each do |name, data|
458
+ lines << "- **#{name}**: encrypts #{data[:encrypts].join(', ')}"
459
+ end
460
+ lines << ""
461
+ lines
462
+ end
463
+
464
+ # ── Helpers ──────────────────────────────────────────────────────
465
+
466
+ # Resolve the DB adapter name, preferring live config over schema introspection
467
+ def resolve_db_adapter(ctx, schema)
468
+ adapter = schema[:adapter]
469
+
470
+ # If the schema introspector returned a non-informative adapter name, try config
471
+ if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
472
+ config_data = ctx[:config]
473
+ if config_data.is_a?(Hash) && !config_data[:error]
474
+ live_adapter = config_data[:database_adapter] || config_data[:adapter]
475
+ adapter = live_adapter if live_adapter
476
+ end
477
+ end
478
+
479
+ # Try to resolve from gems as a fallback
480
+ if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
481
+ gems_data = ctx[:gems]
482
+ if gems_data.is_a?(Hash) && !gems_data[:error]
483
+ notable = gems_data[:notable_gems] || []
484
+ adapter = "PostgreSQL" if notable.any? { |g| g[:name] == "pg" }
485
+ adapter = "MySQL" if notable.any? { |g| g[:name] == "mysql2" }
486
+ adapter = "SQLite" if notable.any? { |g| g[:name] == "sqlite3" }
487
+ end
488
+ end
489
+
490
+ adapter || "unknown"
491
+ end
492
+
493
+ def central_models(models, limit = 5)
494
+ models
495
+ .select { |_, d| d.is_a?(Hash) && !d[:error] }
496
+ .sort_by { |_, d| -(d[:associations]&.size || 0) }
497
+ .first(limit)
498
+ .map(&:first)
499
+ end
500
+
501
+ # ── Purpose inference ────────────────────────────────────────────
502
+
503
+ # Infer a short description of what the app does from its jobs,
504
+ # services, models, gems, and architecture patterns.
505
+ def infer_app_purpose(ctx)
506
+ signals = collect_purpose_signals(ctx)
507
+ return nil if signals.empty?
508
+
509
+ # Deduplicate and join into a natural phrase
510
+ capabilities = signals.uniq
511
+ return nil if capabilities.empty?
512
+
513
+ "#{capabilities.shift} app#{capabilities.any? ? ' with ' + join_capabilities(capabilities) : ''}"
514
+ end
515
+
516
+ # Collect domain signals from jobs, services, models, gems, and conventions
517
+ def collect_purpose_signals(ctx) # rubocop:disable Metrics
518
+ signals = []
519
+
520
+ # Gather raw names from all sources
521
+ job_names = extract_job_names(ctx)
522
+ service_names = extract_service_names
523
+ model_names = extract_model_names(ctx)
524
+ gem_names = extract_gem_names(ctx)
525
+ architecture = extract_architecture(ctx)
526
+
527
+ # Infer primary domain from model names
528
+ signals.concat(infer_domain(model_names, job_names, service_names))
529
+
530
+ # Infer capabilities from jobs and services
531
+ signals.concat(infer_ingestion_sources(job_names, service_names))
532
+ signals.concat(infer_federation(service_names, gem_names, model_names))
533
+ signals.concat(infer_ai_processing(service_names, job_names, gem_names))
534
+ signals.concat(infer_social_features(model_names, service_names))
535
+ signals.concat(infer_notifications(service_names, job_names))
536
+ signals.concat(infer_search(gem_names, architecture))
537
+ signals.concat(infer_ecommerce(model_names, service_names, gem_names))
538
+ signals.concat(infer_messaging(model_names, job_names))
539
+
540
+ signals
541
+ end
542
+
543
+ def extract_job_names(ctx)
544
+ jobs = ctx[:jobs]
545
+ return [] unless jobs.is_a?(Hash) && !jobs[:error]
546
+ (jobs[:jobs] || []).map { |j| j[:name].to_s }.reject(&:empty?)
547
+ end
548
+
549
+ def extract_service_names
550
+ services_dir = File.join(Rails.root, "app", "services")
551
+ return [] unless Dir.exist?(services_dir)
552
+
553
+ Dir.glob(File.join(services_dir, "**", "*.rb")).filter_map do |path|
554
+ name = File.basename(path, ".rb").camelize
555
+ name unless name == "ApplicationService" || name == "BaseService"
556
+ end
557
+ rescue
558
+ []
559
+ end
560
+
561
+ def extract_model_names(ctx)
562
+ models = ctx[:models]
563
+ return [] unless models.is_a?(Hash) && !models[:error]
564
+ models.keys.map(&:to_s)
565
+ end
566
+
567
+ def extract_gem_names(ctx)
568
+ gems = ctx[:gems]
569
+ return [] unless gems.is_a?(Hash) && !gems[:error]
570
+ (gems[:notable_gems] || []).map { |g| g[:name].to_s }
571
+ end
572
+
573
+ def extract_architecture(ctx)
574
+ conv = ctx[:conventions]
575
+ return [] unless conv.is_a?(Hash) && !conv[:error]
576
+ conv[:architecture] || []
577
+ end
578
+
579
+ # Infer the primary domain of the app (e.g., "news aggregation", "e-commerce")
580
+ def infer_domain(model_names, job_names, service_names)
581
+ all_names = (model_names + job_names + service_names).map(&:downcase).join(" ")
582
+
583
+ # Order matters: more specific patterns first
584
+ if all_names.match?(/article|news|rss|feed/) && all_names.match?(/site|source|feed/)
585
+ [ "news aggregation" ]
586
+ elsif all_names.match?(/article|blog|post/) && all_names.match?(/comment|author/)
587
+ [ "content publishing" ]
588
+ elsif all_names.match?(/product|cart|order|checkout/)
589
+ [ "e-commerce" ]
590
+ elsif all_names.match?(/patient|appointment|doctor|medical/)
591
+ [ "healthcare" ]
592
+ elsif all_names.match?(/course|lesson|student|enrollment/)
593
+ [ "education/LMS" ]
594
+ elsif all_names.match?(/listing|property|booking|reservation/)
595
+ [ "marketplace" ]
596
+ elsif all_names.match?(/ticket|issue|sprint|project/) && all_names.match?(/assign|board/)
597
+ [ "project management" ]
598
+ elsif all_names.match?(/message|conversation|chat|thread/)
599
+ [ "messaging" ]
600
+ elsif all_names.match?(/invoice|payment|subscription|billing/)
601
+ [ "billing/SaaS" ]
602
+ elsif all_names.match?(/post|comment|follow|like|feed/)
603
+ [ "social platform" ]
604
+ elsif all_names.match?(/article|post|page|content/)
605
+ [ "content management" ]
606
+ else
607
+ []
608
+ end
609
+ end
610
+
611
+ # Infer content ingestion sources from job/service names
612
+ def infer_ingestion_sources(job_names, service_names)
613
+ all_names = (job_names + service_names).map(&:downcase)
614
+ sources = []
615
+
616
+ sources << "RSS" if all_names.any? { |n| n.include?("rss") }
617
+ sources << "YouTube" if all_names.any? { |n| n.include?("youtube") }
618
+ sources << "HackerNews" if all_names.any? { |n| n.include?("hackernews") || n.include?("hacker_news") }
619
+ sources << "Reddit" if all_names.any? { |n| n.include?("reddit") }
620
+ sources << "Gmail" if all_names.any? { |n| n.include?("gmail") }
621
+ sources << "Twitter" if all_names.any? { |n| n.include?("twitter") }
622
+
623
+ return [] if sources.empty?
624
+ [ "#{sources.join(', ')} ingestion" ]
625
+ end
626
+
627
+ # Infer ActivityPub/federation features
628
+ def infer_federation(service_names, gem_names, model_names)
629
+ all = (service_names + gem_names + model_names).map(&:downcase)
630
+
631
+ if all.any? { |n| n.match?(/mastodon|activitypub|federails|federation/) }
632
+ [ "ActivityPub federation" ]
633
+ elsif all.any? { |n| n.match?(/fediverse/) }
634
+ [ "Fediverse integration" ]
635
+ else
636
+ []
637
+ end
638
+ end
639
+
640
+ # Infer AI/ML processing features
641
+ def infer_ai_processing(service_names, job_names, gem_names)
642
+ all = (service_names + job_names + gem_names).map(&:downcase)
643
+
644
+ if all.any? { |n| n.match?(/agent|openai|anthropic|llm|ai_|_ai/) }
645
+ [ "AI processing" ]
646
+ elsif all.any? { |n| n.match?(/ml_|machine_learn|predict/) }
647
+ [ "ML processing" ]
648
+ else
649
+ []
650
+ end
651
+ end
652
+
653
+ # Infer social features (follows, likes, etc.)
654
+ def infer_social_features(model_names, service_names)
655
+ all = (model_names + service_names).map(&:downcase)
656
+
657
+ if all.any? { |n| n.match?(/follow|like|mention|social/) } && all.any? { |n| n.match?(/federation|mastodon/) }
658
+ [] # Already covered by federation
659
+ elsif all.any? { |n| n.match?(/oauth|social_media/) }
660
+ [ "social media integration" ]
661
+ else
662
+ []
663
+ end
664
+ end
665
+
666
+ # Infer push/notification features
667
+ def infer_notifications(service_names, job_names)
668
+ all = (service_names + job_names).map(&:downcase)
669
+
670
+ if all.any? { |n| n.match?(/push_notif|web_push|notification/) }
671
+ [ "push notifications" ]
672
+ else
673
+ []
674
+ end
675
+ end
676
+
677
+ # Infer search capabilities
678
+ def infer_search(gem_names, architecture)
679
+ all = (gem_names + architecture).map(&:downcase)
680
+
681
+ if all.any? { |n| n.match?(/elasticsearch|searchkick|meilisearch/) }
682
+ [ "full-text search" ]
683
+ else
684
+ []
685
+ end
686
+ end
687
+
688
+ # Infer e-commerce features
689
+ def infer_ecommerce(model_names, service_names, gem_names)
690
+ all = (model_names + service_names + gem_names).map(&:downcase)
691
+
692
+ if all.any? { |n| n.match?(/stripe|pay\b|braintree/) }
693
+ [ "payment processing" ]
694
+ else
695
+ []
696
+ end
697
+ end
698
+
699
+ # Infer messaging/real-time features
700
+ def infer_messaging(model_names, job_names)
701
+ all = (model_names + job_names).map(&:downcase)
702
+
703
+ if all.any? { |n| n.match?(/conversation|chat|direct_message/) }
704
+ [ "real-time messaging" ]
705
+ else
706
+ []
707
+ end
708
+ end
709
+
710
+ # Quick one-line frontend summary from conventions
711
+ def quick_frontend_summary(ctx)
712
+ conv = ctx[:conventions]
713
+ return nil unless conv.is_a?(Hash) && !conv[:error]
714
+
715
+ arch = conv[:architecture] || []
716
+ parts = []
717
+
718
+ parts << "Hotwire" if arch.include?("hotwire")
719
+ parts << "Phlex" if arch.include?("phlex")
720
+ parts << "ViewComponent" if arch.include?("view_components") && !arch.include?("phlex")
721
+ parts << "Stimulus" if arch.include?("stimulus") && !arch.include?("hotwire")
722
+ parts << "React" if arch.include?("react")
723
+ parts << "Vue" if arch.include?("vue")
724
+
725
+ # Check frontend frameworks introspection too
726
+ frontend = ctx[:frontend_frameworks]
727
+ if frontend.is_a?(Hash) && !frontend[:error]
728
+ frameworks = frontend[:frameworks]
729
+ if frameworks.is_a?(Hash)
730
+ frameworks.each_key do |name|
731
+ n = name.to_s.downcase
732
+ parts << "React" if n.include?("react") && !parts.include?("React")
733
+ parts << "Vue" if n.include?("vue") && !parts.include?("Vue")
734
+ parts << "Angular" if n.include?("angular") && !parts.include?("Angular")
735
+ parts << "Svelte" if n.include?("svelte") && !parts.include?("Svelte")
736
+ end
737
+ end
738
+ end
739
+
740
+ parts.any? ? "#{parts.join(' + ')} frontend" : nil
741
+ end
742
+
743
+ # Join a list of capabilities with commas and "and" before the last
744
+ def join_capabilities(items)
745
+ case items.size
746
+ when 0 then ""
747
+ when 1 then items.first
748
+ when 2 then "#{items[0]} and #{items[1]}"
749
+ else "#{items[0..-2].join(', ')}, and #{items.last}"
750
+ end
751
+ end
752
+ end
753
+ end
754
+ end
755
+ end