rigortype 0.1.15 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -7,6 +7,7 @@ require_relative "../environment"
7
7
  require_relative "../scope"
8
8
  require_relative "../reflection"
9
9
  require_relative "../type"
10
+ require_relative "../source/literals"
10
11
  require_relative "../inference/def_return_typer"
11
12
  require_relative "../inference/scope_indexer"
12
13
  require_relative "../inference/rbs_type_translator"
@@ -893,9 +894,7 @@ module Rigor
893
894
  end
894
895
 
895
896
  def extract_symbol_arguments(call_node)
896
- (call_node.arguments&.arguments || []).filter_map do |arg|
897
- arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
898
- end
897
+ Source::Literals.symbol_arguments(call_node)
899
898
  end
900
899
 
901
900
  # Returns a closure that looks up `:@<attr_name>` in the
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../type"
8
+ require_relative "../source/literals"
8
9
  require_relative "../inference/scope_indexer"
9
10
 
10
11
  module Rigor
@@ -307,9 +308,8 @@ module Rigor
307
308
  def binding_name_for(call_node)
308
309
  first_arg = call_node.arguments&.arguments&.first
309
310
  return call_node.name == :subject ? :subject : nil if first_arg.nil?
310
- return first_arg.unescaped.to_sym if first_arg.is_a?(Prism::SymbolNode) || first_arg.is_a?(Prism::StringNode)
311
311
 
312
- nil
312
+ Source::Literals.symbol_or_string(first_arg)
313
313
  end
314
314
 
315
315
  def type_block_body(block_node, scope_index)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Source
7
+ # Extracts literal Symbol/String values from Prism call arguments.
8
+ #
9
+ # The "is this argument a literal `:sym` or `"str"`, and if so what
10
+ # Symbol does it name?" question recurs across the analyzer (sig-gen
11
+ # observation, attr-accessor generation, synthetic-method scanning)
12
+ # and across nearly every DSL plugin (`state :draft`,
13
+ # `has_one_attached :avatar`, `validate_presence_of(:name)`, …). This
14
+ # module is the one place that answers it, so the
15
+ # `node.unescaped.to_sym if SymbolNode || StringNode` shape is written
16
+ # once rather than copied per call site.
17
+ #
18
+ # `#unescaped` (not `#value`) is used deliberately so an interpolation-
19
+ # free `"foo"` / `:foo` round-trips to `:foo` consistently for both
20
+ # node kinds.
21
+ #
22
+ # The surface is a small grid over two axes — which node kinds are
23
+ # accepted (`SymbolNode` only, or `SymbolNode`/`StringNode`) and what
24
+ # the caller wants back (the interned `Symbol`, or the raw `String`
25
+ # name). The SymbolNode-only forms ({.symbol} / {.symbol_name}) exist
26
+ # so a DSL that distinguishes `state :draft` from `state "draft"`
27
+ # keeps that distinction instead of silently widening to accept the
28
+ # string literal.
29
+ #
30
+ # | accepts | → Symbol | → String |
31
+ # | ------------------ | ------------------- | ------------------------ |
32
+ # | `:sym` only | {.symbol} | {.symbol_name} |
33
+ # | `:sym` or `"str"` | {.symbol_or_string} | {.symbol_or_string_name} |
34
+ module Literals
35
+ module_function
36
+
37
+ # The Symbol a literal `Prism::SymbolNode` / `Prism::StringNode`
38
+ # names, or `nil` for any other node (including `nil`).
39
+ #
40
+ # @param node [Prism::Node, nil]
41
+ # @return [Symbol, nil]
42
+ def symbol_or_string(node)
43
+ return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
44
+
45
+ node.unescaped.to_sym
46
+ end
47
+
48
+ # The String a literal `Prism::SymbolNode` / `Prism::StringNode`
49
+ # names, or `nil` for any other node (including `nil`). The
50
+ # String-returning sibling of {.symbol_or_string} — for callers
51
+ # that key on the raw name rather than the interned Symbol (route
52
+ # helpers, factory names, filter targets). `#unescaped` round-trips
53
+ # an interpolation-free `:foo` / `"foo"` to `"foo"` for both kinds.
54
+ #
55
+ # @param node [Prism::Node, nil]
56
+ # @return [String, nil]
57
+ def symbol_or_string_name(node)
58
+ return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
59
+
60
+ node.unescaped
61
+ end
62
+
63
+ # The Symbol a literal `Prism::SymbolNode` names, or `nil` for any
64
+ # other node (including a `Prism::StringNode` and `nil`). Stricter
65
+ # than {.symbol_or_string}: a DSL that accepts only `:draft` and
66
+ # not `"draft"` keeps that distinction by reaching for this rather
67
+ # than the Symbol-or-String form.
68
+ #
69
+ # @param node [Prism::Node, nil]
70
+ # @return [Symbol, nil]
71
+ def symbol(node)
72
+ return nil unless node.is_a?(Prism::SymbolNode)
73
+
74
+ node.unescaped.to_sym
75
+ end
76
+
77
+ # The String a literal `Prism::SymbolNode` names, or `nil` for any
78
+ # other node (including a `Prism::StringNode` and `nil`). The
79
+ # String-returning sibling of {.symbol} — SymbolNode-only, but the
80
+ # caller wants the raw name rather than the interned Symbol.
81
+ #
82
+ # @param node [Prism::Node, nil]
83
+ # @return [String, nil]
84
+ def symbol_name(node)
85
+ return nil unless node.is_a?(Prism::SymbolNode)
86
+
87
+ node.unescaped
88
+ end
89
+
90
+ # Every literal Symbol/String positional argument of a call, in
91
+ # source order. Non-literal arguments are dropped. Returns `[]` when
92
+ # the call has no argument list.
93
+ #
94
+ # @param call_node [Prism::CallNode, nil]
95
+ # @return [Array<Symbol>]
96
+ def symbol_arguments(call_node)
97
+ args = call_node&.arguments&.arguments
98
+ return [] if args.nil?
99
+
100
+ args.filter_map { |arg| symbol_or_string(arg) }
101
+ end
102
+
103
+ # The literal Symbol/String at positional `index`, or `nil` when the
104
+ # call has no argument list, the index is out of range, or the
105
+ # argument there is not a literal Symbol/String.
106
+ #
107
+ # @param call_node [Prism::CallNode, nil]
108
+ # @param index [Integer]
109
+ # @return [Symbol, nil]
110
+ def symbol_arg(call_node, index)
111
+ args = call_node&.arguments&.arguments
112
+ return nil if args.nil?
113
+
114
+ symbol_or_string(args[index])
115
+ end
116
+ end
117
+ end
118
+ end
@@ -32,6 +32,32 @@ module Rigor
32
32
  yield node
