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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "mailer_index"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Actionmailer < Rigor::Plugin::Base
10
+ # Walks the configured mailer-search paths via the
11
+ # plugin's `IoBoundary`, parses each `.rb` file with
12
+ # Prism, and collects classes whose immediate superclass
13
+ # is one of the configured base classes.
14
+ #
15
+ # For each discovered class, the discoverer:
16
+ #
17
+ # - Reads the instance-side `def` nodes and records each
18
+ # one as an action method, capturing the arity envelope.
19
+ # - For each (class, action) pair, attempts to read every
20
+ # candidate view template under
21
+ # `app/views/<mailer_underscore>/<action>.{html,text}.erb`.
22
+ # Existing templates feed the IoBoundary's cache
23
+ # descriptor (so the cache invalidates when the
24
+ # template changes); missing templates are recorded so
25
+ # the plugin can surface a diagnostic on the mailer
26
+ # class definition.
27
+ #
28
+ # Limitations (intentional for v0.1.0):
29
+ #
30
+ # - Direct-superclass match only. `class CustomerMailer
31
+ # < BaseMailer` where `BaseMailer < ApplicationMailer`
32
+ # is NOT discovered. Add `BaseMailer` to
33
+ # `mailer_base_classes` if needed.
34
+ # - Action methods are read from the syntactic instance-
35
+ # side `def` list. Methods built via `define_method`,
36
+ # `private`, or non-action helpers (e.g. methods
37
+ # starting with `_`) are out of scope. The discoverer
38
+ # filters obvious non-actions (`initialize`, names
39
+ # prefixed with `_`).
40
+ # - Adding a brand-new view file under
41
+ # `app/views/<mailer>/` will NOT invalidate the
42
+ # cached index until something the mailer file
43
+ # touches changes. This is the standard read-tracking
44
+ # trade-off — only files we successfully read get
45
+ # digested into the descriptor.
46
+ class MailerDiscoverer
47
+ DEFAULT_VIEWS_ROOT = "app/views"
48
+ VIEW_FORMATS = %w[html text].freeze
49
+ VIEW_EXTENSIONS = %w[erb haml slim].freeze
50
+
51
+ # @param io_boundary [Rigor::Plugin::IoBoundary]
52
+ # @param search_paths [Array<String>] absolute or
53
+ # project-relative paths to scan for mailers.
54
+ # @param base_classes [Array<String>] direct
55
+ # superclasses that mark a class as a mailer.
56
+ # @param views_root [String] absolute or project-
57
+ # relative path to the views directory (typically
58
+ # `app/views`).
59
+ def initialize(io_boundary:, search_paths:, base_classes:, views_root: DEFAULT_VIEWS_ROOT)
60
+ @io_boundary = io_boundary
61
+ @search_paths = search_paths
62
+ @base_classes = base_classes.to_set
63
+ @views_root = views_root
64
+ end
65
+
66
+ # @return [MailerIndex]
67
+ def discover
68
+ entries = []
69
+ ruby_files_under(@search_paths).each do |path|
70
+ contents = read_safely(path)
71
+ next if contents.nil?
72
+
73
+ tree = Prism.parse(contents).value
74
+ walk_for_mailers(tree, []) do |class_name, def_nodes|
75
+ entries << build_class_entry(class_name, path, def_nodes)
76
+ end
77
+ end
78
+ MailerIndex.new(entries)
79
+ end
80
+
81
+ private
82
+
83
+ def read_safely(path)
84
+ @io_boundary.read_file(path)
85
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
86
+ nil
87
+ end
88
+
89
+ def ruby_files_under(roots)
90
+ roots.flat_map do |root|
91
+ absolute = File.expand_path(root)
92
+ next [] unless File.directory?(absolute)
93
+
94
+ Dir.glob(File.join(absolute, "**", "*.rb"))
95
+ end
96
+ end
97
+
98
+ def walk_for_mailers(node, lexical_path, &)
99
+ return if node.nil?
100
+
101
+ case node
102
+ when Prism::ClassNode then visit_class(node, lexical_path, &)
103
+ when Prism::ModuleNode then visit_module(node, lexical_path, &)
104
+ else
105
+ node.compact_child_nodes.each { |child| walk_for_mailers(child, lexical_path, &) }
106
+ end
107
+ end
108
+
109
+ def visit_class(node, lexical_path, &)
110
+ class_local_name = constant_path_name(node.constant_path)
111
+ return if class_local_name.nil?
112
+
113
+ full_name = (lexical_path + [class_local_name]).join("::")
114
+ superclass = constant_path_name(node.superclass) if node.superclass
115
+ if superclass && @base_classes.include?(superclass)
116
+ def_nodes = collect_action_defs(node.body)
117
+ yield full_name, def_nodes
118
+ end
119
+
120
+ inner_path = lexical_path + [class_local_name]
121
+ walk_for_mailers(node.body, inner_path, &) if node.body
122
+ end
123
+
124
+ def visit_module(node, lexical_path, &)
125
+ module_local_name = constant_path_name(node.constant_path)
126
+ return if module_local_name.nil?
127
+
128
+ inner_path = lexical_path + [module_local_name]
129
+ walk_for_mailers(node.body, inner_path, &) if node.body
130
+ end
131
+
132
+ def constant_path_name(node)
133
+ return nil if node.nil?
134
+
135
+ case node
136
+ when Prism::ConstantReadNode then node.name.to_s
137
+ when Prism::ConstantPathNode
138
+ parts = []
139
+ current = node
140
+ while current.is_a?(Prism::ConstantPathNode)
141
+ parts.unshift(current.name.to_s)
142
+ current = current.parent
143
+ end
144
+ case current
145
+ when nil then "::#{parts.join('::')}"
146
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
147
+ end
148
+ end
149
+ end
150
+
151
+ # Returns the instance-side `def` nodes that look like
152
+ # mailer actions. Filters non-actions:
153
+ # - `initialize`
154
+ # - methods starting with `_` (Ruby convention for
155
+ # private/internal)
156
+ # - `def self.<name>` (singleton-side)
157
+ # - methods after a bare `private` (or
158
+ # `public` → `private` transition) — these are
159
+ # internal helpers, not actions
160
+ # - methods named as a `private :foo` argument
161
+ # - methods named as a callback target
162
+ # (`before_action :name`, `after_action`,
163
+ # `around_action`)
164
+ #
165
+ # Pre-fix, Mastodon's `AdminMailer#process_params` /
166
+ # `set_instance` / `set_locale` / `set_important_headers!`
167
+ # all surfaced as missing-view because the bare `private`
168
+ # keyword wasn't honoured. ~19 false positives across
169
+ # Mastodon's mailers.
170
+ CALLBACK_DECLARATIONS = %i[before_action after_action around_action].freeze
171
+ private_constant :CALLBACK_DECLARATIONS
172
+
173
+ def collect_action_defs(body)
174
+ return [] if body.nil?
175
+
176
+ private_names, callback_names = collect_visibility_and_callbacks(body)
177
+ visibility = :public
178
+
179
+ body.compact_child_nodes.flat_map do |node|
180
+ visibility = next_visibility(node, visibility)
181
+ next [] unless node.is_a?(Prism::DefNode)
182
+ next [] if node.receiver.is_a?(Prism::SelfNode)
183
+ next [] if node.name == :initialize
184
+ next [] if node.name.to_s.start_with?("_")
185
+ next [] if visibility == :private
186
+ next [] if private_names.include?(node.name)
187
+ next [] if callback_names.include?(node.name)
188
+
189
+ [node]
190
+ end
191
+ end
192
+
193
+ # First pass over the class body: collect (a) names
194
+ # passed to `private :foo` / `protected :foo` (explicit
195
+ # visibility-on-existing-method form), and (b) Symbol
196
+ # arguments to callback declarations
197
+ # (`before_action :setup`, etc.).
198
+ def collect_visibility_and_callbacks(body)
199
+ private_names = []
200
+ callback_names = []
201
+
202
+ body.compact_child_nodes.each do |node|
203
+ next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
204
+
205
+ args = (node.arguments&.arguments || []).filter_map do |arg|
206
+ arg.is_a?(Prism::SymbolNode) ? arg.unescaped.to_sym : nil
207
+ end
208
+ next if args.empty?
209
+
210
+ case node.name
211
+ when :private, :protected then private_names.concat(args)
212
+ when *CALLBACK_DECLARATIONS then callback_names.concat(args)
213
+ end
214
+ end
215
+
216
+ [private_names.to_set, callback_names.to_set]
217
+ end
218
+
219
+ # Returns the new visibility scope state after observing
220
+ # `node`. Bare `private` / `protected` / `public` switch
221
+ # state; the `private :foo` arg-bearing form does NOT
222
+ # (already handled by `collect_visibility_and_callbacks`).
223
+ def next_visibility(node, current)
224
+ return current unless node.is_a?(Prism::CallNode)
225
+ return current unless node.receiver.nil?
226
+ return current unless (args = node.arguments&.arguments).nil? || args.empty?
227
+
228
+ case node.name
229
+ when :private then :private
230
+ when :protected then :protected
231
+ when :public then :public
232
+ else current
233
+ end
234
+ end
235
+
236
+ def build_class_entry(class_name, file_path, def_nodes)
237
+ actions = def_nodes.to_h do |def_node|
238
+ entry = build_action_entry(def_node)
239
+ [entry.method_name, entry]
240
+ end
241
+
242
+ missing_views = actions.keys.reject { |action| view_exists?(class_name, action) }
243
+
244
+ MailerIndex::ClassEntry.new(
245
+ class_name: class_name,
246
+ file_path: file_path,
247
+ actions: actions,
248
+ missing_views: missing_views
249
+ )
250
+ end
251
+
252
+ def build_action_entry(def_node)
253
+ parameters = def_node.parameters
254
+ location = def_node.name_loc
255
+
256
+ if parameters.nil?
257
+ return MailerIndex::ActionEntry.new(
258
+ method_name: def_node.name,
259
+ min_arity: 0, max_arity: 0,
260
+ def_line: location.start_line,
261
+ def_column: location.start_column + 1
262
+ )
263
+ end
264
+
265
+ required_count = (parameters.requireds || []).size
266
+ optional_count = (parameters.optionals || []).size
267
+ rest_present = !parameters.rest.nil?
268
+
269
+ MailerIndex::ActionEntry.new(
270
+ method_name: def_node.name,
271
+ min_arity: required_count,
272
+ max_arity: rest_present ? Float::INFINITY : required_count + optional_count,
273
+ def_line: location.start_line,
274
+ def_column: location.start_column + 1
275
+ )
276
+ end
277
+
278
+ # Checks whether *any* template under
279
+ # `app/views/<underscore>/<action>.{html,text}.{erb,haml,slim}`
280
+ # exists, by attempting to read each candidate via the
281
+ # IoBoundary. Successful reads are recorded by the
282
+ # boundary; failed reads (missing file or access
283
+ # denied) are swallowed.
284
+ def view_exists?(class_name, action_name)
285
+ views_root_absolute = File.expand_path(@views_root)
286
+ underscore_path = underscore(class_name.delete_prefix("::"))
287
+ mailer_dir = File.join(views_root_absolute, underscore_path)
288
+
289
+ VIEW_FORMATS.any? do |format|
290
+ VIEW_EXTENSIONS.any? do |ext|
291
+ candidate = File.join(mailer_dir, "#{action_name}.#{format}.#{ext}")
292
+ read_safely(candidate)
293
+ end
294
+ end
295
+ end
296
+
297
+ # Convert `Foo::BarMailer` → `foo/bar_mailer`. Mirrors
298
+ # ActiveSupport's String#underscore for ASCII-only
299
+ # constant names; we don't try to be inflector-perfect
300
+ # here.
301
+ def underscore(name)
302
+ name.gsub("::", "/")
303
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
304
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
305
+ .downcase
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Actionmailer < Rigor::Plugin::Base
6
+ # Frozen catalogue of discovered Mailer classes, each
7
+ # carrying:
8
+ #
9
+ # - the action methods it defines (arity envelope per
10
+ # action; same shape as `rigor-activejob`'s
11
+ # `JobIndex::Entry`)
12
+ # - the source file path the class was declared in
13
+ # (used to anchor missing-view diagnostics on the
14
+ # mailer file)
15
+ # - the list of `(action, location)` pairs whose view
16
+ # templates are missing from `app/views/`
17
+ class MailerIndex
18
+ ActionEntry = Data.define(:method_name, :min_arity, :max_arity, :def_line, :def_column) do
19
+ def arity_label
20
+ return "#{min_arity}+" if max_arity == Float::INFINITY
21
+ return min_arity.to_s if min_arity == max_arity
22
+
23
+ "#{min_arity}..#{max_arity}"
24
+ end
25
+
26
+ def accepts?(actual)
27
+ actual.between?(min_arity, max_arity)
28
+ end
29
+ end
30
+
31
+ ClassEntry = Data.define(:class_name, :file_path, :actions, :missing_views) do
32
+ def find_action(method_name)
33
+ actions[method_name.to_sym]
34
+ end
35
+ end
36
+
37
+ attr_reader :entries
38
+
39
+ def initialize(entries)
40
+ @entries = entries.freeze
41
+ @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
42
+ freeze
43
+ end
44
+
45
+ # @return [ClassEntry, nil]
46
+ def find(class_name)
47
+ @by_name[class_name.to_s]
48
+ end
49
+
50
+ def known?(class_name)
51
+ @by_name.key?(class_name.to_s)
52
+ end
53
+
54
+ # @param file_path [String] absolute path of a mailer
55
+ # file (canonicalised — see plugin entry's
56
+ # `harvest`)
57
+ # @return [ClassEntry, nil]
58
+ def find_by_file(file_path)
59
+ @entries.find { |entry| entry.file_path == file_path }
60
+ end
61
+
62
+ def empty?
63
+ @entries.empty?
64
+ end
65
+
66
+ def size
67
+ @entries.size
68
+ end
69
+
70
+ def names
71
+ @by_name.keys
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "actionmailer/mailer_index"
6
+ require_relative "actionmailer/mailer_discoverer"
7
+ require_relative "actionmailer/analyzer"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-actionmailer — validates `Mailer.action(args)`
12
+ # call sites and detects missing view templates.
13
+ #
14
+ # Tier 1C of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
15
+ # Statically discovers mailer classes by walking
16
+ # `mailer_search_paths` and parsing each file with
17
+ # Prism — no `action_mailer` runtime dependency.
18
+ #
19
+ # ## Configuration
20
+ #
21
+ # plugins:
22
+ # - gem: rigor-actionmailer
23
+ # config:
24
+ # mailer_search_paths: ["app/mailers"] # default; optional
25
+ # mailer_base_classes: ["ApplicationMailer", "ActionMailer::Base"] # default; optional
26
+ # views_root: "app/views" # default; optional
27
+ #
28
+ # ## What it checks
29
+ #
30
+ # 1. **Method existence** — `UserMailer.welcome(user)`
31
+ # is flagged when `welcome` is not defined on
32
+ # `UserMailer`.
33
+ # 2. **Argument arity** — calls with too few / too many
34
+ # positional arguments emit `wrong-arity`.
35
+ # 3. **View template existence** — for every action
36
+ # method, at least one of
37
+ # `app/views/<mailer_underscore>/<action>.{html,text}.{erb,haml,slim}`
38
+ # must exist. Missing actions get a `missing-view`
39
+ # diagnostic anchored on the action's `def`.
40
+ #
41
+ # ## Limitations (v0.1.0)
42
+ #
43
+ # - Direct-superclass match only.
44
+ # - Action methods are read from the syntactic instance-
45
+ # side `def` list. `define_method` actions are out of
46
+ # scope.
47
+ # - Adding a brand-new view file does not invalidate the
48
+ # cache until something the mailer file touches
49
+ # changes.
50
+ class Actionmailer < Rigor::Plugin::Base
51
+ manifest(
52
+ id: "actionmailer",
53
+ version: "0.1.0",
54
+ description: "Validates ActionMailer call shape and view template existence.",
55
+ config_schema: {
56
+ "mailer_search_paths" => :array,
57
+ "mailer_base_classes" => :array,
58
+ "views_root" => :string
59
+ }
60
+ )
61
+
62
+ DEFAULT_MAILER_SEARCH_PATHS = ["app/mailers"].freeze
63
+ DEFAULT_MAILER_BASE_CLASSES = %w[ApplicationMailer ActionMailer::Base].freeze
64
+ DEFAULT_VIEWS_ROOT = "app/views"
65
+
66
+ producer :mailer_index do |_params|
67
+ MailerDiscoverer.new(
68
+ io_boundary: io_boundary,
69
+ search_paths: @mailer_search_paths,
70
+ base_classes: @mailer_base_classes,
71
+ views_root: @views_root
72
+ ).discover
73
+ end
74
+
75
+ def init(_services)
76
+ @mailer_search_paths = Array(config.fetch("mailer_search_paths", DEFAULT_MAILER_SEARCH_PATHS)).map(&:to_s)
77
+ @mailer_base_classes = Array(config.fetch("mailer_base_classes", DEFAULT_MAILER_BASE_CLASSES)).map(&:to_s)
78
+ @views_root = config.fetch("views_root", DEFAULT_VIEWS_ROOT).to_s
79
+ @mailer_index = nil
80
+ @load_error = nil
81
+ end
82
+
83
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
84
+ index = mailer_index_or_nil
85
+ return [load_error_diagnostic(path)] if index.nil? && @load_error
86
+ return [] if index.nil? || index.empty?
87
+
88
+ diagnostics = []
89
+ diagnostics.concat(call_site_diagnostics(path, root, index))
90
+ diagnostics.concat(missing_view_diagnostics(path, index))
91
+ diagnostics
92
+ end
93
+
94
+ private
95
+
96
+ def mailer_index_or_nil
97
+ return @mailer_index if @mailer_index
98
+
99
+ # Two-glob descriptor: every mailer class under
100
+ # `mailer_search_paths` AND every view template under
101
+ # `views_root`. Without explicit enumeration the cache
102
+ # invalidates only on files the `IoBoundary` has already
103
+ # read in the current process — empty on the first call
104
+ # of a fresh process, so warm hits would serve stale
105
+ # `MailerIndex` data after mailers are added / removed or
106
+ # view templates are added (`view_exists?` failures aren't
107
+ # recorded, so the auto-built descriptor cannot detect a
108
+ # newly-added view).
109
+ mailer_d = glob_descriptor(@mailer_search_paths, "**/*.rb")
110
+ view_d = glob_descriptor([@views_root], "**/*")
111
+ descriptor = Rigor::Cache::Descriptor.compose(mailer_d, view_d)
112
+ @mailer_index = cache_for(:mailer_index, params: {}, descriptor: descriptor).call
113
+ rescue StandardError => e
114
+ @load_error = "rigor-actionmailer: failed to discover mailers: #{e.class}: #{e.message}"
115
+ nil
116
+ end
117
+
118
+ def call_site_diagnostics(path, root, index)
119
+ Analyzer.diagnose(path: path, root: root, mailer_index: index).map { |diag| build_diagnostic(diag) }
120
+ end
121
+
122
+ # Anchors `missing-view` diagnostics on the mailer file
123
+ # itself: when the file currently being analysed is the
124
+ # mailer's source file, emit one diagnostic per missing
125
+ # action template at the action's `def` location.
126
+ def missing_view_diagnostics(path, index)
127
+ canonical = canonical_path(path)
128
+ class_entry = index.find_by_file(canonical)
129
+ return [] if class_entry.nil? || class_entry.missing_views.empty?
130
+
131
+ class_entry.missing_views.map do |action_name|
132
+ action_entry = class_entry.find_action(action_name)
133
+ Rigor::Analysis::Diagnostic.new(
134
+ path: path,
135
+ line: action_entry&.def_line || 1,
136
+ column: action_entry&.def_column || 1,
137
+ severity: :warning,
138
+ rule: "missing-view",
139
+ message: "`#{class_entry.class_name}##{action_name}` has no view template " \
140
+ "under `#{@views_root}/#{underscore(class_entry.class_name.delete_prefix('::'))}/`"
141
+ )
142
+ end
143
+ end
144
+
145
+ def canonical_path(path)
146
+ File.realpath(path)
147
+ rescue StandardError
148
+ File.expand_path(path)
149
+ end
150
+
151
+ def underscore(name)
152
+ name.gsub("::", "/")
153
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
154
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
155
+ .downcase
156
+ end
157
+
158
+ def load_error_diagnostic(path)
159
+ Rigor::Analysis::Diagnostic.new(
160
+ path: path, line: 1, column: 1,
161
+ message: @load_error,
162
+ severity: :warning,
163
+ rule: "load-error"
164
+ )
165
+ end
166
+
167
+ def build_diagnostic(diag)
168
+ Rigor::Analysis::Diagnostic.new(
169
+ path: diag.path, line: diag.line, column: diag.column,
170
+ message: diag.message, severity: diag.severity, rule: diag.rule
171
+ )
172
+ end
173
+ end
174
+
175
+ Rigor::Plugin.register(Actionmailer)
176
+ end
177
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/actionmailer"