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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- 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 +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -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 +283 -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 +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -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 +37 -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 +478 -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 +353 -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 +175 -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 +350 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -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
- data/sig/rigor/scope.rbs +22 -0
- metadata +157 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
class Actioncable < Rigor::Plugin::Base
|
|
9
|
+
# Walks a parsed file's AST looking for ActionCable
|
|
10
|
+
# entry-point calls and validates each against the
|
|
11
|
+
# {ChannelIndex}.
|
|
12
|
+
#
|
|
13
|
+
# Recognised shapes:
|
|
14
|
+
#
|
|
15
|
+
# - `<ChannelClass>.broadcast_to(record, data)` —
|
|
16
|
+
# class-targeted broadcast. The class must exist in
|
|
17
|
+
# the index.
|
|
18
|
+
# - `ActionCable.server.broadcast(stream_name, data)`
|
|
19
|
+
# — string-targeted broadcast. When `stream_name`
|
|
20
|
+
# is a literal string and the index has at least
|
|
21
|
+
# one channel with no dynamic stream registrations,
|
|
22
|
+
# we check that the name appears in
|
|
23
|
+
# `index.all_stream_names`. Otherwise the
|
|
24
|
+
# `unknown-stream` warning is suppressed (we can't
|
|
25
|
+
# prove absence).
|
|
26
|
+
module Analyzer
|
|
27
|
+
# `ActionCable.server.broadcast(...)` — the receiver
|
|
28
|
+
# path we recognise as a server-targeted broadcast.
|
|
29
|
+
# Single-symbol form (just `broadcast`) is too
|
|
30
|
+
# ambiguous to validate.
|
|
31
|
+
SERVER_BROADCAST_RECEIVER_NAMES = %w[
|
|
32
|
+
ActionCable.server
|
|
33
|
+
::ActionCable.server
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# @param path [String]
|
|
41
|
+
# @param root [Prism::Node]
|
|
42
|
+
# @param channel_index [ChannelIndex]
|
|
43
|
+
# @return [Array<Diagnostic>]
|
|
44
|
+
def diagnose(path:, root:, channel_index:)
|
|
45
|
+
diagnostics = []
|
|
46
|
+
walk(root) do |call_node|
|
|
47
|
+
case call_node.name
|
|
48
|
+
when :broadcast_to
|
|
49
|
+
diagnostics.concat(analyse_broadcast_to(path, call_node, channel_index))
|
|
50
|
+
when :broadcast
|
|
51
|
+
diagnostics.concat(analyse_server_broadcast(path, call_node, channel_index))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
diagnostics
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def walk(node, &)
|
|
58
|
+
return unless node.is_a?(Prism::Node)
|
|
59
|
+
|
|
60
|
+
yield node if node.is_a?(Prism::CallNode)
|
|
61
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def analyse_broadcast_to(path, call_node, channel_index)
|
|
65
|
+
class_name = constant_receiver_name(call_node.receiver)
|
|
66
|
+
return [] if class_name.nil?
|
|
67
|
+
|
|
68
|
+
# broadcast_to with a class-name receiver that
|
|
69
|
+
# doesn't end in "Channel" is almost certainly
|
|
70
|
+
# not ActionCable — pass through silently to
|
|
71
|
+
# avoid flagging unrelated `broadcast_to` methods.
|
|
72
|
+
return [] unless class_name.end_with?("Channel")
|
|
73
|
+
|
|
74
|
+
entry = channel_index.find(class_name) || channel_index.find("::#{class_name}")
|
|
75
|
+
return [unknown_channel_diagnostic(path, call_node, class_name, channel_index)] if entry.nil?
|
|
76
|
+
|
|
77
|
+
[broadcast_target_info(path, call_node, entry)]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def analyse_server_broadcast(path, call_node, channel_index)
|
|
81
|
+
receiver_path = call_chain_string(call_node.receiver)
|
|
82
|
+
return [] unless SERVER_BROADCAST_RECEIVER_NAMES.include?(receiver_path)
|
|
83
|
+
|
|
84
|
+
args = call_node.arguments&.arguments || []
|
|
85
|
+
stream_arg = args.first
|
|
86
|
+
return [] if stream_arg.nil?
|
|
87
|
+
return [] unless stream_arg.is_a?(Prism::StringNode)
|
|
88
|
+
return [] if channel_index.any_dynamic_streams?
|
|
89
|
+
|
|
90
|
+
stream_name = stream_arg.unescaped
|
|
91
|
+
if channel_index.all_stream_names.include?(stream_name)
|
|
92
|
+
return [server_broadcast_info(path, call_node, stream_name)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
[unknown_stream_diagnostic(path, call_node, stream_name, channel_index)]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def broadcast_target_info(path, call_node, entry)
|
|
99
|
+
location = call_node.location
|
|
100
|
+
Diagnostic.new(
|
|
101
|
+
path: path,
|
|
102
|
+
line: location.start_line,
|
|
103
|
+
column: location.start_column + 1,
|
|
104
|
+
severity: :info,
|
|
105
|
+
rule: "broadcast-target",
|
|
106
|
+
message: "`#{entry.class_name}.broadcast_to(...)` matches discovered channel"
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def server_broadcast_info(path, call_node, stream_name)
|
|
111
|
+
location = call_node.location
|
|
112
|
+
Diagnostic.new(
|
|
113
|
+
path: path,
|
|
114
|
+
line: location.start_line,
|
|
115
|
+
column: location.start_column + 1,
|
|
116
|
+
severity: :info,
|
|
117
|
+
rule: "broadcast-stream",
|
|
118
|
+
message: "`broadcast(\"#{stream_name}\", ...)` matches a registered `stream_from`"
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unknown_channel_diagnostic(path, call_node, class_name, channel_index)
|
|
123
|
+
location = call_node.location
|
|
124
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: channel_index.names).correct(class_name)
|
|
125
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
|
|
126
|
+
Diagnostic.new(
|
|
127
|
+
path: path,
|
|
128
|
+
line: location.start_line,
|
|
129
|
+
column: location.start_column + 1,
|
|
130
|
+
severity: :error,
|
|
131
|
+
rule: "unknown-channel",
|
|
132
|
+
message: "no ActionCable channel `#{class_name}`#{suggestion_part}"
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def unknown_stream_diagnostic(path, call_node, stream_name, channel_index)
|
|
137
|
+
location = call_node.location
|
|
138
|
+
dictionary = channel_index.all_stream_names.to_a
|
|
139
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(stream_name)
|
|
140
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `\"#{suggestions.first}\"`?)"
|
|
141
|
+
Diagnostic.new(
|
|
142
|
+
path: path,
|
|
143
|
+
line: location.start_line,
|
|
144
|
+
column: location.start_column + 1,
|
|
145
|
+
severity: :warning,
|
|
146
|
+
rule: "unknown-stream",
|
|
147
|
+
message: "no `stream_from \"#{stream_name}\"` registration in any discovered " \
|
|
148
|
+
"channel#{suggestion_part}"
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Renders an `A.b.c` chain as a string (used to
|
|
153
|
+
# detect `ActionCable.server`). Returns nil for
|
|
154
|
+
# non-chained nodes.
|
|
155
|
+
def call_chain_string(node)
|
|
156
|
+
parts = []
|
|
157
|
+
current = node
|
|
158
|
+
while current.is_a?(Prism::CallNode) && current.arguments.nil?
|
|
159
|
+
parts.unshift(current.name.to_s)
|
|
160
|
+
current = current.receiver
|
|
161
|
+
end
|
|
162
|
+
base = constant_receiver_name(current)
|
|
163
|
+
return nil if base.nil? || parts.empty?
|
|
164
|
+
|
|
165
|
+
[base, *parts].join(".")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def constant_receiver_name(node)
|
|
169
|
+
case node
|
|
170
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
171
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def constant_path_name(node)
|
|
176
|
+
parts = []
|
|
177
|
+
current = node
|
|
178
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
179
|
+
parts.unshift(current.name.to_s)
|
|
180
|
+
current = current.parent
|
|
181
|
+
end
|
|
182
|
+
case current
|
|
183
|
+
when nil then "::#{parts.join('::')}"
|
|
184
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "channel_index"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Actioncable < Rigor::Plugin::Base
|
|
10
|
+
# Walks the configured channel-search paths via the
|
|
11
|
+
# plugin's `IoBoundary`, parses each `.rb` file with
|
|
12
|
+
# Prism, and collects classes whose immediate
|
|
13
|
+
# superclass is one of the configured base classes.
|
|
14
|
+
#
|
|
15
|
+
# For each discovered channel, the discoverer:
|
|
16
|
+
#
|
|
17
|
+
# - Records every public instance-side `def` whose
|
|
18
|
+
# name isn't an ActionCable framework hook
|
|
19
|
+
# (`subscribed`, `unsubscribed`, `_`-prefixed).
|
|
20
|
+
# These are the action methods clients can invoke
|
|
21
|
+
# via `subscription.perform("action_name", data)`.
|
|
22
|
+
# - Records every literal-string `stream_from "name"`
|
|
23
|
+
# call as a registered stream name.
|
|
24
|
+
# - Sets `dynamic_streams: true` when the channel has
|
|
25
|
+
# ANY non-literal `stream_from` argument (or a
|
|
26
|
+
# `stream_for` call) so the analyzer knows it can't
|
|
27
|
+
# be sure of every stream name.
|
|
28
|
+
#
|
|
29
|
+
# Limitations (intentional for v0.1.0):
|
|
30
|
+
#
|
|
31
|
+
# - Direct-superclass match only.
|
|
32
|
+
# - Public-vs-private is not tracked; the framework
|
|
33
|
+
# hooks (`subscribed`/`unsubscribed`) are excluded
|
|
34
|
+
# by name. Methods marked `private` after a
|
|
35
|
+
# `private` keyword would still appear in the
|
|
36
|
+
# `action_methods` set.
|
|
37
|
+
# - `stream_for(record)` (model-scoped streams) is
|
|
38
|
+
# recognised as setting `dynamic_streams: true` but
|
|
39
|
+
# not introspected further.
|
|
40
|
+
class ChannelDiscoverer
|
|
41
|
+
FRAMEWORK_HOOKS = %i[subscribed unsubscribed].to_set.freeze
|
|
42
|
+
|
|
43
|
+
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
44
|
+
@io_boundary = io_boundary
|
|
45
|
+
@search_paths = search_paths
|
|
46
|
+
@base_classes = base_classes.to_set
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [ChannelIndex]
|
|
50
|
+
def discover
|
|
51
|
+
entries = []
|
|
52
|
+
ruby_files_under(@search_paths).each do |path|
|
|
53
|
+
contents = read_safely(path)
|
|
54
|
+
next if contents.nil?
|
|
55
|
+
|
|
56
|
+
tree = Prism.parse(contents).value
|
|
57
|
+
walk_for_channels(tree, []) do |class_name, body|
|
|
58
|
+
entries << build_entry(class_name, path, body)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
ChannelIndex.new(entries)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def read_safely(path)
|
|
67
|
+
@io_boundary.read_file(path)
|
|
68
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ruby_files_under(roots)
|
|
73
|
+
roots.flat_map do |root|
|
|
74
|
+
absolute = File.expand_path(root)
|
|
75
|
+
next [] unless File.directory?(absolute)
|
|
76
|
+
|
|
77
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def walk_for_channels(node, lexical_path, &)
|
|
82
|
+
return if node.nil?
|
|
83
|
+
|
|
84
|
+
case node
|
|
85
|
+
when Prism::ClassNode then visit_class(node, lexical_path, &)
|
|
86
|
+
when Prism::ModuleNode then visit_module(node, lexical_path, &)
|
|
87
|
+
else
|
|
88
|
+
node.compact_child_nodes.each { |child| walk_for_channels(child, lexical_path, &) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def visit_class(node, lexical_path, &)
|
|
93
|
+
class_local_name = constant_path_name(node.constant_path)
|
|
94
|
+
return if class_local_name.nil?
|
|
95
|
+
|
|
96
|
+
full_name = (lexical_path + [class_local_name]).join("::")
|
|
97
|
+
superclass = constant_path_name(node.superclass) if node.superclass
|
|
98
|
+
yield full_name, node.body if superclass && @base_classes.include?(superclass)
|
|
99
|
+
|
|
100
|
+
inner_path = lexical_path + [class_local_name]
|
|
101
|
+
walk_for_channels(node.body, inner_path, &) if node.body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def visit_module(node, lexical_path, &)
|
|
105
|
+
module_local_name = constant_path_name(node.constant_path)
|
|
106
|
+
return if module_local_name.nil?
|
|
107
|
+
|
|
108
|
+
inner_path = lexical_path + [module_local_name]
|
|
109
|
+
walk_for_channels(node.body, inner_path, &) if node.body
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_entry(class_name, path, body)
|
|
113
|
+
actions = []
|
|
114
|
+
(body&.compact_child_nodes || []).each do |node|
|
|
115
|
+
actions << node.name if node.is_a?(Prism::DefNode) && action_def?(node)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
stream_names, dynamic_streams = collect_stream_registrations(body)
|
|
119
|
+
|
|
120
|
+
ChannelIndex::Entry.new(
|
|
121
|
+
class_name: class_name,
|
|
122
|
+
file_path: path,
|
|
123
|
+
action_methods: actions.to_set.freeze,
|
|
124
|
+
stream_names: stream_names.to_set.freeze,
|
|
125
|
+
dynamic_streams: dynamic_streams
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Walks the channel body recursively (so
|
|
130
|
+
# `stream_from` / `stream_for` calls inside
|
|
131
|
+
# `subscribed` / helper methods are picked up).
|
|
132
|
+
# Returns `[Array<String>, bool]` — the literal
|
|
133
|
+
# stream names + whether any dynamic registration
|
|
134
|
+
# was seen.
|
|
135
|
+
def collect_stream_registrations(node, names: [], dynamic: false)
|
|
136
|
+
return [names, dynamic] if node.nil?
|
|
137
|
+
|
|
138
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
139
|
+
case node.name
|
|
140
|
+
when :stream_from
|
|
141
|
+
arg = node.arguments&.arguments&.first
|
|
142
|
+
if arg.is_a?(Prism::StringNode)
|
|
143
|
+
names << arg.unescaped
|
|
144
|
+
else
|
|
145
|
+
dynamic = true
|
|
146
|
+
end
|
|
147
|
+
when :stream_for
|
|
148
|
+
# Model-scoped stream — name is computed from
|
|
149
|
+
# the record at runtime; treat as dynamic.
|
|
150
|
+
dynamic = true
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
node.compact_child_nodes.each do |child|
|
|
155
|
+
names, dynamic = collect_stream_registrations(child, names: names, dynamic: dynamic)
|
|
156
|
+
end
|
|
157
|
+
[names, dynamic]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def action_def?(node)
|
|
161
|
+
return false if node.receiver.is_a?(Prism::SelfNode)
|
|
162
|
+
return false if FRAMEWORK_HOOKS.include?(node.name)
|
|
163
|
+
return false if node.name.to_s.start_with?("_")
|
|
164
|
+
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def constant_path_name(node)
|
|
169
|
+
return nil if node.nil?
|
|
170
|
+
|
|
171
|
+
case node
|
|
172
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
173
|
+
when Prism::ConstantPathNode
|
|
174
|
+
parts = []
|
|
175
|
+
current = node
|
|
176
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
177
|
+
parts.unshift(current.name.to_s)
|
|
178
|
+
current = current.parent
|
|
179
|
+
end
|
|
180
|
+
case current
|
|
181
|
+
when nil then "::#{parts.join('::')}"
|
|
182
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -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
|