33
33
  node.compact_child_nodes.each { |child| walk(child, &) }
34
34
  end
35
+
36
+ # Like {.each}, but also yields the node's lexical ancestor chain
37
+ # (outermost first, EXCLUDING the node itself). The yielded
38
+ # `ancestors` array is the live descent stack — callers that retain
39
+ # it past the block invocation MUST copy it (`Plugin::NodeContext`
40
+ # does). Used by the plugin engine to give `node_rule` blocks their
41
+ # enclosing class / method / block context (ADR-37 slice 1d).
42
+ #
43
+ # @yieldparam node [Prism::Node]
44
+ # @yieldparam ancestors [Array<Prism::Node>]
45
+ # @return [Enumerator] when no block is given.
46
+ def each_with_ancestors(root, &)
47
+ return to_enum(__method__, root) unless block_given?
48
+
49
+ walk_with_ancestors(root, [], &)
50
+ nil
51
+ end
52
+
53
+ def walk_with_ancestors(node, ancestors, &block)
54
+ return unless node.is_a?(Prism::Node)
55
+
56
+ block.call(node, ancestors)
57
+ ancestors.push(node)
58
+ node.compact_child_nodes.each { |child| walk_with_ancestors(child, ancestors, &block) }
59
+ ancestors.pop
60
+ end
35
61
  end
36
62
  end
37
63
  end
data/lib/rigor/source.rb CHANGED
@@ -13,3 +13,4 @@ end
13
13
 
14
14
  require_relative "source/node_locator"
15
15
  require_relative "source/node_walker"
16
+ require_relative "source/literals"
@@ -14,6 +14,7 @@ require_relative "difference"
14
14
  require_relative "refined"
15
15
  require_relative "intersection"
16
16
  require_relative "bound_method"
17
+ require_relative "../inference/budget_trace"
17
18
 
18
19
  module Rigor
19
20
  module Type
