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.
@@ -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