conjure_shield 0.1.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.
- checksums.yaml +7 -0
- data/lib/conjure_shield/analyzer.rb +762 -0
- data/lib/conjure_shield/railtie.rb +35 -0
- data/lib/conjure_shield/tasks/conjure_shield_tasks.rake +150 -0
- data/lib/conjure_shield/test_generator.rb +2336 -0
- data/lib/conjure_shield/version.rb +3 -0
- data/lib/conjure_shield.rb +30 -0
- data/lib/generators/conjure_shield/install_generator.rb +194 -0
- metadata +125 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
require "rubocop"
|
|
3
|
+
require "rubocop-rails"
|
|
4
|
+
require "rubocop-rspec"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module ConjureShield
|
|
8
|
+
class Analyzer
|
|
9
|
+
RESTFUL_ACTIONS = %i[index show new create edit update destroy].freeze
|
|
10
|
+
SINGULAR_ACTIONS = %i[show new create edit update destroy].freeze
|
|
11
|
+
|
|
12
|
+
ACTION_ROUTE_MAP = {
|
|
13
|
+
index: [:get_index, :index_pagination, :index_sorting],
|
|
14
|
+
show: [:get_show, :show_with_associations],
|
|
15
|
+
new: [:get_new, :new_form],
|
|
16
|
+
create: [:post_create, :post_create_valid, :post_create_invalid, :post_create_redirect],
|
|
17
|
+
edit: [:get_edit, :edit_form],
|
|
18
|
+
update: [:put_patch_update_valid, :put_patch_update_invalid, :put_patch_update_redirect],
|
|
19
|
+
destroy: [:delete_destroy, :delete_destroy_redirect]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :codebase_path, :files, :ast_nodes, :missing_tests, :existing_tests, :routes, :routes_parsed, :devise_model
|
|
23
|
+
|
|
24
|
+
def initialize(path)
|
|
25
|
+
@codebase_path = File.expand_path(path)
|
|
26
|
+
@ast_nodes = []
|
|
27
|
+
@missing_tests = []
|
|
28
|
+
@existing_tests = []
|
|
29
|
+
@files = []
|
|
30
|
+
@routes = {}
|
|
31
|
+
@routes_parsed = false
|
|
32
|
+
@devise_model = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def analyze
|
|
36
|
+
scan_ruby_files
|
|
37
|
+
scan_existing_tests
|
|
38
|
+
analyze_ast
|
|
39
|
+
parse_routes
|
|
40
|
+
detect_missing_tests
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def scan_ruby_files
|
|
47
|
+
Dir.glob("#{codebase_path}/**/*.rb").each do |file|
|
|
48
|
+
next if file.include?("/vendor/") || file.include?("/node_modules/")
|
|
49
|
+
next if file.include?("/test/") || file.include?("/spec/")
|
|
50
|
+
next if file.include?("/db/") || file.include?("/config/")
|
|
51
|
+
|
|
52
|
+
content = File.read(file)
|
|
53
|
+
@files << { path: file, content: content }
|
|
54
|
+
parse_file(file, content)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Dir.glob("#{codebase_path}/**/*_controller.js").each do |file|
|
|
58
|
+
next if file.include?("/node_modules/") || file.include?("/vendor/")
|
|
59
|
+
|
|
60
|
+
content = File.read(file)
|
|
61
|
+
extract_stimulus(file, content)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_file(file, content)
|
|
66
|
+
begin
|
|
67
|
+
ast = Prism.parse(content).value
|
|
68
|
+
@ast_nodes << { file: file, ast: ast, content: content }
|
|
69
|
+
extract_model_info(file, ast)
|
|
70
|
+
extract_controller_info(file, ast)
|
|
71
|
+
extract_serializer_info(file, ast)
|
|
72
|
+
extract_helper_methods(file, ast)
|
|
73
|
+
extract_callbacks(file, ast)
|
|
74
|
+
extract_scopes(file, ast)
|
|
75
|
+
extract_validations(file, ast)
|
|
76
|
+
extract_associations(file, ast)
|
|
77
|
+
extract_custom_methods(file, ast)
|
|
78
|
+
extract_factories(file, content)
|
|
79
|
+
extract_serialization(file, content)
|
|
80
|
+
extract_delegation(file, ast)
|
|
81
|
+
extract_cable_subscriptions(file, content)
|
|
82
|
+
extract_cable_broadcasts(file, content)
|
|
83
|
+
extract_describe_blocks(file, content)
|
|
84
|
+
rescue Prism::ParseError => e
|
|
85
|
+
warn "Parse error in #{file}: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def scan_existing_tests
|
|
90
|
+
# Look for both RSpec and Minitest structures safely
|
|
91
|
+
Dir.glob("#{codebase_path}/{spec,test}/**/*_{spec,test}.rb").each do |file|
|
|
92
|
+
next if file.include?("/vendor/") || file.include?("/node_modules/")
|
|
93
|
+
|
|
94
|
+
@existing_tests << {
|
|
95
|
+
file: file,
|
|
96
|
+
filename: File.basename(file)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def analyze_ast
|
|
102
|
+
# Gather all granular metadata fragments found by your visitors
|
|
103
|
+
metadata_nodes = @ast_nodes.select do |n|
|
|
104
|
+
[:callbacks, :validations, :scopes, :associations, :custom_methods, :delegation].include?(n[:type])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Find the primary component definitions
|
|
108
|
+
primary_components = @ast_nodes.select { |n| [:model, :controller, :serializer, :helper].include?(n[:type]) }
|
|
109
|
+
|
|
110
|
+
# Cross-reference and enrich the primary components with their specific features
|
|
111
|
+
# This keeps @ast_nodes flat so your CLI counters still work!
|
|
112
|
+
primary_components.each do |component|
|
|
113
|
+
component[:metadata] ||= {}
|
|
114
|
+
|
|
115
|
+
file_metadata = metadata_nodes.select { |meta| meta[:file] == component[:file] }
|
|
116
|
+
file_metadata.each do |meta|
|
|
117
|
+
payload_key = meta[:type]
|
|
118
|
+
component[:metadata][payload_key] = meta[payload_key] || meta[:methods] || meta[:delegations]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extract_model_info(file, ast)
|
|
124
|
+
return unless file.include?("/app/models/")
|
|
125
|
+
|
|
126
|
+
content = @files.find { |f| f[:path] == file }&.dig(:content) || ""
|
|
127
|
+
model_name = model_name_from_path(file)
|
|
128
|
+
|
|
129
|
+
@devise_model ||= model_name if content.include?("devise")
|
|
130
|
+
|
|
131
|
+
@ast_nodes << { file: file, model: model_name, type: :model }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def model_name_from_path(path)
|
|
135
|
+
relative = path.sub(%r{.*/app/[^/]+/}, "").sub(/\.rb\z/, "")
|
|
136
|
+
relative.split("/").map { |part| part.split("_").map(&:capitalize).join }.join("::")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def extract_controller_info(file, ast)
|
|
140
|
+
return unless file.include?("/app/controllers/")
|
|
141
|
+
|
|
142
|
+
controller_name = model_name_from_path(file)
|
|
143
|
+
@ast_nodes << { file: file, controller: controller_name, type: :controller }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract_serializer_info(file, ast)
|
|
147
|
+
return unless file.include?("/app/serializers/")
|
|
148
|
+
|
|
149
|
+
serializer_name = File.basename(file, ".rb").split('_').map(&:capitalize).join
|
|
150
|
+
@ast_nodes << { file: file, serializer: serializer_name, type: :serializer }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_helper_methods(file, ast)
|
|
154
|
+
return unless File.basename(file).include?("_helper")
|
|
155
|
+
|
|
156
|
+
@ast_nodes << { file: file, type: :helper }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def extract_callbacks(file, ast)
|
|
160
|
+
visitor = Class.new(Prism::Visitor) do
|
|
161
|
+
attr_reader :callbacks
|
|
162
|
+
|
|
163
|
+
def initialize
|
|
164
|
+
@callbacks = []
|
|
165
|
+
@targets = [:before_save, :after_save, :before_create, :after_create,
|
|
166
|
+
:before_update, :after_update, :before_destroy, :after_destroy,
|
|
167
|
+
:before_validation, :after_validation, :around_save, :around_create]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def visit_call_node(node)
|
|
171
|
+
@callbacks << node.name if @targets.include?(node.name)
|
|
172
|
+
super
|
|
173
|
+
end
|
|
174
|
+
end.new
|
|
175
|
+
|
|
176
|
+
visitor.visit(ast)
|
|
177
|
+
return if visitor.callbacks.empty?
|
|
178
|
+
|
|
179
|
+
@ast_nodes << { file: file, callbacks: visitor.callbacks, type: :callbacks }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def extract_scopes(file, ast)
|
|
183
|
+
visitor = Class.new(Prism::Visitor) do
|
|
184
|
+
attr_reader :scopes
|
|
185
|
+
|
|
186
|
+
def initialize
|
|
187
|
+
@scopes = []
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def visit_call_node(node)
|
|
191
|
+
if node.name == :scope && node.arguments&.arguments&.any?
|
|
192
|
+
arg = node.arguments.arguments.first
|
|
193
|
+
name = if arg.is_a?(Prism::StringNode)
|
|
194
|
+
arg.content
|
|
195
|
+
elsif arg.is_a?(Prism::SymbolNode)
|
|
196
|
+
arg.value
|
|
197
|
+
end
|
|
198
|
+
@scopes << { name: name, args: [] } if name
|
|
199
|
+
end
|
|
200
|
+
super
|
|
201
|
+
end
|
|
202
|
+
end.new
|
|
203
|
+
|
|
204
|
+
visitor.visit(ast)
|
|
205
|
+
return if visitor.scopes.empty?
|
|
206
|
+
|
|
207
|
+
@ast_nodes << { file: file, scopes: visitor.scopes, type: :scopes }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def extract_validations(file, ast)
|
|
211
|
+
content = @files.find { |f| f[:path] == file }&.dig(:content) || ""
|
|
212
|
+
validations = []
|
|
213
|
+
|
|
214
|
+
content.each_line do |line|
|
|
215
|
+
next unless line =~ /validates\s+(?::)?(\w[\w!?]*)/
|
|
216
|
+
|
|
217
|
+
field = $1
|
|
218
|
+
validators = []
|
|
219
|
+
validators << :presence if line.include?("presence:")
|
|
220
|
+
validators << :uniqueness if line.include?("uniqueness:")
|
|
221
|
+
validators << :length if line.include?("length:")
|
|
222
|
+
validators << :format if line.include?("format:")
|
|
223
|
+
validators << :inclusion if line.include?("inclusion:")
|
|
224
|
+
validators << :exclusion if line.include?("exclusion:")
|
|
225
|
+
validators << :confirmation if line.include?("confirmation:")
|
|
226
|
+
validators << :acceptance if line.include?("acceptance:")
|
|
227
|
+
validators << :numericality if line.include?("numericality:")
|
|
228
|
+
validators << :comparison if line.include?("comparison:")
|
|
229
|
+
|
|
230
|
+
validations << { field: field, validators: validators }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
return if validations.empty?
|
|
234
|
+
|
|
235
|
+
@ast_nodes << { file: file, validations: validations, type: :validations }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def extract_associations(file, ast)
|
|
239
|
+
visitor = Class.new(Prism::Visitor) do
|
|
240
|
+
attr_reader :associations
|
|
241
|
+
|
|
242
|
+
def initialize
|
|
243
|
+
@associations = []
|
|
244
|
+
@targets = [:belongs_to, :has_many, :has_one, :has_and_belongs_to_many,
|
|
245
|
+
:has_many_through, :has_one_through]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def visit_call_node(node)
|
|
249
|
+
if @targets.include?(node.name)
|
|
250
|
+
name = nil
|
|
251
|
+
if node.arguments&.arguments&.first
|
|
252
|
+
arg = node.arguments.arguments.first
|
|
253
|
+
name = arg.value.to_s if arg.respond_to?(:value)
|
|
254
|
+
end
|
|
255
|
+
target = name.classify
|
|
256
|
+
@associations << { type: node.name, name: name, target: target }
|
|
257
|
+
end
|
|
258
|
+
super
|
|
259
|
+
end
|
|
260
|
+
end.new
|
|
261
|
+
|
|
262
|
+
visitor.visit(ast)
|
|
263
|
+
return if visitor.associations.empty?
|
|
264
|
+
|
|
265
|
+
@ast_nodes << { file: file, associations: visitor.associations, type: :associations }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def extract_custom_methods(file, ast)
|
|
269
|
+
visitor = Class.new(Prism::Visitor) do
|
|
270
|
+
attr_reader :methods
|
|
271
|
+
|
|
272
|
+
def initialize
|
|
273
|
+
@methods = []
|
|
274
|
+
@ignored = [:initialize, :to_json, :to_yaml, :inspect, :class, :new, :attr_accessor,
|
|
275
|
+
:attr_reader, :attr_writer, :attr, :attr_readonly]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def visit_def_node(node)
|
|
279
|
+
unless @ignored.include?(node.name)
|
|
280
|
+
is_class_method = node.receiver.is_a?(Prism::SelfNode)
|
|
281
|
+
@methods << { name: node.name, class_method: is_class_method }
|
|
282
|
+
end
|
|
283
|
+
super
|
|
284
|
+
end
|
|
285
|
+
end.new
|
|
286
|
+
|
|
287
|
+
visitor.visit(ast)
|
|
288
|
+
return if visitor.methods.empty?
|
|
289
|
+
|
|
290
|
+
@ast_nodes << { file: file, methods: visitor.methods, type: :custom_methods }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def extract_factories(file, content)
|
|
294
|
+
return unless file.include?("factories") || file.include?("factory")
|
|
295
|
+
|
|
296
|
+
@ast_nodes << { file: file, type: :factory }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def extract_serialization(file, content)
|
|
300
|
+
return unless content.include?("to_json") || content.include?("to_yaml")
|
|
301
|
+
|
|
302
|
+
@ast_nodes << { file: file, type: :serialization }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def extract_delegation(file, ast)
|
|
306
|
+
visitor = Class.new(Prism::Visitor) do
|
|
307
|
+
attr_reader :delegations
|
|
308
|
+
|
|
309
|
+
def initialize
|
|
310
|
+
@delegations = []
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def visit_call_node(node)
|
|
314
|
+
@delegations << node.name if node.name == :delegate
|
|
315
|
+
super
|
|
316
|
+
end
|
|
317
|
+
end.new
|
|
318
|
+
|
|
319
|
+
visitor.visit(ast)
|
|
320
|
+
return if visitor.delegations.empty?
|
|
321
|
+
|
|
322
|
+
@ast_nodes << { file: file, delegations: visitor.delegations, type: :delegation }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def extract_stimulus(file, content)
|
|
326
|
+
return unless file.end_with?("_controller.js")
|
|
327
|
+
|
|
328
|
+
controller_name = file.match(/(\w+)_controller\.js\z/)&.[](1)
|
|
329
|
+
return unless controller_name
|
|
330
|
+
|
|
331
|
+
info = { file: file, controller: controller_name, type: :stimulus }
|
|
332
|
+
|
|
333
|
+
if content =~ /static\s+targets\s*=\s*\[([^\]]*)\]/
|
|
334
|
+
info[:targets] = $1.split(",").map { |t| t.strip.gsub(/["'\s]/, "") }.reject(&:empty?)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
if content =~ /static\s+values\s*=\s*\{(.*?)\}/m
|
|
338
|
+
info[:values] = {}
|
|
339
|
+
$1.scan(/(\w+)\s*:\s*(\w+)/) { info[:values][$1] = $2 }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
if content =~ /static\s+classes\s*=\s*\[([^\]]*)\]/
|
|
343
|
+
info[:classes] = $1.split(",").map { |t| t.strip.gsub(/["'\s]/, "") }.reject(&:empty?)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
lifecycle = %w[connect disconnect initialize export default class extends static get set constructor]
|
|
347
|
+
info[:actions] = content.scan(/^\s+(\w+)\s*\(/).flatten - lifecycle
|
|
348
|
+
|
|
349
|
+
@ast_nodes << info
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def extract_cable_subscriptions(file, content)
|
|
353
|
+
subscriptions = []
|
|
354
|
+
content.scan(/subscribe\s*\(\s*["'](?<sub>[^"]+)["']\s*,\s*["'](?<channel>[^"]+)["']/).each do |match|
|
|
355
|
+
subscriptions << { subscription: match[:sub], channel: match[:channel] }
|
|
356
|
+
end
|
|
357
|
+
content.scan(/stream_from\s+(?::)?["']?(\w[\w\/]*)["'#]/).each do |match|
|
|
358
|
+
subscriptions << { subscription: "stream_from", channel: match[0] }
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
match = file.match(%r{/channels/(\w+)_channel\.rb\z})
|
|
362
|
+
channel_name = "#{match[1].camelize}Channel" if match
|
|
363
|
+
if channel_name && subscriptions.any?
|
|
364
|
+
subscriptions.each { |s| s[:channel] = channel_name }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
return if subscriptions.empty?
|
|
368
|
+
|
|
369
|
+
@ast_nodes << { file: file, subscriptions: subscriptions, type: :cable_subscription }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def extract_cable_broadcasts(file, content)
|
|
373
|
+
channels = []
|
|
374
|
+
content.scan(/broadcast_to\s*\(\s*["'](?<channel>[^"']+)["']/).each do |match|
|
|
375
|
+
channels << match[:channel]
|
|
376
|
+
end
|
|
377
|
+
content.scan(/ActionCable\.server\.broadcast\s*\(\s*["']?(\w[\w\/]*)["'#]/).each do |match|
|
|
378
|
+
channels << match[0]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
match = file.match(%r{/channels/(\w+)_channel\.rb\z})
|
|
382
|
+
channel_name = "#{match[1].camelize}Channel" if match
|
|
383
|
+
channels.map! { |_| channel_name } if channel_name && channels.any?
|
|
384
|
+
|
|
385
|
+
return if channels.empty?
|
|
386
|
+
|
|
387
|
+
@ast_nodes << { file: file, channels: channels, type: :cable_broadcast }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def extract_describe_blocks(file, content)
|
|
391
|
+
blocks = []
|
|
392
|
+
content.scan(/RSpec\.describe\s+\["?([^"\]]+)"/).each do |match|
|
|
393
|
+
blocks << { name: match[0], suggestions: [] }
|
|
394
|
+
end
|
|
395
|
+
blocks
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def parse_routes
|
|
399
|
+
routes_file = File.join(@codebase_path, "config", "routes.rb")
|
|
400
|
+
return unless File.exist?(routes_file)
|
|
401
|
+
return if @routes_parsed
|
|
402
|
+
|
|
403
|
+
if parse_routes_via_rails_command
|
|
404
|
+
@routes_parsed = true
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
content = File.read(routes_file)
|
|
409
|
+
parse_routes_via_regex(content)
|
|
410
|
+
@routes_parsed = true
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def parse_routes_via_rails_command
|
|
414
|
+
stdout, _, status = Open3.capture3(
|
|
415
|
+
"bundle", "exec", "rails", "routes",
|
|
416
|
+
chdir: @codebase_path
|
|
417
|
+
)
|
|
418
|
+
return false unless status.success?
|
|
419
|
+
|
|
420
|
+
stdout.each_line do |line|
|
|
421
|
+
stripped = line.strip
|
|
422
|
+
next if stripped.empty?
|
|
423
|
+
next if stripped.include?("Controller#Action")
|
|
424
|
+
|
|
425
|
+
if stripped =~ /([a-z_][a-z0-9_\/]*)#([a-z_]+)\s*\z/
|
|
426
|
+
controller_path = $1
|
|
427
|
+
action = $2.to_sym
|
|
428
|
+
controller_parts = controller_path.split("/").map(&:camelize)
|
|
429
|
+
controller_class = controller_parts.join("::") + "Controller"
|
|
430
|
+
register_controller_routes(controller_class, [action])
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
true
|
|
435
|
+
rescue => e
|
|
436
|
+
warn "rails routes command failed: #{e.message}"
|
|
437
|
+
false
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def parse_routes_via_regex(content)
|
|
441
|
+
prefix_stack = []
|
|
442
|
+
|
|
443
|
+
content.each_line do |line|
|
|
444
|
+
stripped = line.strip
|
|
445
|
+
next if stripped.empty? || stripped.start_with?("#")
|
|
446
|
+
|
|
447
|
+
if stripped =~ /\Aend\b/
|
|
448
|
+
prefix_stack.pop
|
|
449
|
+
next
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
if stripped =~ /\bnamespace\s+(?::|["'])(\w+)["']?\s+do\b/
|
|
453
|
+
prefix_stack << $1.camelize
|
|
454
|
+
next
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
if stripped =~ /\bscope\s+module:\s*(?::|["'])(\w+)["']?\s+do\b/
|
|
458
|
+
prefix_stack << $1.camelize
|
|
459
|
+
next
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
prefix = prefix_stack.any? ? prefix_stack.join("::") + "::" : ""
|
|
463
|
+
|
|
464
|
+
if stripped =~ /\bresources\s+(?::|["'])(\w+)["']?/
|
|
465
|
+
resource_name = $1
|
|
466
|
+
controller = "#{prefix}#{resource_name.camelize}Controller"
|
|
467
|
+
actions = parse_route_options(stripped, RESTFUL_ACTIONS)
|
|
468
|
+
param = stripped.match(/param:\s*(?::|["'])(\w+)["']?\s*/) && $1
|
|
469
|
+
register_controller_routes(controller, actions, param: param)
|
|
470
|
+
next
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
if stripped =~ /\bresource\s+(?::|["'])(\w+)["']?/
|
|
474
|
+
resource_name = $1
|
|
475
|
+
controller = "#{prefix}#{resource_name.camelize}Controller"
|
|
476
|
+
actions = parse_route_options(stripped, SINGULAR_ACTIONS)
|
|
477
|
+
register_controller_routes(controller, actions)
|
|
478
|
+
next
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
if stripped =~ /\b(get|post|put|patch|delete|match)\s+["'].*?["'].*\bto:\s*["']([a-z_\/]+)#(\w+)["']/
|
|
482
|
+
controller_parts = $2.split("/").map(&:camelize)
|
|
483
|
+
controller = "#{prefix}#{controller_parts.join("::")}Controller"
|
|
484
|
+
register_controller_routes(controller, [$3.to_sym])
|
|
485
|
+
next
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
if stripped =~ /\b(get|post|put|patch|delete|match)\s+["'].*?["']\s*=>\s*["']([a-z_\/]+)#(\w+)["']/
|
|
489
|
+
controller_parts = $2.split("/").map(&:camelize)
|
|
490
|
+
controller = "#{prefix}#{controller_parts.join("::")}Controller"
|
|
491
|
+
register_controller_routes(controller, [$3.to_sym])
|
|
492
|
+
next
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def parse_route_options(rest, default_actions)
|
|
498
|
+
if rest =~ /only:\s*\[([^\]]*)\]/
|
|
499
|
+
$1.split(",").map { |s| s.strip.gsub(/[":\[\]']/, "").to_sym } & default_actions
|
|
500
|
+
elsif rest =~ /except:\s*\[([^\]]*)\]/
|
|
501
|
+
default_actions - $1.split(",").map { |s| s.strip.gsub(/[":\[\]']/, "").to_sym }
|
|
502
|
+
else
|
|
503
|
+
default_actions.dup
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def register_controller_routes(controller, actions, param: nil)
|
|
508
|
+
@routes[controller] ||= { actions: [], param: nil }
|
|
509
|
+
@routes[controller][:actions] = (@routes[controller][:actions] + actions).uniq
|
|
510
|
+
@routes[controller][:param] = param if param
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def routable_actions_for(controller_name)
|
|
514
|
+
if @routes.key?(controller_name)
|
|
515
|
+
@routes[controller_name][:actions]
|
|
516
|
+
else
|
|
517
|
+
RESTFUL_ACTIONS
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def route_param_for(controller_name)
|
|
522
|
+
@routes.dig(controller_name, :param)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def parse_schema
|
|
526
|
+
schema_file = File.join(@codebase_path, "db", "schema.rb")
|
|
527
|
+
return {} unless File.exist?(schema_file)
|
|
528
|
+
|
|
529
|
+
content = File.read(schema_file)
|
|
530
|
+
tables = {}
|
|
531
|
+
current_table = nil
|
|
532
|
+
current_columns = {}
|
|
533
|
+
|
|
534
|
+
content.each_line do |line|
|
|
535
|
+
if line =~ /create_table\s+"(\w+)".*\bdo\b/
|
|
536
|
+
current_table = $1
|
|
537
|
+
current_columns = {}
|
|
538
|
+
elsif current_table && line =~ /t\.(\w+)\s+"(\w+)"/
|
|
539
|
+
current_columns[$2] = { type: $1.to_sym }
|
|
540
|
+
elsif current_table && line =~ /^\s*end\s*$/
|
|
541
|
+
tables[current_table] = current_columns
|
|
542
|
+
current_table = nil
|
|
543
|
+
current_columns = {}
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
tables
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def table_name_for_model(model_name)
|
|
551
|
+
model_name.underscore.tr("/", "_").pluralize
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def detect_missing_tests
|
|
555
|
+
schema_tables = parse_schema
|
|
556
|
+
models = @ast_nodes.select { |n| n[:type] == :model }
|
|
557
|
+
controllers = @ast_nodes.select { |n| n[:type] == :controller }
|
|
558
|
+
|
|
559
|
+
models.each do |model_node|
|
|
560
|
+
next if model_node[:model] == "ApplicationRecord"
|
|
561
|
+
expected_spec = "#{File.basename(model_node[:file], '.rb')}_spec.rb"
|
|
562
|
+
has_test = @existing_tests.any? { |t| t[:filename] == expected_spec }
|
|
563
|
+
|
|
564
|
+
unless has_test
|
|
565
|
+
suggestions = []
|
|
566
|
+
model_name = model_node[:model]
|
|
567
|
+
table_name = table_name_for_model(model_name)
|
|
568
|
+
model_columns = schema_tables.fetch(table_name, {})
|
|
569
|
+
|
|
570
|
+
if model_node.dig(:metadata, :validations)&.any?
|
|
571
|
+
suggestions << {
|
|
572
|
+
name: "Test active validations",
|
|
573
|
+
type: :validations,
|
|
574
|
+
model: model_name,
|
|
575
|
+
columns: model_columns,
|
|
576
|
+
fields: model_node[:metadata][:validations],
|
|
577
|
+
validations: model_node[:metadata][:validations]
|
|
578
|
+
}
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
if model_node.dig(:metadata, :associations)&.any?
|
|
582
|
+
assoc_types = model_node[:metadata][:associations].map { |a| a[:type] }.uniq
|
|
583
|
+
assoc_types.each do |assoc_type|
|
|
584
|
+
suggestions << {
|
|
585
|
+
name: "Test #{assoc_type} associations",
|
|
586
|
+
type: assoc_type,
|
|
587
|
+
model: model_name,
|
|
588
|
+
columns: model_columns,
|
|
589
|
+
associations: model_node[:metadata][:associations]
|
|
590
|
+
}
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
if model_node.dig(:metadata, :scopes)&.any?
|
|
595
|
+
suggestions << {
|
|
596
|
+
name: "Test model scopes",
|
|
597
|
+
type: :scopes,
|
|
598
|
+
model: model_name,
|
|
599
|
+
columns: model_columns,
|
|
600
|
+
scopes: model_node[:metadata][:scopes]
|
|
601
|
+
}
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
cb = model_node.dig(:metadata, :callbacks) || []
|
|
605
|
+
cb.each do |callback_name|
|
|
606
|
+
suggestions << {
|
|
607
|
+
name: "Test #{callback_name} callback",
|
|
608
|
+
type: callback_name,
|
|
609
|
+
model: model_name,
|
|
610
|
+
columns: model_columns,
|
|
611
|
+
callbacks: cb.map { |c| { type: c } }
|
|
612
|
+
}
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
if model_node.dig(:metadata, :custom_methods)&.any?
|
|
616
|
+
suggestions << {
|
|
617
|
+
name: "Test custom methods",
|
|
618
|
+
type: :custom_methods,
|
|
619
|
+
model: model_name,
|
|
620
|
+
columns: model_columns,
|
|
621
|
+
custom_methods: model_node[:metadata][:custom_methods]
|
|
622
|
+
}
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
if model_node.dig(:metadata, :delegation)&.any?
|
|
626
|
+
suggestions << {
|
|
627
|
+
name: "Test delegation",
|
|
628
|
+
type: :delegation,
|
|
629
|
+
model: model_name,
|
|
630
|
+
columns: model_columns
|
|
631
|
+
}
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
suggestions << {
|
|
635
|
+
name: "Test factory",
|
|
636
|
+
type: :factories,
|
|
637
|
+
model: model_name,
|
|
638
|
+
columns: model_columns,
|
|
639
|
+
associations: model_node.dig(:metadata, :associations)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
@missing_tests << {
|
|
643
|
+
type: :unit,
|
|
644
|
+
model: model_name,
|
|
645
|
+
file: model_node[:file],
|
|
646
|
+
suggestions: suggestions
|
|
647
|
+
}
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
controllers.each do |controller_node|
|
|
652
|
+
controller_name = controller_node[:controller]
|
|
653
|
+
next if controller_name == "ApplicationController"
|
|
654
|
+
|
|
655
|
+
expected_spec = "#{File.basename(controller_node[:file], '.rb')}_spec.rb"
|
|
656
|
+
has_test = @existing_tests.any? { |t| t[:filename] == expected_spec }
|
|
657
|
+
|
|
658
|
+
unless has_test
|
|
659
|
+
routable = routable_actions_for(controller_name)
|
|
660
|
+
next if @routes_parsed && routable.empty?
|
|
661
|
+
|
|
662
|
+
inferred_model = controller_name.sub(/Controller$/, "").singularize
|
|
663
|
+
inferred_table = table_name_for_model(inferred_model)
|
|
664
|
+
inferred_columns = schema_tables.fetch(inferred_table, {})
|
|
665
|
+
route_param = route_param_for(controller_name)
|
|
666
|
+
|
|
667
|
+
suggestions = []
|
|
668
|
+
routable.each do |action|
|
|
669
|
+
next unless ACTION_ROUTE_MAP.key?(action)
|
|
670
|
+
|
|
671
|
+
ACTION_ROUTE_MAP[action].each do |type|
|
|
672
|
+
suggestions << {
|
|
673
|
+
name: "Test #{type} for #{controller_name}",
|
|
674
|
+
type: type,
|
|
675
|
+
controller: controller_name,
|
|
676
|
+
model: inferred_model,
|
|
677
|
+
columns: inferred_columns,
|
|
678
|
+
route_param: route_param
|
|
679
|
+
}
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
if !@routes_parsed || routable.include?(:create)
|
|
684
|
+
suggestions << {
|
|
685
|
+
name: "Test strong parameters permit for #{controller_name}",
|
|
686
|
+
type: :strong_parameters_permit,
|
|
687
|
+
controller: controller_name,
|
|
688
|
+
model: inferred_model,
|
|
689
|
+
columns: inferred_columns,
|
|
690
|
+
route_param: route_param
|
|
691
|
+
}
|
|
692
|
+
suggestions << {
|
|
693
|
+
name: "Test strong parameters deny for #{controller_name}",
|
|
694
|
+
type: :strong_parameters_deny,
|
|
695
|
+
controller: controller_name,
|
|
696
|
+
model: inferred_model,
|
|
697
|
+
columns: inferred_columns,
|
|
698
|
+
route_param: route_param
|
|
699
|
+
}
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
@missing_tests << {
|
|
703
|
+
type: :request,
|
|
704
|
+
controller: controller_name,
|
|
705
|
+
file: controller_node[:file],
|
|
706
|
+
suggestions: suggestions
|
|
707
|
+
}
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
stimulus_nodes = @ast_nodes.select { |n| n[:type] == :stimulus }
|
|
712
|
+
stimulus_nodes.each do |node|
|
|
713
|
+
controller_identifier = node[:controller]
|
|
714
|
+
next unless controller_identifier
|
|
715
|
+
next if controller_identifier == "application" || controller_identifier == "index"
|
|
716
|
+
|
|
717
|
+
expected_spec = "#{controller_identifier}_stimulus_spec.rb"
|
|
718
|
+
next if @existing_tests.any? { |t| t[:filename] == expected_spec }
|
|
719
|
+
|
|
720
|
+
@missing_tests << {
|
|
721
|
+
type: :stimulus,
|
|
722
|
+
controller: controller_identifier,
|
|
723
|
+
file: node[:file],
|
|
724
|
+
suggestions: [{
|
|
725
|
+
name: "Test #{controller_identifier} Stimulus controller",
|
|
726
|
+
type: :stimulus,
|
|
727
|
+
controller: controller_identifier,
|
|
728
|
+
targets: node[:targets] || [],
|
|
729
|
+
values: node[:values] || {},
|
|
730
|
+
classes: node[:classes] || [],
|
|
731
|
+
actions: node[:actions] || []
|
|
732
|
+
}]
|
|
733
|
+
}
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
cable_nodes = @ast_nodes.select { |n| n[:type] == :cable_subscription || n[:type] == :cable_broadcast }
|
|
737
|
+
channels = []
|
|
738
|
+
cable_nodes.each do |node|
|
|
739
|
+
if node[:type] == :cable_subscription
|
|
740
|
+
node[:subscriptions]&.each { |s| channels << s[:channel] }
|
|
741
|
+
elsif node[:type] == :cable_broadcast
|
|
742
|
+
(node[:channels] || []).each { |ch| channels << ch }
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
channels.uniq.each do |channel_name|
|
|
746
|
+
expected_spec = "#{channel_name.underscore}_cable_spec.rb"
|
|
747
|
+
next if @existing_tests.any? { |t| t[:filename] == expected_spec }
|
|
748
|
+
|
|
749
|
+
@missing_tests << {
|
|
750
|
+
type: :cable,
|
|
751
|
+
channel: channel_name,
|
|
752
|
+
file: nil,
|
|
753
|
+
suggestions: [{
|
|
754
|
+
name: "Test #{channel_name} channel",
|
|
755
|
+
type: :cable,
|
|
756
|
+
channel: channel_name
|
|
757
|
+
}]
|
|
758
|
+
}
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
end
|