@@ -360,7 +361,11 @@ module Rigor
360
361
  # Normalized union. Flattens nested Unions, deduplicates structurally
361
362
  # equal members, drops Bot, and collapses 0/1-member results.
362
363
  def union(*types)
363
- collapse_union(normalized_union_members(types))
364
+ result = collapse_union(normalized_union_members(types))
365
+ if Inference::BudgetTrace.enabled? && result.is_a?(Union)
366
+ Inference::BudgetTrace.observe(Inference::BudgetTrace::UNION_ARITY, result.members.size)
367
+ end
368
+ result
364
369
  end
365
370
 
366
371
  # `key_of[T]` type function — projects the type-level
@@ -24,8 +24,31 @@ module Rigor
24
24
  freeze
25
25
  end
26
26
 
27
+ # Display-only adoption of two concise RBS spellings for the
28
+ # union (see docs/type-specification/normalization.md § "Interaction
29
+ # with display" and rbs-compatible-types.md § "Optionals"). Both are
30
+ # purely cosmetic: `@members` keeps every carrier verbatim, so the
31
+ # underlying type identity, RBS erasure, and round-trip are unchanged
32
+ # — only the human-facing rendering reads like the RBS the user wrote.
33
+ #
34
+ # * `true | false` → `bool` (the RBS boolean alias). The
35
+ # `bool` token leads the rendering, so `false | Foo | true` reads
36
+ # as `bool | Foo` rather than burying the pair mid-list.
37
+ # * `T | nil` → `T?` (the RBS optional sugar). Only
38
+ # applied when exactly one *logical* member remains beside `nil`,
39
+ # matching the rbs gem's own `to_s`: a multi-member union such as
40
+ # `Integer | String | nil` stays explicit rather than gaining a
41
+ # parenthesised `(Integer | String)?`. The two collapses compose,
42
+ # so `false | true | nil` reads as `bool?`.
27
43
  def describe(verbosity = :short)
28
- members.map { |m| m.describe(verbosity) }.join(" | ")
44
+ return "#{optional_inner(verbosity)}?" if optional?
45
+
46
+ if boolean_pair?
47
+ rest = members.reject { |m| boolean_literal?(m) }
48
+ ["bool", *rest.map { |m| m.describe(verbosity) }].join(" | ")
49
+ else
50
+ members.map { |m| m.describe(verbosity) }.join(" | ")
51
+ end
29
52
  end
30
53
 
31
54
  # ADR-1 § "RBS round-trip is lossless" + the value-lattice
@@ -79,6 +102,47 @@ module Rigor
79
102
  def inspect
80
103
  "#<Rigor::Type::Union #{describe(:short)}>"
81
104
  end
105
+
106
+ private
107
+
108
+ # Both `true` and `false` literals are present, so the pair can
109
+ # render as `bool`. A union carrying only one of them stays a
110
+ # plain literal (`true` / `false`) — that asymmetry is meaningful.
111
+ def boolean_pair?
112
+ members.any? { |m| boolean_literal?(m, true) } &&
113
+ members.any? { |m| boolean_literal?(m, false) }
114
+ end
115
+
116
+ def boolean_literal?(member, which = :either)
117
+ return false unless member.is_a?(Constant)
118
+
119
+ case which
120
+ when :either then member.value.equal?(true) || member.value.equal?(false)
121
+ else member.value.equal?(which)
122
+ end
123
+ end
124
+
125
+ def nil_literal?(member)
126
+ member.is_a?(Constant) && member.value.nil?
127
+ end
128
+
129
+ # `nil` is present and, once the `bool` pair is treated as a
130
+ # single logical member, exactly one non-`nil` member remains —
131
+ # so the whole union renders as `T?`. Counting the bool pair as
132
+ # one is what lets `false | true | nil` reach `bool?`.
133
+ def optional?
134
+ return false unless members.any? { |m| nil_literal?(m) }
135
+
136
+ significant = members.reject { |m| nil_literal?(m) }
137
+ logical = significant.size - (boolean_pair? ? 1 : 0)
138
+ logical == 1
139
+ end
140
+
141
+ def optional_inner(verbosity)
142
+ return "bool" if boolean_pair?
143
+
144
+ members.find { |m| !nil_literal?(m) }.describe(verbosity)
145
+ end
82
146
  end
83
147
  end
84
148
  end
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.15"
4
+ VERSION = "0.1.16"
5
5
  end
