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,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Actioncable < Rigor::Plugin::Base
|
|
6
|
+
# Frozen catalogue of discovered ActionCable channel
|
|
7
|
+
# classes keyed by qualified class name. Each entry
|
|
8
|
+
# holds:
|
|
9
|
+
#
|
|
10
|
+
# - `action_methods` — the set of public instance
|
|
11
|
+
# methods that aren't ActionCable framework hooks
|
|
12
|
+
# (`subscribed` / `unsubscribed`). These are the
|
|
13
|
+
# methods clients invoke via
|
|
14
|
+
# `subscription.perform("action_name", data)`.
|
|
15
|
+
# - `stream_names` — the set of literal-string stream
|
|
16
|
+
# names registered via `stream_from "name"` calls
|
|
17
|
+
# inside the channel body. Dynamic registrations
|
|
18
|
+
# (`stream_from interpolated_string`) are recorded
|
|
19
|
+
# separately as `dynamic_streams: true` so the
|
|
20
|
+
# analyzer can suppress unknown-stream warnings on
|
|
21
|
+
# any channel that has at least one dynamic
|
|
22
|
+
# registration.
|
|
23
|
+
class ChannelIndex
|
|
24
|
+
Entry = Data.define(:class_name, :file_path, :action_methods, :stream_names, :dynamic_streams) do
|
|
25
|
+
def includes_action?(name)
|
|
26
|
+
action_methods.include?(name.to_sym)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def known_actions
|
|
30
|
+
action_methods.to_a.sort
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :entries
|
|
35
|
+
|
|
36
|
+
def initialize(entries)
|
|
37
|
+
@entries = entries.freeze
|
|
38
|
+
@by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Entry, nil]
|
|
43
|
+
def find(class_name)
|
|
44
|
+
@by_name[class_name.to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def known?(class_name)
|
|
48
|
+
@by_name.key?(class_name.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty?
|
|
52
|
+
@entries.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def size
|
|
56
|
+
@entries.size
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def names
|
|
60
|
+
@by_name.keys
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# All literal stream names registered across every
|
|
64
|
+
# discovered channel.
|
|
65
|
+
def all_stream_names
|
|
66
|
+
@entries.flat_map { |e| e.stream_names.to_a }.to_set
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# True when at least one discovered channel uses a
|
|
70
|
+
# dynamic stream registration. The analyzer treats
|
|
71
|
+
# this as "we can't be sure any literal name is
|
|
72
|
+
# missing" and downgrades unknown-stream from
|
|
73
|
+
# `:warning` to `:info` (or drops it entirely;
|
|
74
|
+
# current behaviour: skip warnings).
|
|
75
|
+
def any_dynamic_streams?
|
|
76
|
+
@entries.any?(&:dynamic_streams)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
require_relative "actioncable/channel_index"
|
|
6
|
+
require_relative "actioncable/channel_discoverer"
|
|
7
|
+
require_relative "actioncable/analyzer"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Plugin
|
|
11
|
+
# rigor-actioncable — validates ActionCable
|
|
12
|
+
# `<Channel>.broadcast_to(...)` and
|
|
13
|
+
# `ActionCable.server.broadcast(stream_name, ...)`
|
|
14
|
+
# call sites against the discovered channel index.
|
|
15
|
+
#
|
|
16
|
+
# Tier 3F of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
|
|
17
|
+
# Statically discovers channel classes by walking
|
|
18
|
+
# `channel_search_paths` and parsing each file with
|
|
19
|
+
# Prism — no `actioncable` runtime dependency.
|
|
20
|
+
#
|
|
21
|
+
# ## Configuration
|
|
22
|
+
#
|
|
23
|
+
# plugins:
|
|
24
|
+
# - gem: rigor-actioncable
|
|
25
|
+
# config:
|
|
26
|
+
# channel_search_paths: ["app/channels"] # default; optional
|
|
27
|
+
# channel_base_classes: ["ApplicationCable::Channel", "ActionCable::Channel::Base"] # default; optional
|
|
28
|
+
#
|
|
29
|
+
# ## What it checks
|
|
30
|
+
#
|
|
31
|
+
# 1. **Channel class existence** — `<X>.broadcast_to(...)`
|
|
32
|
+
# where `X` ends in `Channel` must resolve to a
|
|
33
|
+
# discovered channel.
|
|
34
|
+
# 2. **Stream-name registration** —
|
|
35
|
+
# `ActionCable.server.broadcast("stream_name", ...)`
|
|
36
|
+
# with a literal stream name is checked against
|
|
37
|
+
# every discovered channel's `stream_from "..."`
|
|
38
|
+
# registrations. The check is suppressed when ANY
|
|
39
|
+
# discovered channel uses a dynamic registration
|
|
40
|
+
# (`stream_from interpolated_string` or
|
|
41
|
+
# `stream_for record`) — the absence of a literal
|
|
42
|
+
# match doesn't prove absence.
|
|
43
|
+
#
|
|
44
|
+
# ## Limitations (v0.1.0)
|
|
45
|
+
#
|
|
46
|
+
# - **Direct-superclass match only.** Indirect
|
|
47
|
+
# inheritance (`AdminChannel < BaseChannel <
|
|
48
|
+
# ApplicationCable::Channel`) needs `BaseChannel`
|
|
49
|
+
# listed in `channel_base_classes`.
|
|
50
|
+
# - **Action method invocations are not validated.**
|
|
51
|
+
# ActionCable actions are invoked from JS via
|
|
52
|
+
# `subscription.perform("action_name", data)`; we
|
|
53
|
+
# don't analyse JS so the action-method index is
|
|
54
|
+
# currently informational only (future cross-plugin
|
|
55
|
+
# handoff to a hypothetical JS-side analyzer).
|
|
56
|
+
# - **`broadcast_to` arity isn't checked.** The method
|
|
57
|
+
# takes any record + any data hash; there's no
|
|
58
|
+
# useful arity envelope.
|
|
59
|
+
class Actioncable < Rigor::Plugin::Base
|
|
60
|
+
manifest(
|
|
61
|
+
id: "actioncable",
|
|
62
|
+
version: "0.1.0",
|
|
63
|
+
description: "Validates ActionCable broadcast call shape against discovered channels.",
|
|
64
|
+
config_schema: {
|
|
65
|
+
"channel_search_paths" => :array,
|
|
66
|
+
"channel_base_classes" => :array
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
DEFAULT_CHANNEL_SEARCH_PATHS = ["app/channels"].freeze
|
|
71
|
+
DEFAULT_CHANNEL_BASE_CLASSES = [
|
|
72
|
+
"ApplicationCable::Channel",
|
|
73
|
+
"ActionCable::Channel::Base"
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
producer :channel_index do |_params|
|
|
77
|
+
ChannelDiscoverer.new(
|
|
78
|
+
io_boundary: io_boundary,
|
|
79
|
+
search_paths: @channel_search_paths,
|
|
80
|
+
base_classes: @channel_base_classes
|
|
81
|
+
).discover
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def init(_services)
|
|
85
|
+
@channel_search_paths = Array(
|
|
86
|
+
config.fetch("channel_search_paths", DEFAULT_CHANNEL_SEARCH_PATHS)
|
|
87
|
+
).map(&:to_s)
|
|
88
|
+
@channel_base_classes = Array(
|
|
89
|
+
config.fetch("channel_base_classes", DEFAULT_CHANNEL_BASE_CLASSES)
|
|
90
|
+
).map(&:to_s)
|
|
91
|
+
@channel_index = nil
|
|
92
|
+
@load_error = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
96
|
+
index = channel_index_or_nil
|
|
97
|
+
return [load_error_diagnostic(path)] if index.nil? && @load_error
|
|
98
|
+
return [] if index.nil? || index.empty?
|
|
99
|
+
|
|
100
|
+
Analyzer.diagnose(path: path, root: root, channel_index: index).map { |diag| build_diagnostic(diag) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def channel_index_or_nil
|
|
106
|
+
return @channel_index if @channel_index
|
|
107
|
+
|
|
108
|
+
# Pass an explicit descriptor covering every `.rb` file
|
|
109
|
+
# under the configured channel search paths so the cache
|
|
110
|
+
# invalidates when channels are added, removed, or edited.
|
|
111
|
+
# Without it the auto-built descriptor depends on the
|
|
112
|
+
# `IoBoundary`'s in-process read history — empty on the
|
|
113
|
+
# first call of a fresh process, so warm cache hits would
|
|
114
|
+
# serve stale `ChannelIndex` data when project files have
|
|
115
|
+
# changed between sessions.
|
|
116
|
+
descriptor = glob_descriptor(@channel_search_paths, "**/*.rb")
|
|
117
|
+
@channel_index = cache_for(:channel_index, params: {}, descriptor: descriptor).call
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
@load_error = "rigor-actioncable: failed to discover channels: #{e.class}: #{e.message}"
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def load_error_diagnostic(path)
|
|
124
|
+
Rigor::Analysis::Diagnostic.new(
|
|
125
|
+
path: path, line: 1, column: 1,
|
|
126
|
+
message: @load_error,
|
|
127
|
+
severity: :warning,
|
|
128
|
+
rule: "load-error"
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_diagnostic(diag)
|
|
133
|
+
Rigor::Analysis::Diagnostic.new(
|
|
134
|
+
path: diag.path, line: diag.line, column: diag.column,
|
|
135
|
+
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
Rigor::Plugin.register(Actioncable)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,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
|