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,571 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class GenerateTest < BaseTool
6
+ tool_name "rails_generate_test"
7
+ description "Generate test scaffolding that matches your project's actual test patterns — framework, factories, assertion style. " \
8
+ "Use when: adding tests for a model, controller, or service. Generates copy-paste-ready test files. " \
9
+ "Key params: model (e.g. 'User'), controller (e.g. 'CooksController'), file (e.g. 'app/services/foo.rb')."
10
+
11
+ input_schema(
12
+ properties: {
13
+ model: {
14
+ type: "string",
15
+ description: "Model name (e.g. 'User'). Generates model spec with validations, associations, scopes, enums."
16
+ },
17
+ controller: {
18
+ type: "string",
19
+ description: "Controller name (e.g. 'CooksController'). Generates request spec with routes and auth."
20
+ },
21
+ file: {
22
+ type: "string",
23
+ description: "File path relative to Rails root (e.g. 'app/services/payment_service.rb'). Auto-detects type."
24
+ },
25
+ type: {
26
+ type: "string",
27
+ enum: %w[unit request system],
28
+ description: "Test type: unit (model/service, default), request (controller), system (browser/Capybara)."
29
+ }
30
+ }
31
+ )
32
+
33
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
34
+
35
+ def self.call(model: nil, controller: nil, file: nil, type: "unit", server_context: nil)
36
+ unless model || controller || file
37
+ return text_response("Provide at least one of: `model`, `controller`, or `file`.")
38
+ end
39
+
40
+ tests_data = cached_context[:tests] || {}
41
+ framework = tests_data[:framework] || detect_framework
42
+ patterns = detect_patterns(framework)
43
+
44
+ if model
45
+ generate_model_test(model.strip, framework, patterns, tests_data)
46
+ elsif controller
47
+ generate_controller_test(controller.strip, framework, patterns, tests_data)
48
+ elsif file
49
+ generate_file_test(file.strip, framework, patterns, tests_data, type)
50
+ end
51
+ rescue => e
52
+ text_response("Generate test error: #{e.message}")
53
+ end
54
+
55
+ class << self
56
+ private
57
+
58
+ def detect_framework
59
+ if Dir.exist?(File.join(Rails.root, "spec"))
60
+ "rspec"
61
+ else
62
+ "minitest"
63
+ end
64
+ end
65
+
66
+ # Scan existing tests to learn project patterns
67
+ def detect_patterns(framework) # rubocop:disable Metrics
68
+ root = Rails.root.to_s
69
+ patterns = { factory_style: :create, let_style: true, expect_style: true, described_class: true }
70
+
71
+ glob = framework == "rspec" ? "spec/**/*_spec.rb" : "test/**/*_test.rb"
72
+ files = Dir.glob(File.join(root, glob)).first(5)
73
+
74
+ expect_count = 0
75
+ should_count = 0
76
+ create_count = 0
77
+ build_count = 0
78
+ let_count = 0
79
+ instance_var_count = 0
80
+
81
+ files.each do |f|
82
+ next if File.size(f) > config.max_test_file_size
83
+ source = File.read(f, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
84
+ expect_count += source.scan(/expect\(/).size
85
+ should_count += source.scan(/\.should\b/).size
86
+ create_count += source.scan(/create\(:/).size
87
+ build_count += source.scan(/build\(:/).size
88
+ let_count += source.scan(/\blet[!]?\(:/).size
89
+ instance_var_count += source.scan(/@\w+\s*=/).size
90
+ end
91
+
92
+ patterns[:expect_style] = expect_count >= should_count
93
+ patterns[:factory_style] = create_count >= build_count ? :create : :build
94
+ patterns[:let_style] = let_count > instance_var_count
95
+ patterns
96
+ end
97
+
98
+ # ── Model test generation ────────────────────────────────────────
99
+
100
+ def generate_model_test(model_name, framework, patterns, tests_data)
101
+ models = cached_context[:models] || {}
102
+ key = models.keys.find { |k| k.downcase == model_name.downcase } ||
103
+ models.keys.find { |k| k.underscore == model_name.underscore }
104
+ unless key
105
+ return not_found_response("Model", model_name, models.keys.sort,
106
+ recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
107
+ end
108
+
109
+ data = models[key]
110
+ return text_response("Model #{key} has errors: #{data[:error]}") if data[:error]
111
+
112
+ if framework == "rspec"
113
+ generate_rspec_model(key, data, patterns, tests_data)
114
+ else
115
+ generate_minitest_model(key, data, patterns, tests_data)
116
+ end
117
+ end
118
+
119
+ def generate_rspec_model(name, data, patterns, tests_data)
120
+ file_path = "spec/models/#{name.underscore}_spec.rb"
121
+ factory = find_factory_name(name, tests_data)
122
+ lines = []
123
+ lines << "# #{file_path}"
124
+ lines << ""
125
+ lines << "```ruby"
126
+ lines << "# frozen_string_literal: true"
127
+ lines << ""
128
+ lines << "require \"rails_helper\""
129
+ lines << ""
130
+ lines << "RSpec.describe #{name}, type: :model do"
131
+
132
+ # Factory/fixture setup
133
+ if factory
134
+ style = patterns[:factory_style]
135
+ if patterns[:let_style]
136
+ lines << " let(:#{name.underscore}) { #{style}(:#{factory}) }"
137
+ end
138
+ end
139
+
140
+ # Associations
141
+ assocs = data[:associations] || []
142
+ if assocs.any?
143
+ lines << ""
144
+ lines << " describe \"associations\" do"
145
+ assocs.each do |a|
146
+ case a[:type]
147
+ when "belongs_to"
148
+ lines << " it { is_expected.to belong_to(:#{a[:name]}) }"
149
+ when "has_many"
150
+ if a[:through]
151
+ lines << " it { is_expected.to have_many(:#{a[:name]}).through(:#{a[:through]}) }"
152
+ else
153
+ dep = a[:dependent] ? ".dependent(:#{a[:dependent]})" : ""
154
+ lines << " it { is_expected.to have_many(:#{a[:name]})#{dep} }"
155
+ end
156
+ when "has_one"
157
+ lines << " it { is_expected.to have_one(:#{a[:name]}) }"
158
+ end
159
+ end
160
+ lines << " end"
161
+ end
162
+
163
+ # Validations
164
+ validations = data[:validations] || []
165
+ if validations.any?
166
+ lines << ""
167
+ lines << " describe \"validations\" do"
168
+ seen = Set.new
169
+ validations.each do |v|
170
+ v[:attributes].each do |attr|
171
+ key = "#{v[:kind]}:#{attr}"
172
+ next if seen.include?(key)
173
+ seen << key
174
+
175
+ case v[:kind]
176
+ when "presence"
177
+ lines << " it { is_expected.to validate_presence_of(:#{attr}) }"
178
+ when "uniqueness"
179
+ lines << " it { is_expected.to validate_uniqueness_of(:#{attr}) }"
180
+ when "length"
181
+ opts = v[:options] || {}
182
+ matcher = "validate_length_of(:#{attr})"
183
+ matcher += ".is_at_most(#{opts[:maximum]})" if opts[:maximum]
184
+ matcher += ".is_at_least(#{opts[:minimum]})" if opts[:minimum]
185
+ lines << " it { is_expected.to #{matcher} }"
186
+ when "numericality"
187
+ lines << " it { is_expected.to validate_numericality_of(:#{attr}) }"
188
+ when "inclusion"
189
+ vals = v.dig(:options, :in)
190
+ if vals
191
+ lines << " it { is_expected.to validate_inclusion_of(:#{attr}).in_array(#{vals.inspect}) }"
192
+ else
193
+ lines << " it { is_expected.to validate_inclusion_of(:#{attr}) }"
194
+ end
195
+ else
196
+ lines << " it \"validates #{v[:kind]} of #{attr}\" do"
197
+ lines << " # TODO: implement #{v[:kind]} validation test"
198
+ lines << " end"
199
+ end
200
+ end
201
+ end
202
+ lines << " end"
203
+ end
204
+
205
+ # Scopes
206
+ scopes = data[:scopes] || []
207
+ if scopes.any?
208
+ lines << ""
209
+ lines << " describe \"scopes\" do"
210
+ scopes.each do |s|
211
+ scope_name = s.is_a?(Hash) ? s[:name] : s
212
+ lines << " describe \".#{scope_name}\" do"
213
+ lines << " it \"returns expected records\" do"
214
+ lines << " # TODO: create test data and verify scope behavior"
215
+ lines << " end"
216
+ lines << " end"
217
+ end
218
+ lines << " end"
219
+ end
220
+
221
+ # Enums
222
+ enums = data[:enums] || {}
223
+ if enums.any?
224
+ lines << ""
225
+ lines << " describe \"enums\" do"
226
+ enums.each do |attr, values|
227
+ vals = values.is_a?(Hash) ? values.keys : Array(values)
228
+ lines << " it { is_expected.to define_enum_for(:#{attr}).with_values(#{vals.inspect}) }"
229
+ end
230
+ lines << " end"
231
+ end
232
+
233
+ # Callbacks
234
+ callbacks = data[:callbacks] || {}
235
+ if callbacks.any?
236
+ lines << ""
237
+ lines << " describe \"callbacks\" do"
238
+ callbacks.each do |type, methods|
239
+ Array(methods).each do |m|
240
+ lines << " it \"#{type} calls #{m}\" do"
241
+ lines << " # TODO: verify callback behavior"
242
+ lines << " end"
243
+ end
244
+ end
245
+ lines << " end"
246
+ end
247
+
248
+ lines << "end"
249
+ lines << "```"
250
+
251
+ text_response(lines.join("\n"))
252
+ end
253
+
254
+ def generate_minitest_model(name, data, _patterns, tests_data)
255
+ file_path = "test/models/#{name.underscore}_test.rb"
256
+ factory = find_factory_name(name, tests_data)
257
+ lines = []
258
+ lines << "# #{file_path}"
259
+ lines << ""
260
+ lines << "```ruby"
261
+ lines << "# frozen_string_literal: true"
262
+ lines << ""
263
+ lines << "require \"test_helper\""
264
+ lines << ""
265
+ lines << "class #{name}Test < ActiveSupport::TestCase"
266
+
267
+ setup_var = name.underscore
268
+ fixtures = tests_data[:fixtures]
269
+ fixture_names = tests_data[:fixture_names] || {}
270
+ # Determine data setup: factory > fixture > inline
271
+ lines << " setup do"
272
+ if factory
273
+ lines << " @#{setup_var} = create(:#{factory})"
274
+ elsif fixtures
275
+ fixture_key = fixture_names.keys.find { |k| k.to_s.downcase == name.underscore.pluralize } ? ":one" : ":one"
276
+ lines << " @#{setup_var} = #{name.underscore.pluralize}(#{fixture_key})"
277
+ else
278
+ lines << " @#{setup_var} = #{name}.new"
279
+ end
280
+ lines << " end"
281
+
282
+ # Validations
283
+ validations = data[:validations] || []
284
+ if validations.any?
285
+ lines << ""
286
+ seen = Set.new
287
+ validations.each do |v|
288
+ v[:attributes].each do |attr|
289
+ key = "#{v[:kind]}:#{attr}"
290
+ next if seen.include?(key)
291
+ seen << key
292
+ lines << " test \"validates #{v[:kind]} of #{attr}\" do"
293
+ case v[:kind]
294
+ when "presence"
295
+ lines << " @#{setup_var}.#{attr} = nil"
296
+ when "inclusion"
297
+ lines << " @#{setup_var}.#{attr} = \"__invalid_value__\""
298
+ when "uniqueness"
299
+ lines << " duplicate = @#{setup_var}.dup"
300
+ lines << " assert_not duplicate.valid?"
301
+ lines << " end"
302
+ lines << ""
303
+ next
304
+ when "numericality"
305
+ lines << " @#{setup_var}.#{attr} = \"not_a_number\""
306
+ when "length"
307
+ max = v.dig(:options, :maximum)
308
+ if max
309
+ lines << " @#{setup_var}.#{attr} = \"a\" * #{max + 1}"
310
+ else
311
+ lines << " @#{setup_var}.#{attr} = \"\""
312
+ end
313
+ when "format"
314
+ lines << " @#{setup_var}.#{attr} = \"invalid-format\""
315
+ end
316
+ lines << " assert_not @#{setup_var}.valid?"
317
+ lines << " end"
318
+ lines << ""
319
+ end
320
+ end
321
+ end
322
+
323
+ # Associations
324
+ assocs = data[:associations] || []
325
+ if assocs.any?
326
+ assocs.each do |a|
327
+ lines << " test \"#{a[:type]} #{a[:name]}\" do"
328
+ lines << " assert_respond_to @#{setup_var}, :#{a[:name]}"
329
+ lines << " end"
330
+ lines << ""
331
+ end
332
+ end
333
+
334
+ # Scopes
335
+ scopes = data[:scopes] || []
336
+ scopes.each do |s|
337
+ scope_name = s.is_a?(Hash) ? s[:name] : s
338
+ scope_body = s.is_a?(Hash) ? s[:body] : nil
339
+ lines << " test \"scope .#{scope_name} returns expected records\" do"
340
+ if scope_body&.include?("order")
341
+ lines << " sql = #{name}.#{scope_name}.to_sql"
342
+ lines << " assert_match(/ORDER BY/i, sql)"
343
+ elsif scope_body&.include?("where")
344
+ lines << " results = #{name}.#{scope_name}"
345
+ lines << " assert_kind_of ActiveRecord::Relation, results"
346
+ else
347
+ lines << " results = #{name}.#{scope_name}"
348
+ lines << " assert_kind_of ActiveRecord::Relation, results"
349
+ end
350
+ lines << " end"
351
+ lines << ""
352
+ end
353
+
354
+ lines << "end"
355
+ lines << "```"
356
+
357
+ text_response(lines.join("\n"))
358
+ end
359
+
360
+ # ── Controller test generation ───────────────────────────────────
361
+
362
+ def generate_controller_test(ctrl_name, framework, patterns, tests_data)
363
+ ctrl_name = ctrl_name.strip
364
+ # Normalize: "cooks" → "CooksController", "CooksController" stays
365
+ ctrl_class = ctrl_name.end_with?("Controller") ? ctrl_name : "#{ctrl_name.camelize}Controller"
366
+ snake = ctrl_class.underscore.delete_suffix("_controller")
367
+
368
+ routes = cached_context[:routes] || {}
369
+ by_ctrl = routes[:by_controller] || {}
370
+ ctrl_routes = by_ctrl[snake] || by_ctrl[snake.pluralize] || []
371
+
372
+ if framework == "rspec"
373
+ generate_rspec_request(ctrl_class, snake, ctrl_routes, patterns, tests_data)
374
+ else
375
+ generate_minitest_controller(ctrl_class, snake, ctrl_routes, tests_data)
376
+ end
377
+ end
378
+
379
+ def generate_rspec_request(ctrl_class, snake, routes, patterns, tests_data)
380
+ file_path = "spec/requests/#{snake}_spec.rb"
381
+ factory = find_factory_name(snake.singularize.camelize, tests_data)
382
+ has_devise = tests_data[:test_helper_setup]&.any? { |h| h.include?("Devise") }
383
+
384
+ lines = [ "# #{file_path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"rails_helper\"", "" ]
385
+ lines << "RSpec.describe \"#{ctrl_class}\", type: :request do"
386
+
387
+ if has_devise
388
+ lines << " include Devise::Test::IntegrationHelpers"
389
+ lines << ""
390
+ lines << " let(:user) { create(:user) }"
391
+ lines << " before { sign_in user }"
392
+ end
393
+
394
+ if factory
395
+ lines << " let(:#{snake.singularize}) { create(:#{factory}) }"
396
+ end
397
+
398
+ routes.each do |r|
399
+ verb = (r[:verb] || "GET").downcase
400
+ path = r[:path] || "/#{snake}"
401
+ action = r[:action] || "index"
402
+ helper = r[:name] ? "#{r[:name]}_path" : "\"#{path}\""
403
+
404
+ lines << ""
405
+ lines << " describe \"#{r[:verb]} #{r[:path]}\" do"
406
+
407
+ case action
408
+ when "index"
409
+ lines << " it \"returns success\" do"
410
+ lines << " get #{helper}"
411
+ lines << " expect(response).to have_http_status(:ok)"
412
+ lines << " end"
413
+ when "show"
414
+ lines << " it \"returns the #{snake.singularize}\" do"
415
+ lines << " get #{helper}#{"(id: #{snake.singularize}.id)" if r[:name]}"
416
+ lines << " expect(response).to have_http_status(:ok)"
417
+ lines << " end"
418
+ when "create"
419
+ lines << " context \"with valid params\" do"
420
+ lines << " it \"creates a new #{snake.singularize}\" do"
421
+ lines << " expect {"
422
+ lines << " post #{helper}, params: { #{snake.singularize}: valid_attributes }"
423
+ lines << " }.to change(#{snake.singularize.camelize}, :count).by(1)"
424
+ lines << " end"
425
+ lines << " end"
426
+ lines << ""
427
+ lines << " context \"with invalid params\" do"
428
+ lines << " it \"does not create\" do"
429
+ lines << " expect {"
430
+ lines << " post #{helper}, params: { #{snake.singularize}: invalid_attributes }"
431
+ lines << " }.not_to change(#{snake.singularize.camelize}, :count)"
432
+ lines << " end"
433
+ lines << " end"
434
+ when "update"
435
+ lines << " it \"updates the #{snake.singularize}\" do"
436
+ lines << " patch #{helper}#{"(id: #{snake.singularize}.id)" if r[:name]}, params: { #{snake.singularize}: { name: \"Updated\" } }"
437
+ lines << " expect(response).to redirect_to(#{snake.singularize})"
438
+ lines << " end"
439
+ when "destroy"
440
+ lines << " it \"destroys the #{snake.singularize}\" do"
441
+ lines << " #{snake.singularize} # ensure exists"
442
+ lines << " expect {"
443
+ lines << " delete #{helper}#{"(id: #{snake.singularize}.id)" if r[:name]}"
444
+ lines << " }.to change(#{snake.singularize.camelize}, :count).by(-1)"
445
+ lines << " end"
446
+ else
447
+ lines << " it \"returns success\" do"
448
+ lines << " #{verb} #{helper}"
449
+ lines << " expect(response).to have_http_status(:ok)"
450
+ lines << " end"
451
+ end
452
+
453
+ lines << " end"
454
+ end
455
+
456
+ if routes.empty?
457
+ lines << " it \"has tests\" do"
458
+ lines << " # No routes found for #{ctrl_class}. Add route-specific tests here."
459
+ lines << " end"
460
+ end
461
+
462
+ lines << "end"
463
+ lines << "```"
464
+ text_response(lines.join("\n"))
465
+ end
466
+
467
+ def generate_minitest_controller(ctrl_class, snake, routes, tests_data)
468
+ file_path = "test/controllers/#{snake}_controller_test.rb"
469
+ lines = [ "# #{file_path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"test_helper\"", "" ]
470
+ lines << "class #{ctrl_class}Test < ActionDispatch::IntegrationTest"
471
+
472
+ has_devise = tests_data[:test_helper_setup]&.any? { |h| h.include?("Devise") }
473
+ if has_devise
474
+ lines << " include Devise::Test::IntegrationHelpers"
475
+ lines << ""
476
+ lines << " setup do"
477
+ lines << " @user = users(:one)"
478
+ lines << " sign_in @user"
479
+ lines << " end"
480
+ end
481
+
482
+ routes.each do |r|
483
+ action = r[:action] || "index"
484
+ path = r[:path] || "/#{snake}"
485
+ verb = (r[:verb] || "GET").downcase
486
+
487
+ # Extract dynamic segments (e.g. :post_id, :id) from the path
488
+ param_names = path.scan(/:(\w+)/).flatten
489
+ quoted_path = path.gsub(/:(\w+)/, '#{\\1}')
490
+
491
+ lines << ""
492
+ lines << " test \"#{r[:verb]} #{r[:path]} works\" do"
493
+ param_names.each do |param|
494
+ if param == "id"
495
+ lines << " #{param} = #{snake.pluralize}(:one).id"
496
+ elsif param.end_with?("_id")
497
+ resource = param.delete_suffix("_id")
498
+ lines << " #{param} = #{resource.pluralize}(:one).id"
499
+ end
500
+ end
501
+ lines << " #{verb} \"#{quoted_path}\""
502
+ lines << " assert_response :success"
503
+ lines << " end"
504
+ end
505
+
506
+ lines << "end"
507
+ lines << "```"
508
+ text_response(lines.join("\n"))
509
+ end
510
+
511
+ # ── File-based test generation ───────────────────────────────────
512
+
513
+ def generate_file_test(file, framework, patterns, tests_data, type)
514
+ case file
515
+ when %r{app/models/(.+)\.rb}
516
+ model_name = $1.split("/").last.camelize
517
+ generate_model_test(model_name, framework, patterns, tests_data)
518
+ when %r{app/controllers/(.+)_controller\.rb}
519
+ ctrl_name = "#{$1.split('/').map(&:camelize).join('::')}Controller"
520
+ generate_controller_test(ctrl_name, framework, patterns, tests_data)
521
+ when %r{app/services/(.+)\.rb}, %r{app/jobs/(.+)\.rb}
522
+ class_name = $1.split("/").map(&:camelize).join("::")
523
+ generate_service_test(class_name, file, framework)
524
+ else
525
+ text_response("Cannot auto-detect test type for `#{file}`. Use `model:` or `controller:` parameter instead.")
526
+ end
527
+ end
528
+
529
+ def generate_service_test(class_name, file, framework)
530
+ if framework == "rspec"
531
+ path = "spec/services/#{class_name.underscore}_spec.rb"
532
+ lines = [ "# #{path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"rails_helper\"", "" ]
533
+ lines << "RSpec.describe #{class_name} do"
534
+ lines << " describe \".call\" do"
535
+ lines << " it \"performs the expected action\" do"
536
+ lines << " # TODO: set up input and verify output"
537
+ lines << " result = described_class.call"
538
+ lines << " expect(result).to be_truthy"
539
+ lines << " end"
540
+ lines << " end"
541
+ lines << "end"
542
+ lines << "```"
543
+ else
544
+ path = "test/services/#{class_name.underscore}_test.rb"
545
+ lines = [ "# #{path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"test_helper\"", "" ]
546
+ lines << "class #{class_name}Test < ActiveSupport::TestCase"
547
+ lines << " test \"performs the expected action\" do"
548
+ lines << " # TODO: set up input and verify output"
549
+ lines << " end"
550
+ lines << "end"
551
+ lines << "```"
552
+ end
553
+ text_response(lines.join("\n"))
554
+ end
555
+
556
+ # ── Helpers ──────────────────────────────────────────────────────
557
+
558
+ def find_factory_name(model_name, tests_data)
559
+ factory_names = tests_data[:factory_names] || {}
560
+ underscore = model_name.underscore
561
+ # Look for a factory matching the model name
562
+ factory_names.each_value do |names|
563
+ return underscore.to_sym if names.include?(underscore.to_sym) || names.include?(underscore)
564
+ end
565
+ # Check if factories directory exists at all
566
+ tests_data[:factories] ? underscore.to_sym : nil
567
+ end
568
+ end
569
+ end
570
+ end
571
+ end
@@ -118,8 +118,25 @@ module RailsAiContext
118
118
  concern_callbacks = find_concern_callbacks(name, data)
119
119
  if concern_callbacks.any?
120
120
  lines << "" << "## From Concerns"
121
- concern_callbacks.each do |concern_name, cbs|
122
- lines << "- **#{concern_name}:** #{cbs.join(', ')}"
121
+ if detail == "full"
122
+ concern_callbacks.each do |concern_name, info|
123
+ lines << "### #{concern_name}"
124
+ info[:callbacks].each do |cb|
125
+ source = extract_method_source(info[:path], cb[:method_name])
126
+ lines << "- #{cb[:declaration]}"
127
+ if source
128
+ lines << "```ruby"
129
+ lines << source[:code]
130
+ lines << "```"
131
+ lines << ""
132
+ end
133
+ end
134
+ end
135
+ else
136
+ concern_callbacks.each do |concern_name, info|
137
+ declarations = info[:callbacks].map { |cb| cb[:declaration] }
138
+ lines << "- **#{concern_name}:** #{declarations.join(', ')}"
139
+ end
123
140
  end
124
141
  end
125
142
 
@@ -210,6 +227,10 @@ module RailsAiContext
210
227
 
211
228
  private_class_method def self.extract_callback_source(model_name, method_name)
212
229
  path = Rails.root.join("app", "models", "#{model_name.underscore}.rb")
230
+ extract_method_source(path, method_name)
231
+ end
232
+
233
+ private_class_method def self.extract_method_source(path, method_name)
213
234
  return nil unless File.exist?(path)
214
235
  return nil if File.size(path) > RailsAiContext.configuration.max_file_size
215
236
 
@@ -263,11 +284,13 @@ module RailsAiContext
263
284
 
264
285
  source.each_line do |line|
265
286
  if (match = line.match(/\A\s*(before_\w+|after_\w+|around_\w+)\s+[: ]*(\w+)/))
266
- callbacks << "#{match[1]} :#{match[2]}"
287
+ callbacks << { declaration: "#{match[1]} :#{match[2]}", method_name: match[2] }
267
288
  end
268
289
  end
269
290
 
270
- concern_callbacks[concern_name] = callbacks if callbacks.any?
291
+ if callbacks.any?
292
+ concern_callbacks[concern_name] = { callbacks: callbacks, path: concern_path }
293
+ end
271
294
  end
272
295
 
273
296
  concern_callbacks
@@ -53,7 +53,15 @@ module RailsAiContext
53
53
 
54
54
  text_response(render_single(found, detail))
55
55
  else
56
- return text_response("No components found in app/components/.") if components.empty?
56
+ if components.empty?
57
+ return text_response(
58
+ "No components found in app/components/.\n\n" \
59
+ "This app may use ERB partials instead of ViewComponent/Phlex. Try:\n" \
60
+ "- `rails_get_partial_interface(partial:\"shared/partial_name\")` — partial locals contract + usage\n" \
61
+ "- `rails_get_view(controller:\"name\")` — view templates with partial/Stimulus references\n" \
62
+ "- `rails_get_design_system` — UI component patterns extracted from views"
63
+ )
64
+ end
57
65
  text_response(render_catalog(components, data[:summary], detail))
58
66
  end
59
67
  end
@@ -102,7 +110,8 @@ module RailsAiContext
102
110
  lines << "**Props:**"
103
111
  comp[:props].each do |prop|
104
112
  default = prop[:default] ? " (default: #{prop[:default]})" : ""
105
- lines << " - `#{prop[:name]}`#{default}"
113
+ values = prop[:values]&.any? ? " -- values: #{prop[:values].join(', ')}" : ""
114
+ lines << " - `#{prop[:name]}`#{default}#{values}"
106
115
  end
107
116
  end
108
117