data/lib/rigor.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "rigor/ast"
9
9
  require_relative "rigor/environment"
10
10
  require_relative "rigor/rbs_extended"
11
11
  require_relative "rigor/testing"
12
+ require_relative "rigor/inference/budget_trace"
12
13
  require_relative "rigor/inference/fallback"
13
14
  require_relative "rigor/inference/fallback_tracer"
14
15
  require_relative "rigor/inference/acceptance"
@@ -33,35 +33,31 @@ module Rigor
33
33
  ::ActionCable.server
34
34
  ].freeze
35
35
 
36
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
36
+ # One broadcast observation. Carries no path/location the
37
+ # caller (the `node_rule` block) positions it via
38
+ # `Plugin::Base#diagnostic`.
39
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
37
40
 
38
41
  module_function
39
42
 
40
- # @param path [String]
41
- # @param root [Prism::Node]
43
+ # The broadcast violations for a single call node, or `[]` when
44
+ # the node is not a `broadcast_to` / `ActionCable.server.broadcast`
45
+ # call this plugin recognises. ADR-37: the engine owns the walk.
46
+ #
47
+ # @param call_node [Prism::Node]
42
48
  # @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
49
+ # @return [Array<Violation>]
50
+ def violations_for(call_node:, channel_index:)
51
+ return [] unless call_node.is_a?(Prism::CallNode)
52
+
53
+ case call_node.name
54
+ when :broadcast_to then analyse_broadcast_to(call_node, channel_index)
55
+ when :broadcast then analyse_server_broadcast(call_node, channel_index)
56
+ else []
53
57
  end
54
- diagnostics
55
58
  end
56
59
 
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)
60
+ def analyse_broadcast_to(call_node, channel_index)
65
61
  class_name = constant_receiver_name(call_node.receiver)
66
62
  return [] if class_name.nil?
67
63
 
@@ -72,12 +68,12 @@ module Rigor
72
68
  return [] unless class_name.end_with?("Channel")
73
69
 
74
70
  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?
71
+ return [unknown_channel_violation(class_name, channel_index)] if entry.nil?
76
72
 
77
- [broadcast_target_info(path, call_node, entry)]
73
+ [broadcast_target_info(entry)]
78
74
  end
79
75
 
80
- def analyse_server_broadcast(path, call_node, channel_index)
76
+ def analyse_server_broadcast(call_node, channel_index)
81
77
  receiver_path = call_chain_string(call_node.receiver)
82
78
  return [] unless SERVER_BROADCAST_RECEIVER_NAMES.include?(receiver_path)
83
79
 
@@ -88,60 +84,42 @@ module Rigor
88
84
  return [] if channel_index.any_dynamic_streams?
89
85
 
90
86
  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
87
+ return [server_broadcast_info(stream_name)] if channel_index.all_stream_names.include?(stream_name)
94
88
 
95
- [unknown_stream_diagnostic(path, call_node, stream_name, channel_index)]
89
+ [unknown_stream_violation(stream_name, channel_index)]
96
90
  end
97
91
 
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,
92
+ def broadcast_target_info(entry)
93
+ Violation.new(
104
94
  severity: :info,
105
95
  rule: "broadcast-target",
106
96
  message: "`#{entry.class_name}.broadcast_to(...)` matches discovered channel"
107
97
  )
108
98
  end
109
99
 
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,
100
+ def server_broadcast_info(stream_name)
101
+ Violation.new(
116
102
  severity: :info,
117
103
  rule: "broadcast-stream",
118
104
  message: "`broadcast(\"#{stream_name}\", ...)` matches a registered `stream_from`"
119
105
  )
120
106
  end
121
107
 
122
- def unknown_channel_diagnostic(path, call_node, class_name, channel_index)
123
- location = call_node.location
108
+ def unknown_channel_violation(class_name, channel_index)
124
109
  suggestions = DidYouMean::SpellChecker.new(dictionary: channel_index.names).correct(class_name)
125
110
  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,
111
+ Violation.new(
130
112
  severity: :error,
131
113
  rule: "unknown-channel",
132
114
  message: "no ActionCable channel `#{class_name}`#{suggestion_part}"
133
115
  )
134
116
  end
135
117
 
136
- def unknown_stream_diagnostic(path, call_node, stream_name, channel_index)
137
- location = call_node.location
118
+ def unknown_stream_violation(stream_name, channel_index)
138
119
  dictionary = channel_index.all_stream_names.to_a
