rigortype 0.1.10 → 0.1.12

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -0,0 +1,199 @@
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
+ respond_to? respond_to_missing? method_defined?
44
+ public_send send __send__ public_method
45
+ method instance_method methods
46
+ ].freeze
47
+
48
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
49
+
50
+ module_function
51
+
52
+ # @param path [String]
53
+ # @param root [Prism::Node]
54
+ # @param mailer_index [MailerIndex]
55
+ # @return [Array<Diagnostic>]
56
+ def diagnose(path:, root:, mailer_index:)
57
+ diagnostics = []
58
+ walk(root) do |call_node|
59
+ class_name = mailer_class_for_call(call_node)
60
+ next if class_name.nil?
61
+ next if RESERVED_CLASS_METHODS.include?(call_node.name)
62
+
63
+ class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
64
+ next if class_entry.nil?
65
+
66
+ action_entry = class_entry.find_action(call_node.name)
67
+ if action_entry.nil?
68
+ # Skip `unknown-action` when the mailer's include
69
+ # set has any unresolved module — the unresolved
70
+ # module may legitimately define the action
71
+ # (gem-shipped concern, dynamically loaded
72
+ # mailer extension). Mirrors the same predicate
73
+ # `rigor-actionpack` uses for unknown-filter-method.
74
+ next if class_entry.unresolved_includes?
75
+
76
+ diagnostics << unknown_action_diagnostic(path, call_node, class_entry)
77
+ next
78
+ end
79
+
80
+ diagnostics << action_call_info(path, call_node, class_entry, action_entry)
81
+ arity_diag = arity_check(path, call_node, class_entry, action_entry)
82
+ diagnostics << arity_diag if arity_diag
83
+ end
84
+ diagnostics
85
+ end
86
+
87
+ # Walks the tree yielding every CallNode whose receiver
88
+ # resolves (directly or through `.with(...)`) to a
89
+ # constant.
90
+ def walk(node, &)
91
+ return unless node.is_a?(Prism::Node)
92
+
93
+ yield node if node.is_a?(Prism::CallNode) && action_call_candidate?(node)
94
+ node.compact_child_nodes.each { |child| walk(child, &) }
95
+ end
96
+
97
+ def action_call_candidate?(node)
98
+ # Skip anything that doesn't look like a mailer
99
+ # action call: no receiver, or a non-constant /
100
+ # non-`.with(...)` receiver.
101
+ return false if node.receiver.nil?
102
+
103
+ mailer_class_for_call(node) ? true : false
104
+ end
105
+
106
+ # Extracts the mailer class name when the call's
107
+ # receiver is either:
108
+ # - A constant (`UserMailer.welcome(...)`), or
109
+ # - A `.with(...)` call whose receiver is a constant
110
+ # (`UserMailer.with(user: u).welcome`).
111
+ def mailer_class_for_call(node)
112
+ receiver = node.receiver
113
+ case receiver
114
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
115
+ constant_receiver_name(receiver)
116
+ when Prism::CallNode
117
+ return nil unless WITH_METHODS.include?(receiver.name)
118
+
119
+ constant_receiver_name(receiver.receiver)
120
+ end
121
+ end
122
+
123
+ def action_call_info(path, call_node, class_entry, action_entry)
124
+ location = call_node.location
125
+ Diagnostic.new(
126
+ path: path,
127
+ line: location.start_line,
128
+ column: location.start_column + 1,
129
+ severity: :info,
130
+ rule: "mailer-call",
131
+ message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
132
+ "matches mailer action (arity #{action_entry.arity_label})"
133
+ )
134
+ end
135
+
136
+ def arity_check(path, call_node, class_entry, action_entry)
137
+ args = call_node.arguments&.arguments || []
138
+ actual = args.size
139
+ return nil if action_entry.accepts?(actual)
140
+
141
+ # Trailing keyword-hash relaxation. `Notify.foo(uid,
142
+ # gid, success_count: 5)` is 3 positional args from
143
+ # Prism's perspective (2 + a KeywordHashNode); the
144
+ # action's `def foo(uid, gid, success_count:)` has
145
+ # arity 2. When the call's trailing arg is a kwargs
146
+ # hash, allow `(actual - 1) ≤ max_arity` so kwargs-
147
+ # carrying calls don't surface as wrong-arity.
148
+ return nil if args.last.is_a?(Prism::KeywordHashNode) && action_entry.accepts?(actual - 1)
149
+
150
+ location = call_node.location
151
+ Diagnostic.new(
152
+ path: path,
153
+ line: location.start_line,
154
+ column: location.start_column + 1,
155
+ severity: :error,
156
+ rule: "wrong-arity",
157
+ message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
158
+ "expects #{action_entry.arity_label} argument(s), got #{actual}"
159
+ )
160
+ end
161
+
162
+ def unknown_action_diagnostic(path, call_node, class_entry)
163
+ location = call_node.location
164
+ known = class_entry.actions.keys.sort.join(", ")
165
+ known_part = known.empty? ? "no actions defined" : "known actions: #{known}"
166
+ Diagnostic.new(
167
+ path: path,
168
+ line: location.start_line,
169
+ column: location.start_column + 1,
170
+ severity: :error,
171
+ rule: "unknown-action",
172
+ message: "`#{class_entry.class_name}.#{call_node.name}` is not a defined " \
173
+ "mailer action (#{known_part})"
174
+ )
175
+ end
176
+
177
+ def constant_receiver_name(node)
178
+ case node
179
+ when Prism::ConstantReadNode then node.name.to_s
180
+ when Prism::ConstantPathNode then constant_path_name(node)
181
+ end
182
+ end
183
+
184
+ def constant_path_name(node)
185
+ parts = []
186
+ current = node
187
+ while current.is_a?(Prism::ConstantPathNode)
188
+ parts.unshift(current.name.to_s)
189
+ current = current.parent
190
+ end
191
+ case current
192
+ when nil then "::#{parts.join('::')}"
193
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,398 @@
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
+ # Two-pass: first collect every module's defs (for
69
+ # the include-following step), then build per-class
70
+ # entries that pull in actions from include'd modules.
71
+ # GitLab's `Notify` mailer derives every action from
72
+ # `Emails::*` concerns under `app/mailers/emails/`.
73
+ module_actions = {} # module_fqn => Hash<Symbol, ActionEntry>
74
+ class_visits = [] # collected (class_name, path, def_nodes, includes)
75
+
76
+ ruby_files_under(@search_paths).each do |path|
77
+ contents = read_safely(path)
78
+ next if contents.nil?
79
+
80
+ tree = Prism.parse(contents).value
81
+ walk_for_mailers(tree, []) do |class_name, def_nodes, includes|
82
+ class_visits << [class_name, path, def_nodes, includes]
83
+ end
84
+ collect_module_actions(tree, [], module_actions)
85
+ end
86
+
87
+ entries = class_visits.map do |class_name, path, def_nodes, includes|
88
+ build_class_entry(class_name, path, def_nodes, includes, module_actions)
89
+ end
90
+ MailerIndex.new(entries)
91
+ end
92
+
93
+ private
94
+
95
+ def read_safely(path)
96
+ @io_boundary.read_file(path)
97
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
98
+ nil
99
+ end
100
+
101
+ def ruby_files_under(roots)
102
+ roots.flat_map do |root|
103
+ absolute = File.expand_path(root)
104
+ next [] unless File.directory?(absolute)
105
+
106
+ Dir.glob(File.join(absolute, "**", "*.rb"))
107
+ end
108
+ end
109
+
110
+ def walk_for_mailers(node, lexical_path, &)
111
+ return if node.nil?
112
+
113
+ case node
114
+ when Prism::ClassNode then visit_class(node, lexical_path, &)
115
+ when Prism::ModuleNode then visit_module(node, lexical_path, &)
116
+ else
117
+ node.compact_child_nodes.each { |child| walk_for_mailers(child, lexical_path, &) }
118
+ end
119
+ end
120
+
121
+ def visit_class(node, lexical_path, &)
122
+ class_local_name = constant_path_name(node.constant_path)
123
+ return if class_local_name.nil?
124
+
125
+ full_name = (lexical_path + [class_local_name]).join("::")
126
+ superclass = constant_path_name(node.superclass) if node.superclass
127
+ if superclass && @base_classes.include?(superclass)
128
+ def_nodes = collect_action_defs(node.body)
129
+ includes = collect_includes(node.body)
130
+ yield full_name, def_nodes, includes
131
+ end
132
+
133
+ inner_path = lexical_path + [class_local_name]
134
+ walk_for_mailers(node.body, inner_path, &) if node.body
135
+ end
136
+
137
+ # Collects qualified-constant names passed to `include
138
+ # X` calls inside the class body. Used to look up
139
+ # concern-module action definitions (GitLab's
140
+ # `Notify` mailer derives every action from
141
+ # `Emails::Issues`, `Emails::MergeRequests`, etc.).
142
+ def collect_includes(body)
143
+ return [] if body.nil?
144
+
145
+ names = []
146
+ body.compact_child_nodes.each do |node|
147
+ next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
148
+
149
+ (node.arguments&.arguments || []).each do |arg|
150
+ name = constant_path_name(arg)
151
+ names << name.delete_prefix("::") if name
152
+ end
153
+ end
154
+ names
155
+ end
156
+
157
+ # Walks the AST collecting every module's instance-side
158
+ # def nodes by fully-qualified module name. The same
159
+ # `collect_action_defs` filter applies (private /
160
+ # `_`-prefixed / callback-target methods skipped).
161
+ def collect_module_actions(node, lexical_path, accumulator)
162
+ return if node.nil?
163
+
164
+ case node
165
+ when Prism::ModuleNode
166
+ local_name = constant_path_name(node.constant_path)
167
+ return if local_name.nil?
168
+
169
+ full_name = (lexical_path + [local_name.delete_prefix("::")]).join("::")
170
+ if node.body
171
+ def_nodes = collect_action_defs(node.body)
172
+ entries = def_nodes.to_h { |def_node| [def_node.name, build_action_entry(def_node)] }
173
+ accumulator[full_name] = entries unless entries.empty?
174
+ collect_module_actions(node.body, lexical_path + [local_name.delete_prefix("::")], accumulator)
175
+ end
176
+ when Prism::ClassNode
177
+ local_name = constant_path_name(node.constant_path)
178
+ inner = local_name ? lexical_path + [local_name.delete_prefix("::")] : lexical_path
179
+ collect_module_actions(node.body, inner, accumulator) if node.body
180
+ else
181
+ node.compact_child_nodes.each { |child| collect_module_actions(child, lexical_path, accumulator) }
182
+ end
183
+ end
184
+
185
+ def visit_module(node, lexical_path, &)
186
+ module_local_name = constant_path_name(node.constant_path)
187
+ return if module_local_name.nil?
188
+
189
+ inner_path = lexical_path + [module_local_name]
190
+ walk_for_mailers(node.body, inner_path, &) if node.body
191
+ end
192
+
193
+ def constant_path_name(node)
194
+ return nil if node.nil?
195
+
196
+ case node
197
+ when Prism::ConstantReadNode then node.name.to_s
198
+ when Prism::ConstantPathNode
199
+ parts = []
200
+ current = node
201
+ while current.is_a?(Prism::ConstantPathNode)
202
+ parts.unshift(current.name.to_s)
203
+ current = current.parent
204
+ end
205
+ case current
206
+ when nil then "::#{parts.join('::')}"
207
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
208
+ end
209
+ end
210
+ end
211
+
212
+ # Returns the instance-side `def` nodes that look like
213
+ # mailer actions. Filters non-actions:
214
+ # - `initialize`
215
+ # - methods starting with `_` (Ruby convention for
216
+ # private/internal)
217
+ # - `def self.<name>` (singleton-side)
218
+ # - methods after a bare `private` (or
219
+ # `public` → `private` transition) — these are
220
+ # internal helpers, not actions
221
+ # - methods named as a `private :foo` argument
222
+ # - methods named as a callback target
223
+ # (`before_action :name`, `after_action`,
224
+ # `around_action`)
225
+ #
226
+ # Pre-fix, Mastodon's `AdminMailer#process_params` /
227
+ # `set_instance` / `set_locale` / `set_important_headers!`
228
+ # all surfaced as missing-view because the bare `private`
229
+ # keyword wasn't honoured. ~19 false positives across
230
+ # Mastodon's mailers.
231
+ CALLBACK_DECLARATIONS = %i[before_action after_action around_action].freeze
232
+ private_constant :CALLBACK_DECLARATIONS
233
+
234
+ def collect_action_defs(body)
235
+ return [] if body.nil?
236
+
237
+ private_names, callback_names = collect_visibility_and_callbacks(body)
238
+ visibility = :public
239
+
240
+ body.compact_child_nodes.flat_map do |node|
241
+ visibility = next_visibility(node, visibility)
242
+ next [] unless node.is_a?(Prism::DefNode)
243
+ next [] if node.receiver.is_a?(Prism::SelfNode)
244
+ next [] if node.name == :initialize
245
+ next [] if node.name.to_s.start_with?("_")
246
+ next [] if visibility == :private
247
+ next [] if private_names.include?(node.name)
248
+ next [] if callback_names.include?(node.name)
249
+
250
+ [node]
251
+ end
252
+ end
253
+
254
+ # First pass over the class body: collect (a) names
255
+ # passed to `private :foo` / `protected :foo` (explicit
256
+ # visibility-on-existing-method form), and (b) Symbol
257
+ # arguments to callback declarations
258
+ # (`before_action :setup`, etc.).
259
+ def collect_visibility_and_callbacks(body)
260
+ private_names = []
261
+ callback_names = []
262
+
263
+ body.compact_child_nodes.each do |node|
264
+ next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
265
+
266
+ args = (node.arguments&.arguments || []).filter_map do |arg|
267
+ arg.is_a?(Prism::SymbolNode) ? arg.unescaped.to_sym : nil
268
+ end
269
+ next if args.empty?
270
+
271
+ case node.name
272
+ when :private, :protected then private_names.concat(args)
273
+ when *CALLBACK_DECLARATIONS then callback_names.concat(args)
274
+ end
275
+ end
276
+
277
+ [private_names.to_set, callback_names.to_set]
278
+ end
279
+
280
+ # Returns the new visibility scope state after observing
281
+ # `node`. Bare `private` / `protected` / `public` switch
282
+ # state; the `private :foo` arg-bearing form does NOT
283
+ # (already handled by `collect_visibility_and_callbacks`).
284
+ def next_visibility(node, current)
285
+ return current unless node.is_a?(Prism::CallNode)
286
+ return current unless node.receiver.nil?
287
+ return current unless (args = node.arguments&.arguments).nil? || args.empty?
288
+
289
+ case node.name
290
+ when :private then :private
291
+ when :protected then :protected
292
+ when :public then :public
293
+ else current
294
+ end
295
+ end
296
+
297
+ def build_class_entry(class_name, file_path, def_nodes, includes = [], module_actions = {})
298
+ actions = def_nodes.to_h do |def_node|
299
+ entry = build_action_entry(def_node)
300
+ [entry.method_name, entry]
301
+ end
302
+
303
+ # Merge in actions from include'd modules. The
304
+ # discoverer pre-collected every module's defs as
305
+ # `module_actions` keyed by fully-qualified module
306
+ # name. We resolve each include against that map —
307
+ # tries the full include name first, then walks down
308
+ # the class's lexical chain looking for a nested
309
+ # match (e.g. `Emails::Issues` inside `class Notify`
310
+ # at top-level resolves to top-level `Emails::Issues`).
311
+ # Includes we cannot resolve are silently skipped;
312
+ # the per-mailer `unresolved_includes?` predicate
313
+ # below (consumed by the analyzer) downgrades
314
+ # `unknown-action` to silence when any include is
315
+ # unresolved.
316
+ unresolved_includes = []
317
+ includes.each do |include_name|
318
+ inc_actions = module_actions[include_name]
319
+ if inc_actions.nil?
320
+ unresolved_includes << include_name
321
+ next
322
+ end
323
+
324
+ inc_actions.each do |method_name, entry|
325
+ actions[method_name] ||= entry
326
+ end
327
+ end
328
+
329
+ missing_views = actions.keys.reject { |action| view_exists?(class_name, action) }
330
+
331
+ MailerIndex::ClassEntry.new(
332
+ class_name: class_name,
333
+ file_path: file_path,
334
+ actions: actions,
335
+ missing_views: missing_views,
336
+ unresolved_includes: unresolved_includes.freeze
337
+ )
338
+ end
339
+
340
+ def build_action_entry(def_node)
341
+ parameters = def_node.parameters
342
+ location = def_node.name_loc
343
+
344
+ if parameters.nil?
345
+ return MailerIndex::ActionEntry.new(
346
+ method_name: def_node.name,
347
+ min_arity: 0, max_arity: 0,
348
+ def_line: location.start_line,
349
+ def_column: location.start_column + 1
350
+ )
351
+ end
352
+
353
+ required_count = (parameters.requireds || []).size
354
+ optional_count = (parameters.optionals || []).size
355
+ rest_present = !parameters.rest.nil?
356
+
357
+ MailerIndex::ActionEntry.new(
358
+ method_name: def_node.name,
359
+ min_arity: required_count,
360
+ max_arity: rest_present ? Float::INFINITY : required_count + optional_count,
361
+ def_line: location.start_line,
362
+ def_column: location.start_column + 1
363
+ )
364
+ end
365
+
366
+ # Checks whether *any* template under
367
+ # `app/views/<underscore>/<action>.{html,text}.{erb,haml,slim}`
368
+ # exists, by attempting to read each candidate via the
369
+ # IoBoundary. Successful reads are recorded by the
370
+ # boundary; failed reads (missing file or access
371
+ # denied) are swallowed.
372
+ def view_exists?(class_name, action_name)
373
+ views_root_absolute = File.expand_path(@views_root)
374
+ underscore_path = underscore(class_name.delete_prefix("::"))
375
+ mailer_dir = File.join(views_root_absolute, underscore_path)
376
+
377
+ VIEW_FORMATS.any? do |format|
378
+ VIEW_EXTENSIONS.any? do |ext|
379
+ candidate = File.join(mailer_dir, "#{action_name}.#{format}.#{ext}")
380
+ read_safely(candidate)
381
+ end
382
+ end
383
+ end
384
+
385
+ # Convert `Foo::BarMailer` → `foo/bar_mailer`. Mirrors
386
+ # ActiveSupport's String#underscore for ASCII-only
387
+ # constant names; we don't try to be inflector-perfect
388
+ # here.
389
+ def underscore(name)
390
+ name.gsub("::", "/")
391
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
392
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
393
+ .downcase
394
+ end
395
+ end
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,86 @@
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, :unresolved_includes) do
32
+ def find_action(method_name)
33
+ actions[method_name.to_sym]
34
+ end
35
+
36
+ # True when the mailer `include`s a module whose
37
+ # source we couldn't index (typically a gem-shipped
38
+ # concern that defines additional mailer actions).
39
+ # Analyzer downgrades `unknown-action` to silence in
40
+ # this case — the unresolved module may legitimately
41
+ # provide the action.
42
+ def unresolved_includes?
43
+ !unresolved_includes.empty?
44
+ end
45
+ end
46
+
47
+ attr_reader :entries
48
+
49
+ def initialize(entries)
50
+ @entries = entries.freeze
51
+ @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
52
+ freeze
53
+ end
54
+
55
+ # @return [ClassEntry, nil]
56
+ def find(class_name)
57
+ @by_name[class_name.to_s]
58
+ end
59
+
60
+ def known?(class_name)
61
+ @by_name.key?(class_name.to_s)
62
+ end
63
+
64
+ # @param file_path [String] absolute path of a mailer
65
+ # file (canonicalised — see plugin entry's
66
+ # `harvest`)
67
+ # @return [ClassEntry, nil]
68
+ def find_by_file(file_path)
69
+ @entries.find { |entry| entry.file_path == file_path }
70
+ end
71
+
72
+ def empty?
73
+ @entries.empty?
74
+ end
75
+
76
+ def size
77
+ @entries.size
78
+ end
79
+
80
+ def names
81
+ @by_name.keys
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end