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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli.rb +16 -3
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- 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
|