139
120
  suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(stream_name)
140
121
  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,
122
+ Violation.new(
145
123
  severity: :warning,
146
124
  rule: "unknown-stream",
147
125
  message: "no `stream_from \"#{stream_name}\"` registration in any discovered " \
@@ -62,17 +62,14 @@ module Rigor
62
62
  version: "0.1.0",
63
63
  description: "Validates ActionCable broadcast call shape against discovered channels.",
64
64
  config_schema: {
65
- "channel_search_paths" => :array,
66
- "channel_base_classes" => :array
65
+ "channel_search_paths" => { kind: :array, default: ["app/channels"] },
66
+ "channel_base_classes" => {
67
+ kind: :array,
68
+ default: ["ApplicationCable::Channel", "ActionCable::Channel::Base"]
69
+ }
67
70
  }
68
71
  )
69
72
 
70
- DEFAULT_CHANNEL_SEARCH_PATHS = ["app/channels"].freeze
71
- DEFAULT_CHANNEL_BASE_CLASSES = [
72
- "ApplicationCable::Channel",
73
- "ActionCable::Channel::Base"
74
- ].freeze
75
-
76
73
  producer :channel_index do |_params|
77
74
  ChannelDiscoverer.new(
78
75
  io_boundary: io_boundary,
@@ -82,22 +79,30 @@ module Rigor
82
79
  end
83
80
 
84
81
  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)
82
+ @channel_search_paths = Array(config.fetch("channel_search_paths")).map(&:to_s)
83
+ @channel_base_classes = Array(config.fetch("channel_base_classes")).map(&:to_s)
91
84
  @channel_index = nil
92
85
  @load_error = nil
93
86
  end
94
87
 
88
+ # File-level only: the load-error emission. Per-call broadcast
89
+ # validation runs over the engine-owned walk via the node_rule
90
+ # below (ADR-37). The channel index is lazily loaded + memoised by
91
+ # channel_index_or_nil, shared by both surfaces.
95
92
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
96
93
  index = channel_index_or_nil
97
94
  return [load_error_diagnostic(path)] if index.nil? && @load_error
98
- return [] if index.nil? || index.empty?
99
95
 
100
- Analyzer.diagnose(path: path, root: root, channel_index: index).map { |diag| build_diagnostic(diag) }
96
+ []
97
+ end
98
+
99
+ node_rule Prism::CallNode do |node, _scope, path|
100
+ index = channel_index_or_nil
101
+ next [] if index.nil? || index.empty?
102
+
103
+ Analyzer.violations_for(call_node: node, channel_index: index).map do |violation|
104
+ diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
105
+ end
101
106
  end
102
107
 
103
108
  private
@@ -128,13 +133,6 @@ module Rigor
128
133
  rule: "load-error"
129
134
  )
130
135
  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
136
  end
139
137
 
140
138
  Rigor::Plugin.register(Actioncable)
@@ -45,53 +45,44 @@ module Rigor
45
45
  method instance_method methods
46
46
  ].freeze
47
47
 
48
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
48
+ # One mailer-call observation. Carries no path/location the
49
+ # caller (the `node_rule` block) positions it via
50
+ # `Plugin::Base#diagnostic`.
51
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
49
52
 
50
53
  module_function
51
54
 
52
- # @param path [String]
53
- # @param root [Prism::Node]
55
+ # The mailer-call violations for a single call node (0..2), or
56
+ # `[]` when it is not a `<Mailer>.action(...)` call on a known
57
+ # mailer. ADR-37: the engine owns the walk.
58
+ #
59
+ # @param call_node [Prism::Node]
54
60
  # @param mailer_index [MailerIndex]
