rails-ai-context 4.2.2 → 4.3.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,453 @@
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
+ parts = [ "**#{app}** is a Rails #{ctx[:rails_version]} application running Ruby #{ctx[:ruby_version]}" ]
47
+
48
+ schema = ctx[:schema]
49
+ if schema.is_a?(Hash) && !schema[:error]
50
+ parts << "on #{schema[:adapter]} with #{schema[:total_tables]} tables"
51
+ end
52
+
53
+ models = ctx[:models]
54
+ if models.is_a?(Hash) && !models[:error] && models.any?
55
+ top = central_models(models, 3).join(", ")
56
+ parts << "— #{models.size} models (key: #{top})"
57
+ end
58
+
59
+ tests = ctx[:tests]
60
+ if tests.is_a?(Hash) && !tests[:error]
61
+ parts << "— tested with #{tests[:framework] || 'unknown framework'}"
62
+ end
63
+
64
+ parts.join(" ") + "."
65
+ end
66
+
67
+ # ── Standard: structured walkthrough ─────────────────────────────
68
+
69
+ def compose_standard(ctx) # rubocop:disable Metrics
70
+ lines = [ "# Welcome to #{ctx[:app_name] || 'This Rails App'}", "" ]
71
+ lines.concat(section_stack(ctx))
72
+ lines.concat(section_data_model(ctx))
73
+ lines.concat(section_auth(ctx))
74
+ lines.concat(section_key_flows(ctx))
75
+ lines.concat(section_jobs(ctx))
76
+ lines.concat(section_frontend(ctx))
77
+ lines.concat(section_testing(ctx))
78
+ lines.concat(section_getting_started(ctx))
79
+ lines.join("\n")
80
+ end
81
+
82
+ # ── Full: standard + all subsystems ──────────────────────────────
83
+
84
+ def compose_full(ctx) # rubocop:disable Metrics
85
+ lines = [ "# Welcome to #{ctx[:app_name] || 'This Rails App'} (Full Walkthrough)", "" ]
86
+ lines.concat(section_stack(ctx))
87
+ lines.concat(section_data_model(ctx))
88
+ lines.concat(section_auth(ctx))
89
+ lines.concat(section_key_flows(ctx))
90
+ lines.concat(section_jobs(ctx))
91
+ lines.concat(section_frontend(ctx))
92
+ lines.concat(section_payments(ctx))
93
+ lines.concat(section_realtime(ctx))
94
+ lines.concat(section_storage(ctx))
95
+ lines.concat(section_api(ctx))
96
+ lines.concat(section_devops(ctx))
97
+ lines.concat(section_i18n(ctx))
98
+ lines.concat(section_engines(ctx))
99
+ lines.concat(section_env(ctx))
100
+ lines.concat(section_testing(ctx))
101
+ lines.concat(section_getting_started(ctx))
102
+ lines.join("\n")
103
+ end
104
+
105
+ # ── Section builders ─────────────────────────────────────────────
106
+
107
+ def section_stack(ctx)
108
+ lines = [ "## Stack", "" ]
109
+ schema = ctx[:schema]
110
+ db = schema.is_a?(Hash) && !schema[:error] ? "#{schema[:adapter]} (#{schema[:total_tables]} tables)" : "unknown"
111
+ lines << "#{ctx[:app_name]} is a Rails #{ctx[:rails_version]} application running Ruby #{ctx[:ruby_version]} on #{db}."
112
+
113
+ gems = ctx[:gems]
114
+ if gems.is_a?(Hash) && !gems[:error]
115
+ notable = gems[:notable_gems] || []
116
+ if notable.any?
117
+ by_cat = notable.group_by { |g| g[:category]&.to_s || "other" }
118
+ gem_parts = by_cat.first(5).map { |cat, list| "#{cat}: #{list.map { |g| g[:name] }.join(', ')}" }
119
+ lines << "Notable gems — #{gem_parts.join('; ')}."
120
+ end
121
+ end
122
+
123
+ conv = ctx[:conventions]
124
+ if conv.is_a?(Hash) && !conv[:error]
125
+ arch = conv[:architecture] || []
126
+ lines << "Architecture: #{arch.join(', ')}." if arch.any?
127
+ end
128
+
129
+ lines << ""
130
+ lines
131
+ end
132
+
133
+ def section_data_model(ctx)
134
+ models = ctx[:models]
135
+ return [] unless models.is_a?(Hash) && !models[:error] && models.any?
136
+
137
+ lines = [ "## Data Model", "" ]
138
+ top = central_models(models, 7)
139
+ lines << "The app has #{models.size} models. The central ones are:"
140
+ lines << ""
141
+
142
+ top.each do |name|
143
+ data = models[name]
144
+ next unless data.is_a?(Hash) && !data[:error]
145
+ assocs = (data[:associations] || []).map { |a| "#{a[:type]} :#{a[:name]}" }
146
+ val_count = (data[:validations] || []).size
147
+ desc = "**#{name}**"
148
+ desc += " (table: `#{data[:table_name]}`)" if data[:table_name]
149
+ desc += " — #{assocs.first(4).join(', ')}" if assocs.any?
150
+ desc += ", +#{assocs.size - 4} more" if assocs.size > 4
151
+ desc += ". #{val_count} validations." if val_count > 0
152
+ lines << "- #{desc}"
153
+ end
154
+
155
+ remaining = models.size - top.size
156
+ lines << "- _...and #{remaining} more models._" if remaining > 0
157
+ lines << ""
158
+ lines
159
+ end
160
+
161
+ def section_auth(ctx)
162
+ 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
+ lines = [ "## Authentication & Authorization", "" ]
170
+ if authentication[:method]
171
+ lines << "Authentication is handled by #{authentication[:method]}."
172
+ end
173
+ if authentication[:model]
174
+ lines << "The #{authentication[:model]} model handles user accounts."
175
+ end
176
+ if authorization[:method]
177
+ lines << "Authorization uses #{authorization[:method]}."
178
+ end
179
+ lines << ""
180
+ lines
181
+ end
182
+
183
+ def section_key_flows(ctx)
184
+ routes = ctx[:routes]
185
+ controllers = ctx[:controllers]
186
+ return [] unless routes.is_a?(Hash) && !routes[:error]
187
+
188
+ lines = [ "## Key Flows", "" ]
189
+ by_ctrl = routes[:by_controller] || {}
190
+
191
+ # Find controllers with most actions (most important flows)
192
+ internal_prefixes = %w[action_mailbox/ active_storage/ rails/ conductor/ devise/ turbo/]
193
+ app_ctrls = by_ctrl.reject { |k, _| internal_prefixes.any? { |p| k.downcase.start_with?(p) } }
194
+ top_ctrls = app_ctrls.sort_by { |_, routes_list| -routes_list.size }.first(5)
195
+
196
+ top_ctrls.each do |ctrl, ctrl_routes|
197
+ actions = ctrl_routes.map { |r| r[:action] }.compact.uniq
198
+ verbs = ctrl_routes.map { |r| "#{r[:verb]} #{r[:path]}" }.first(3)
199
+ lines << "- **#{ctrl}** — #{actions.join(', ')} (#{verbs.join(', ')})"
200
+ end
201
+
202
+ lines << ""
203
+ lines << "Total: #{routes[:total_routes]} routes across #{app_ctrls.size} controllers."
204
+ lines << ""
205
+ lines
206
+ end
207
+
208
+ def section_jobs(ctx)
209
+ jobs = ctx[:jobs]
210
+ return [] unless jobs.is_a?(Hash) && !jobs[:error]
211
+
212
+ job_list = jobs[:jobs] || []
213
+ mailers = jobs[:mailers] || []
214
+ channels = jobs[:channels] || []
215
+ return [] if job_list.empty? && mailers.empty? && channels.empty?
216
+
217
+ lines = [ "## Background Jobs & Async", "" ]
218
+ if job_list.any?
219
+ names = job_list.map { |j| j[:name] || j[:class_name] }.compact.first(8)
220
+ lines << "#{job_list.size} background jobs: #{names.join(', ')}#{job_list.size > 8 ? ', ...' : ''}."
221
+ end
222
+ lines << "#{mailers.size} mailers." if mailers.any?
223
+ lines << "#{channels.size} Action Cable channels." if channels.any?
224
+ lines << ""
225
+ lines
226
+ end
227
+
228
+ def section_frontend(ctx)
229
+ frontend = ctx[:frontend_frameworks]
230
+ stimulus = ctx[:stimulus]
231
+ turbo = ctx[:turbo]
232
+
233
+ lines = []
234
+ has_content = false
235
+
236
+ if frontend.is_a?(Hash) && !frontend[:error] && frontend[:frameworks]&.any?
237
+ lines << "## Frontend" << ""
238
+ frameworks = frontend[:frameworks]
239
+ if frameworks.is_a?(Hash)
240
+ frameworks.each { |name, version| lines << "- #{name} #{version}".strip }
241
+ elsif frameworks.is_a?(Array)
242
+ frameworks.each { |fw| lines << "- #{fw.is_a?(Hash) ? "#{fw[:name]} #{fw[:version]}" : fw}".strip }
243
+ end
244
+ has_content = true
245
+ end
246
+
247
+ if stimulus.is_a?(Hash) && !stimulus[:error]
248
+ count = stimulus[:total_controllers] || stimulus[:controllers]&.size || 0
249
+ if count > 0
250
+ lines << "## Frontend" << "" unless has_content
251
+ lines << "Stimulus: #{count} controllers for interactive behavior."
252
+ has_content = true
253
+ end
254
+ end
255
+
256
+ if turbo.is_a?(Hash) && !turbo[:error]
257
+ frames = turbo[:frames]&.size || 0
258
+ streams = turbo[:streams]&.size || 0
259
+ if frames > 0 || streams > 0
260
+ lines << "## Frontend" << "" unless has_content
261
+ parts = []
262
+ parts << "#{frames} Turbo Frames" if frames > 0
263
+ parts << "#{streams} Turbo Streams" if streams > 0
264
+ lines << "Hotwire: #{parts.join(', ')}."
265
+ has_content = true
266
+ end
267
+ end
268
+
269
+ lines << "" if has_content
270
+ lines
271
+ end
272
+
273
+ def section_testing(ctx)
274
+ tests = ctx[:tests]
275
+ return [] unless tests.is_a?(Hash) && !tests[:error]
276
+
277
+ lines = [ "## Testing", "" ]
278
+ framework = tests[:framework] || "unknown"
279
+ lines << "Framework: #{framework}."
280
+
281
+ factories = tests[:factories]
282
+ fixtures = tests[:fixtures]
283
+ lines << "Data setup: #{factories ? "FactoryBot (#{factories[:count]} factories)" : fixtures ? "fixtures (#{fixtures[:count]} files)" : "inline"}."
284
+
285
+ ci = tests[:ci_config]
286
+ lines << "CI: #{ci.join(', ')}." if ci&.any?
287
+
288
+ coverage = tests[:coverage]
289
+ lines << "Coverage: #{coverage}." if coverage
290
+
291
+ test_cmd = framework == "rspec" ? "bundle exec rspec" : "rails test"
292
+ lines << "" << "Run tests: `#{test_cmd}`"
293
+ lines << ""
294
+ lines
295
+ end
296
+
297
+ def section_getting_started(ctx)
298
+ test_cmd = (ctx[:tests].is_a?(Hash) && ctx[:tests][:framework] == "rspec") ? "bundle exec rspec" : "rails test"
299
+ [
300
+ "## Getting Started", "",
301
+ "```bash",
302
+ "git clone <repo-url>",
303
+ "cd #{ctx[:app_name]&.underscore || 'app'}",
304
+ "bundle install",
305
+ "rails db:setup",
306
+ "bin/dev # or rails server",
307
+ "#{test_cmd} # verify everything works",
308
+ "```", ""
309
+ ]
310
+ end
311
+
312
+ # ── Full-only sections ───────────────────────────────────────────
313
+
314
+ def section_payments(ctx)
315
+ gems = ctx[:gems]
316
+ models = ctx[:models]
317
+ return [] unless gems.is_a?(Hash) && !gems[:error] && models.is_a?(Hash)
318
+
319
+ payment_gems = %w[stripe pay braintree paddle_pay]
320
+ notable = gems[:notable_gems] || []
321
+ found = notable.select { |g| payment_gems.include?(g[:name]) }
322
+ payment_models = models.keys.select { |m| m.downcase.match?(/payment|subscription|charge|invoice|plan|billing/) }
323
+ return [] if found.empty? && payment_models.empty?
324
+
325
+ lines = [ "## Payments", "" ]
326
+ lines << "Payment gems: #{found.map { |g| g[:name] }.join(', ')}." if found.any?
327
+ lines << "Payment-related models: #{payment_models.join(', ')}." if payment_models.any?
328
+ lines << ""
329
+ lines
330
+ end
331
+
332
+ def section_realtime(ctx)
333
+ turbo = ctx[:turbo]
334
+ jobs = ctx[:jobs]
335
+ channels = (jobs.is_a?(Hash) ? jobs[:channels] : nil) || []
336
+ return [] unless (turbo.is_a?(Hash) && !turbo[:error]) || channels.any?
337
+
338
+ lines = [ "## Real-Time Features", "" ]
339
+ if channels.any?
340
+ names = channels.map { |c| c[:name] || c[:class_name] }.compact
341
+ lines << "Action Cable channels: #{names.join(', ')}."
342
+ end
343
+ if turbo.is_a?(Hash) && !turbo[:error] && turbo[:broadcasts]&.any?
344
+ lines << "Turbo Stream broadcasts: #{turbo[:broadcasts].size} broadcast points."
345
+ end
346
+ lines << ""
347
+ lines
348
+ end
349
+
350
+ def section_storage(ctx)
351
+ storage = ctx[:active_storage]
352
+ text = ctx[:action_text]
353
+ return [] unless (storage.is_a?(Hash) && !storage[:error]) || (text.is_a?(Hash) && !text[:error])
354
+
355
+ lines = [ "## File Storage & Rich Text", "" ]
356
+ if storage.is_a?(Hash) && !storage[:error] && storage[:attachments]&.any?
357
+ lines << "Active Storage: #{storage[:attachments].size} attachment(s) across models."
358
+ end
359
+ if text.is_a?(Hash) && !text[:error] && text[:models]&.any?
360
+ lines << "Action Text: #{text[:models].size} model(s) with rich text fields."
361
+ end
362
+ lines << ""
363
+ lines
364
+ end
365
+
366
+ def section_api(ctx)
367
+ api = ctx[:api]
368
+ return [] unless api.is_a?(Hash) && !api[:error]
369
+ return [] if api.empty? || (api[:endpoints]&.empty? && api[:graphql].nil?)
370
+
371
+ lines = [ "## API", "" ]
372
+ if api[:graphql]
373
+ lines << "GraphQL API detected."
374
+ end
375
+ if api[:endpoints]&.any?
376
+ lines << "#{api[:endpoints].size} API endpoint(s)."
377
+ end
378
+ if api[:serializers]&.any?
379
+ lines << "Serializers: #{api[:serializers].size}."
380
+ end
381
+ lines << ""
382
+ lines
383
+ end
384
+
385
+ def section_devops(ctx)
386
+ devops = ctx[:devops]
387
+ return [] unless devops.is_a?(Hash) && !devops[:error]
388
+
389
+ 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
394
+ lines << ""
395
+ lines
396
+ end
397
+
398
+ def section_i18n(ctx)
399
+ i18n = ctx[:i18n]
400
+ return [] unless i18n.is_a?(Hash) && !i18n[:error]
401
+
402
+ locales = i18n[:locales] || []
403
+ return [] if locales.empty?
404
+
405
+ [ "## Internationalization", "", "Locales: #{locales.join(', ')}.", "" ]
406
+ end
407
+
408
+ def section_engines(ctx)
409
+ engines = ctx[:engines]
410
+ return [] unless engines.is_a?(Hash) && !engines[:error]
411
+
412
+ mounted = engines[:engines] || engines[:mounted] || []
413
+ return [] if mounted.empty?
414
+
415
+ lines = [ "## Mounted Engines", "" ]
416
+ mounted.each do |e|
417
+ name = e[:name] || e[:engine]
418
+ path = e[:path] || e[:mount_path]
419
+ lines << "- **#{name}** at `#{path}`" if name
420
+ end
421
+ lines << ""
422
+ lines
423
+ end
424
+
425
+ def section_env(ctx)
426
+ # Summarize from models that have encrypts, and auth/payment-related env patterns
427
+ models = ctx[:models]
428
+ return [] unless models.is_a?(Hash) && !models[:error]
429
+
430
+ encrypted = models.select { |_, d| d.is_a?(Hash) && d[:encrypts]&.any? }
431
+ return [] if encrypted.empty?
432
+
433
+ lines = [ "## Encrypted Data", "" ]
434
+ encrypted.each do |name, data|
435
+ lines << "- **#{name}**: encrypts #{data[:encrypts].join(', ')}"
436
+ end
437
+ lines << ""
438
+ lines
439
+ end
440
+
441
+ # ── Helpers ──────────────────────────────────────────────────────
442
+
443
+ def central_models(models, limit = 5)
444
+ models
445
+ .select { |_, d| d.is_a?(Hash) && !d[:error] }
446
+ .sort_by { |_, d| -(d[:associations]&.size || 0) }
447
+ .first(limit)
448
+ .map(&:first)
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
@@ -175,12 +175,24 @@ module RailsAiContext
175
175
 
176
176
  private_class_method def self.execute_sqlite(conn, sql, timeout)
177
177
  raw = conn.raw_connection
178
- raw.busy_timeout = (timeout * 1000).to_i
179
178
  result = nil
180
179
  begin
181
180
  conn.execute("PRAGMA query_only = ON")
181
+ # SQLite has no native statement timeout. Use a progress handler
182
+ # to abort queries that run too long (checked every 1000 VM steps).
183
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
184
+ if raw.respond_to?(:set_progress_handler)
185
+ raw.set_progress_handler(1000) do
186
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
187
+ 1 # non-zero = abort
188
+ else
189
+ 0
190
+ end
191
+ end
192
+ end
182
193
  result = conn.select_all(sql)
183
194
  ensure
195
+ raw.set_progress_handler(0, nil) if raw.respond_to?(:set_progress_handler)
184
196
  conn.execute("PRAGMA query_only = OFF")
185
197
  end
186
198
  result