rigortype 0.1.9 → 0.1.11

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