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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- 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 +18 -10
- 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 +10 -19
- data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
- data/lib/rails_ai_context/server.rb +12 -1
- data/lib/rails_ai_context/tools/base_tool.rb +63 -1
- data/lib/rails_ai_context/tools/diagnose.rb +436 -0
- data/lib/rails_ai_context/tools/generate_test.rb +571 -0
- 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 +70 -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 +19 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
- 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 +4 -0
- data/lib/rails_ai_context/tools/onboard.rb +755 -0
- data/lib/rails_ai_context/tools/query.rb +4 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +299 -0
- 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 +132 -0
- data/lib/rails_ai_context/version.rb +1 -1
- 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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
+
values = prop[:values]&.any? ? " -- values: #{prop[:values].join(', ')}" : ""
|
|
114
|
+
lines << " - `#{prop[:name]}`#{default}#{values}"
|
|
106
115
|
end
|
|
107
116
|
end
|
|
108
117
|
|