rigortype 0.1.10 → 0.1.11

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/cli/baseline_command.rb +4 -3
  4. data/lib/rigor/cli.rb +16 -3
  5. data/lib/rigor/version.rb +1 -1
  6. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  7. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  8. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  9. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  10. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  11. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  12. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  13. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  14. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  15. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  16. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  17. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  18. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  19. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  20. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  21. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  22. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  23. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  24. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  25. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  26. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  27. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  28. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  29. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  33. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  34. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  35. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  36. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  37. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  38. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  39. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  40. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  41. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  42. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  43. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  44. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  45. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  46. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  47. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  48. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  49. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  50. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  51. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  52. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  53. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  54. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  55. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  56. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  57. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  58. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  59. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  60. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  61. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  62. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  63. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  64. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  65. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  66. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  67. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  68. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  69. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  71. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  74. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  75. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  76. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  77. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  78. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  81. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  82. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  85. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  86. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  87. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  88. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  89. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  90. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  91. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  92. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  93. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  94. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  95. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  96. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  97. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  98. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  99. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  100. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  102. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  103. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  104. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  105. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  106. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  107. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  108. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  109. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  110. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  111. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  112. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  113. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  114. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  115. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  116. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  117. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  118. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  119. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  120. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  121. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  122. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  123. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  124. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  125. metadata +149 -1
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Actioncable < Rigor::Plugin::Base
6
+ # Frozen catalogue of discovered ActionCable channel
7
+ # classes keyed by qualified class name. Each entry
8
+ # holds:
9
+ #
10
+ # - `action_methods` — the set of public instance
11
+ # methods that aren't ActionCable framework hooks
12
+ # (`subscribed` / `unsubscribed`). These are the
13
+ # methods clients invoke via
14
+ # `subscription.perform("action_name", data)`.
15
+ # - `stream_names` — the set of literal-string stream
16
+ # names registered via `stream_from "name"` calls
17
+ # inside the channel body. Dynamic registrations
18
+ # (`stream_from interpolated_string`) are recorded
19
+ # separately as `dynamic_streams: true` so the
20
+ # analyzer can suppress unknown-stream warnings on
21
+ # any channel that has at least one dynamic
22
+ # registration.
23
+ class ChannelIndex
24
+ Entry = Data.define(:class_name, :file_path, :action_methods, :stream_names, :dynamic_streams) do
25
+ def includes_action?(name)
26
+ action_methods.include?(name.to_sym)
27
+ end
28
+
29
+ def known_actions
30
+ action_methods.to_a.sort
31
+ end
32
+ end
33
+
34
+ attr_reader :entries
35
+
36
+ def initialize(entries)
37
+ @entries = entries.freeze
38
+ @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
39
+ freeze
40
+ end
41
+
42
+ # @return [Entry, nil]
43
+ def find(class_name)
44
+ @by_name[class_name.to_s]
45
+ end
46
+
47
+ def known?(class_name)
48
+ @by_name.key?(class_name.to_s)
49
+ end
50
+
51
+ def empty?
52
+ @entries.empty?
53
+ end
54
+
55
+ def size
56
+ @entries.size
57
+ end
58
+
59
+ def names
60
+ @by_name.keys
61
+ end
62
+
63
+ # All literal stream names registered across every
64
+ # discovered channel.
65
+ def all_stream_names
66
+ @entries.flat_map { |e| e.stream_names.to_a }.to_set
67
+ end
68
+
69
+ # True when at least one discovered channel uses a
70
+ # dynamic stream registration. The analyzer treats
71
+ # this as "we can't be sure any literal name is
72
+ # missing" and downgrades unknown-stream from
73
+ # `:warning` to `:info` (or drops it entirely;
74
+ # current behaviour: skip warnings).
75
+ def any_dynamic_streams?
76
+ @entries.any?(&:dynamic_streams)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "actioncable/channel_index"
6
+ require_relative "actioncable/channel_discoverer"
7
+ require_relative "actioncable/analyzer"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-actioncable — validates ActionCable
12
+ # `<Channel>.broadcast_to(...)` and
13
+ # `ActionCable.server.broadcast(stream_name, ...)`
14
+ # call sites against the discovered channel index.
15
+ #
16
+ # Tier 3F of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
17
+ # Statically discovers channel classes by walking
18
+ # `channel_search_paths` and parsing each file with
19
+ # Prism — no `actioncable` runtime dependency.
20
+ #
21
+ # ## Configuration
22
+ #
23
+ # plugins:
24
+ # - gem: rigor-actioncable
25
+ # config:
26
+ # channel_search_paths: ["app/channels"] # default; optional
27
+ # channel_base_classes: ["ApplicationCable::Channel", "ActionCable::Channel::Base"] # default; optional
28
+ #
29
+ # ## What it checks
30
+ #
31
+ # 1. **Channel class existence** — `<X>.broadcast_to(...)`
32
+ # where `X` ends in `Channel` must resolve to a
33
+ # discovered channel.
34
+ # 2. **Stream-name registration** —
35
+ # `ActionCable.server.broadcast("stream_name", ...)`
36
+ # with a literal stream name is checked against
37
+ # every discovered channel's `stream_from "..."`
38
+ # registrations. The check is suppressed when ANY
39
+ # discovered channel uses a dynamic registration
40
+ # (`stream_from interpolated_string` or
41
+ # `stream_for record`) — the absence of a literal
42
+ # match doesn't prove absence.
43
+ #
44
+ # ## Limitations (v0.1.0)
45
+ #
46
+ # - **Direct-superclass match only.** Indirect
47
+ # inheritance (`AdminChannel < BaseChannel <
48
+ # ApplicationCable::Channel`) needs `BaseChannel`
49
+ # listed in `channel_base_classes`.
50
+ # - **Action method invocations are not validated.**
51
+ # ActionCable actions are invoked from JS via
52
+ # `subscription.perform("action_name", data)`; we
53
+ # don't analyse JS so the action-method index is
54
+ # currently informational only (future cross-plugin
55
+ # handoff to a hypothetical JS-side analyzer).
56
+ # - **`broadcast_to` arity isn't checked.** The method
57
+ # takes any record + any data hash; there's no
58
+ # useful arity envelope.
59
+ class Actioncable < Rigor::Plugin::Base
60
+ manifest(
61
+ id: "actioncable",
62
+ version: "0.1.0",
63
+ description: "Validates ActionCable broadcast call shape against discovered channels.",
64
+ config_schema: {
65
+ "channel_search_paths" => :array,
66
+ "channel_base_classes" => :array
67
+ }
68
+ )
69
+
70
+ DEFAULT_CHANNEL_SEARCH_PATHS = ["app/channels"].freeze
71
+ DEFAULT_CHANNEL_BASE_CLASSES = [
72
+ "ApplicationCable::Channel",
73
+ "ActionCable::Channel::Base"
74
+ ].freeze
75
+
76
+ producer :channel_index do |_params|
77
+ ChannelDiscoverer.new(
78
+ io_boundary: io_boundary,
79
+ search_paths: @channel_search_paths,
80
+ base_classes: @channel_base_classes
81
+ ).discover
82
+ end
83
+
84
+ def init(_services)
85
+ @channel_search_paths = Array(
86
+ config.fetch("channel_search_paths", DEFAULT_CHANNEL_SEARCH_PATHS)
87
+ ).map(&:to_s)
88
+ @channel_base_classes = Array(
89
+ config.fetch("channel_base_classes", DEFAULT_CHANNEL_BASE_CLASSES)
90
+ ).map(&:to_s)
91
+ @channel_index = nil
92
+ @load_error = nil
93
+ end
94
+
95
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
96
+ index = channel_index_or_nil
97
+ return [load_error_diagnostic(path)] if index.nil? && @load_error
98
+ return [] if index.nil? || index.empty?
99
+
100
+ Analyzer.diagnose(path: path, root: root, channel_index: index).map { |diag| build_diagnostic(diag) }
101
+ end
102
+
103
+ private
104
+
105
+ def channel_index_or_nil
106
+ return @channel_index if @channel_index
107
+
108
+ # Pass an explicit descriptor covering every `.rb` file
109
+ # under the configured channel search paths so the cache
110
+ # invalidates when channels are added, removed, or edited.
111
+ # Without it the auto-built descriptor depends on the
112
+ # `IoBoundary`'s in-process read history — empty on the
113
+ # first call of a fresh process, so warm cache hits would
114
+ # serve stale `ChannelIndex` data when project files have
115
+ # changed between sessions.
116
+ descriptor = glob_descriptor(@channel_search_paths, "**/*.rb")
117
+ @channel_index = cache_for(:channel_index, params: {}, descriptor: descriptor).call
118
+ rescue StandardError => e
119
+ @load_error = "rigor-actioncable: failed to discover channels: #{e.class}: #{e.message}"
120
+ nil
121
+ end
122
+
123
+ def load_error_diagnostic(path)
124
+ Rigor::Analysis::Diagnostic.new(
125
+ path: path, line: 1, column: 1,
126
+ message: @load_error,
127
+ severity: :warning,
128
+ rule: "load-error"
129
+ )
130
+ end
131
+
132
+ def build_diagnostic(diag)
133
+ Rigor::Analysis::Diagnostic.new(
134
+ path: diag.path, line: diag.line, column: diag.column,
135
+ message: diag.message, severity: diag.severity, rule: diag.rule
136
+ )
137
+ end
138
+ end
139
+
140
+ Rigor::Plugin.register(Actioncable)
141
+ end
142
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/actioncable"
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Actionmailer < Rigor::Plugin::Base
8
+ # Walks a parsed file's AST looking for
9
+ # `<MailerClass>.<action>(...)` calls and validates
10
+ # each against the {MailerIndex}. Recognises both:
11
+ #
12
+ # - `UserMailer.welcome(user)` — direct action call
13
+ # (the call returns a `Mail::Message` ready for
14
+ # `.deliver_now` / `.deliver_later`).
15
+ # - `UserMailer.with(user: u).welcome` — parametrized
16
+ # action call. The `.with(...)` call is treated as a
17
+ # pass-through; the action's argument shape is
18
+ # validated on the trailing `.welcome` invocation
19
+ # even though the receiver is a method-call chain
20
+ # rather than a constant.
21
+ #
22
+ # The analyzer is purely syntactic: it does not look
23
+ # at runtime mailer state. Constants that don't appear
24
+ # in the index are silently ignored — the rule has no
25
+ # opinion on non-mailer call shapes.
26
+ module Analyzer
27
+ # `.with(...)` is recognised as a forwarding step:
28
+ # the receiver of `.with(...)` is the mailer class,
29
+ # so the trailing action-method call's class context
30
+ # is the same.
31
+ WITH_METHODS = %i[with].freeze
32
+
33
+ # Ruby method names that ActionMailer reserves on the
34
+ # class itself. We don't validate against these as
35
+ # actions even if a mailer happens to override them
36
+ # — the user almost certainly meant the framework
37
+ # method, not their own action.
38
+ RESERVED_CLASS_METHODS = %i[
39
+ new allocate name superclass class
40
+ deliver_later deliver_now deliver_later! deliver_now!
41
+ mail headers attachments default
42
+ with parameters
43
+ ].freeze
44
+
45
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
46
+
47
+ module_function
48
+
49
+ # @param path [String]
50
+ # @param root [Prism::Node]
51
+ # @param mailer_index [MailerIndex]
52
+ # @return [Array<Diagnostic>]
53
+ def diagnose(path:, root:, mailer_index:)
54
+ diagnostics = []
55
+ walk(root) do |call_node|
56
+ class_name = mailer_class_for_call(call_node)
57
+ next if class_name.nil?
58
+ next if RESERVED_CLASS_METHODS.include?(call_node.name)
59
+
60
+ class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
61
+ next if class_entry.nil?
62
+
63
+ action_entry = class_entry.find_action(call_node.name)
64
+ if action_entry.nil?
65
+ diagnostics << unknown_action_diagnostic(path, call_node, class_entry)
66
+ next
67
+ end
68
+
69
+ diagnostics << action_call_info(path, call_node, class_entry, action_entry)
70
+ arity_diag = arity_check(path, call_node, class_entry, action_entry)
71
+ diagnostics << arity_diag if arity_diag
72
+ end
73
+ diagnostics
74
+ end
75
+
76
+ # Walks the tree yielding every CallNode whose receiver
77
+ # resolves (directly or through `.with(...)`) to a
78
+ # constant.
79
+ def walk(node, &)
80
+ return unless node.is_a?(Prism::Node)
81
+
82
+ yield node if node.is_a?(Prism::CallNode) && action_call_candidate?(node)
83
+ node.compact_child_nodes.each { |child| walk(child, &) }
84
+ end
85
+
86
+ def action_call_candidate?(node)
87
+ # Skip anything that doesn't look like a mailer
88
+ # action call: no receiver, or a non-constant /
89
+ # non-`.with(...)` receiver.
90
+ return false if node.receiver.nil?
91
+
92
+ mailer_class_for_call(node) ? true : false
93
+ end
94
+
95
+ # Extracts the mailer class name when the call's
96
+ # receiver is either:
97
+ # - A constant (`UserMailer.welcome(...)`), or
98
+ # - A `.with(...)` call whose receiver is a constant
99
+ # (`UserMailer.with(user: u).welcome`).
100
+ def mailer_class_for_call(node)
101
+ receiver = node.receiver
102
+ case receiver
103
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
104
+ constant_receiver_name(receiver)
105
+ when Prism::CallNode
106
+ return nil unless WITH_METHODS.include?(receiver.name)
107
+
108
+ constant_receiver_name(receiver.receiver)
109
+ end
110
+ end
111
+
112
+ def action_call_info(path, call_node, class_entry, action_entry)
113
+ location = call_node.location
114
+ Diagnostic.new(
115
+ path: path,
116
+ line: location.start_line,
117
+ column: location.start_column + 1,
118
+ severity: :info,
119
+ rule: "mailer-call",
120
+ message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
121
+ "matches mailer action (arity #{action_entry.arity_label})"
122
+ )
123
+ end
124
+
125
+ def arity_check(path, call_node, class_entry, action_entry)
126
+ actual = (call_node.arguments&.arguments || []).size
127
+ return nil if action_entry.accepts?(actual)
128
+
129
+ location = call_node.location
130
+ Diagnostic.new(
131
+ path: path,
132
+ line: location.start_line,
133
+ column: location.start_column + 1,
134
+ severity: :error,
135
+ rule: "wrong-arity",
136
+ message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
137
+ "expects #{action_entry.arity_label} argument(s), got #{actual}"
138
+ )
139
+ end
140
+
141
+ def unknown_action_diagnostic(path, call_node, class_entry)
142
+ location = call_node.location
143
+ known = class_entry.actions.keys.sort.join(", ")
144
+ known_part = known.empty? ? "no actions defined" : "known actions: #{known}"
145
+ Diagnostic.new(
146
+ path: path,
147
+ line: location.start_line,
148
+ column: location.start_column + 1,
149
+ severity: :error,
150
+ rule: "unknown-action",
151
+ message: "`#{class_entry.class_name}.#{call_node.name}` is not a defined " \
152
+ "mailer action (#{known_part})"
153
+ )
154
+ end
155
+
156
+ def constant_receiver_name(node)
157
+ case node
158
+ when Prism::ConstantReadNode then node.name.to_s
159
+ when Prism::ConstantPathNode then constant_path_name(node)
160
+ end
161
+ end
162
+
163
+ def constant_path_name(node)
164
+ parts = []
165
+ current = node
166
+ while current.is_a?(Prism::ConstantPathNode)
167
+ parts.unshift(current.name.to_s)
168
+ current = current.parent
169
+ end
170
+ case current
171
+ when nil then "::#{parts.join('::')}"
172
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end