rigortype 0.1.9 → 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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -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
@@ -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