55
- # @return [Array<Diagnostic>]
56
- def diagnose(path:, root:, mailer_index:)
57
- diagnostics = []
58
- walk(root) do |call_node|
59
- class_name = mailer_class_for_call(call_node)
60
- next if class_name.nil?
61
- next if RESERVED_CLASS_METHODS.include?(call_node.name)
62
-
63
- class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
64
- next if class_entry.nil?
65
-
66
- action_entry = class_entry.find_action(call_node.name)
67
- if action_entry.nil?
68
- # Skip `unknown-action` when the mailer's include
69
- # set has any unresolved module — the unresolved
70
- # module may legitimately define the action
71
- # (gem-shipped concern, dynamically loaded
72
- # mailer extension). Mirrors the same predicate
73
- # `rigor-actionpack` uses for unknown-filter-method.
74
- next if class_entry.unresolved_includes?
75
-
76
- diagnostics << unknown_action_diagnostic(path, call_node, class_entry)
77
- next
78
- end
79
-
80
- diagnostics << action_call_info(path, call_node, class_entry, action_entry)
81
- arity_diag = arity_check(path, call_node, class_entry, action_entry)
82
- diagnostics << arity_diag if arity_diag
83
- end
84
- diagnostics
85
- end
61
+ # @return [Array<Violation>]
62
+ def violations_for(call_node:, mailer_index:)
63
+ return [] unless call_node.is_a?(Prism::CallNode) && action_call_candidate?(call_node)
64
+
65
+ class_name = mailer_class_for_call(call_node)
66
+ return [] if class_name.nil?
67
+ return [] if RESERVED_CLASS_METHODS.include?(call_node.name)
86
68
 
87
- # Walks the tree yielding every CallNode whose receiver
88
- # resolves (directly or through `.with(...)`) to a
89
- # constant.
90
- def walk(node, &)
91
- return unless node.is_a?(Prism::Node)
69
+ class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
70
+ return [] if class_entry.nil?
71
+
72
+ action_entry = class_entry.find_action(call_node.name)
73
+ if action_entry.nil?
74
+ # Skip `unknown-action` when the mailer's include set has any
75
+ # unresolved module — it may legitimately define the action
76
+ # (gem-shipped concern, dynamically loaded extension).
77
+ return [] if class_entry.unresolved_includes?
78
+
79
+ return [unknown_action_violation(call_node, class_entry)]
80
+ end
92
81
 
93
- yield node if node.is_a?(Prism::CallNode) && action_call_candidate?(node)
94
- node.compact_child_nodes.each { |child| walk(child, &) }
82
+ violations = [action_call_info(call_node, class_entry, action_entry)]
83
+ arity = arity_violation(call_node, class_entry, action_entry)
84
+ violations << arity if arity
85
+ violations
95
86
  end
96
87
 
97
88
  def action_call_candidate?(node)
@@ -120,12 +111,8 @@ module Rigor
120
111
  end
121
112
  end
122
113
 
123
- def action_call_info(path, call_node, class_entry, action_entry)
124
- location = call_node.location
125
- Diagnostic.new(
126
- path: path,
127
- line: location.start_line,
128
- column: location.start_column + 1,
114
+ def action_call_info(_call_node, class_entry, action_entry)
115
+ Violation.new(
129
116
  severity: :info,
130
117
  rule: "mailer-call",
131
118
  message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
@@ -133,7 +120,7 @@ module Rigor
133
120
  )
134
121
  end
135
122
 
136
- def arity_check(path, call_node, class_entry, action_entry)
123
+ def arity_violation(call_node, class_entry, action_entry)
137
124
  args = call_node.arguments&.arguments || []
138
125
  actual = args.size
139
126
  return nil if action_entry.accepts?(actual)
@@ -147,11 +134,7 @@ module Rigor
147
134
  # carrying calls don't surface as wrong-arity.
148
135
  return nil if args.last.is_a?(Prism::KeywordHashNode) && action_entry.accepts?(actual - 1)
149
136
 
150
- location = call_node.location
151
- Diagnostic.new(
152
- path: path,
153
- line: location.start_line,
154
- column: location.start_column + 1,
137
+ Violation.new(
155
138
  severity: :error,
156
139
  rule: "wrong-arity",
157
140
  message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
@@ -159,14 +142,10 @@ module Rigor
159
142
  )
160
143
  end
161
144
 
162
- def unknown_action_diagnostic(path, call_node, class_entry)
163
- location = call_node.location
145
+ def unknown_action_violation(call_node, class_entry)
164
146
  known = class_entry.actions.keys.sort.join(", ")
165
147
  known_part = known.empty? ? "no actions defined" : "known actions: #{known}"
166
- Diagnostic.new(
167
- path: path,
168
- line: location.start_line,
169
- column: location.start_column + 1,
148
+ Violation.new(
170
149
  severity: :error,
171
150
  rule: "unknown-action",
172
151
  message: "`#{class_entry.class_name}.#{call_node.name}` is not a defined " \