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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class ShouldaMatchers < Rigor::Plugin::Base
8
+ # Walks every `RSpec.describe <ModelConst> do ... end` /
9
+ # `describe <ModelConst> do ... end` block and validates
10
+ # the shoulda-matchers calls inside its body (any depth
11
+ # of nested `describe` / `context`) against the
12
+ # `:model_index` published by `rigor-activerecord`.
13
+ #
14
+ # The "anchor" for cross-checking is the OUTERMOST
15
+ # describe block whose argument is a constant — that
16
+ # constant names the model being specced. Nested
17
+ # `describe ".some_method"` (String / Symbol args) does
18
+ # NOT change the anchor.
19
+ #
20
+ # ## Recognised matcher calls (v0.1.0)
21
+ #
22
+ # ### Column / db matchers
23
+ #
24
+ # validate_presence_of(:col)
25
+ # validate_uniqueness_of(:col)
26
+ # validate_length_of(:col)
27
+ # validate_numericality_of(:col)
28
+ # validate_acceptance_of(:col)
29
+ # validate_inclusion_of(:col)
30
+ # validate_exclusion_of(:col)
31
+ # validate_absence_of(:col)
32
+ # validate_format_of(:col)
33
+ # validate_confirmation_of(:col)
34
+ # allow_value(...).for(:col)
35
+ # have_db_column(:col)
36
+ # have_db_index(:col)
37
+ #
38
+ # All look up `:col` against the model's columns
39
+ # (`Entry#column?`). Unknown columns fire
40
+ # `shoulda-matchers.unknown-column`.
41
+ #
42
+ # ### Association matchers
43
+ #
44
+ # belong_to(:assoc) ← expects :singular
45
+ # have_one(:assoc) ← expects :singular
46
+ # have_many(:assoc) ← expects :collection
47
+ # have_and_belong_to_many(:assoc) ← expects :collection
48
+ #
49
+ # Unknown associations fire
50
+ # `shoulda-matchers.unknown-association`. Known
51
+ # associations with mismatched kind (`should belong_to(:posts)`
52
+ # where `:posts` is `has_many`) fire
53
+ # `shoulda-matchers.association-kind-mismatch`.
54
+ module Analyzer
55
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
56
+
57
+ # `(matcher_name) => (:column | :association_singular | :association_collection)`
58
+ # — the validation lane each matcher routes to.
59
+ MATCHER_TABLE = {
60
+ # Column matchers — validate the named column exists on the model.
61
+ validate_presence_of: :column,
62
+ validate_uniqueness_of: :column,
63
+ validate_length_of: :column,
64
+ validate_numericality_of: :column,
65
+ validate_acceptance_of: :column,
66
+ validate_inclusion_of: :column,
67
+ validate_exclusion_of: :column,
68
+ validate_absence_of: :column,
69
+ validate_format_of: :column,
70
+ validate_confirmation_of: :column,
71
+ have_db_column: :column,
72
+ have_db_index: :column,
73
+ # Association matchers — validate the association
74
+ # exists AND its kind matches the matcher.
75
+ belong_to: :association_singular,
76
+ have_one: :association_singular,
77
+ have_many: :association_collection,
78
+ have_and_belong_to_many: :association_collection
79
+ }.freeze
80
+
81
+ module_function
82
+
83
+ # @param path [String]
84
+ # @param root [Prism::Node]
85
+ # @param model_index [Object, nil] the `:model_index`
86
+ # fact value. When nil the analyzer falls silent.
87
+ # @return [Array<Diagnostic>]
88
+ def diagnose(path:, root:, model_index:)
89
+ return [] if model_index.nil?
90
+
91
+ diagnostics = []
92
+ walk_describe(root, anchor_model: nil) do |matcher_call, anchor|
93
+ entry = model_index.find(anchor)
94
+ next if entry.nil?
95
+
96
+ diagnostic = diagnostic_for(matcher_call, path, anchor, entry)
97
+ diagnostics << diagnostic if diagnostic
98
+ end
99
+ diagnostics
100
+ end
101
+
102
+ # Walks for `RSpec.describe(Const)` / `describe(Const)`
103
+ # blocks (the Const is the model anchor) and yields
104
+ # every matcher call found in their body.
105
+ #
106
+ # The anchor stays the OUTERMOST describe-with-const
107
+ # — nested describes / contexts inherit it without
108
+ # overriding (a nested `describe ".active"` is not a
109
+ # model constant). When a nested describe DOES name a
110
+ # different model, the nested anchor wins inside that
111
+ # subtree (rare; we still honour it).
112
+ def walk_describe(node, anchor_model:, &)
113
+ return unless node.is_a?(Prism::Node)
114
+
115
+ if describe_with_constant?(node)
116
+ inner_anchor = describe_const_name(node) || anchor_model
117
+ collect_matchers(node.block.body, inner_anchor, &) if node.block&.body
118
+ return
119
+ end
120
+
121
+ node.compact_child_nodes.each do |child|
122
+ walk_describe(child, anchor_model: anchor_model, &)
123
+ end
124
+ end
125
+
126
+ # Walks the body of a describe block looking for:
127
+ # (a) matcher calls — `should MATCHER` or
128
+ # `expect(...).to MATCHER` chains; we yield the
129
+ # inner MATCHER call.
130
+ # (b) nested describe / context blocks — we recurse
131
+ # so deeper matchers are reachable.
132
+ def collect_matchers(body, anchor, &)
133
+ return unless body.is_a?(Prism::Node)
134
+ return if anchor.nil?
135
+
136
+ if matcher_invocation?(body)
137
+ yield body, anchor
138
+ return
139
+ end
140
+
141
+ if describe_with_constant?(body)
142
+ inner_anchor = describe_const_name(body) || anchor
143
+ collect_matchers(body.block.body, inner_anchor, &) if body.block&.body
144
+ return
145
+ end
146
+
147
+ body.compact_child_nodes.each do |child|
148
+ collect_matchers(child, anchor, &)
149
+ end
150
+ end
151
+
152
+ # A direct matcher invocation is a `CallNode` whose
153
+ # `name` is in `MATCHER_TABLE` and whose first argument
154
+ # is a `SymbolNode`. The chain shape (`should`,
155
+ # `expect(...).to`, `is_expected.to`) is irrelevant —
156
+ # we always recurse to the inner matcher, so a
157
+ # diagnostic fires on the matcher regardless of the
158
+ # surrounding chain.
159
+ def matcher_invocation?(node)
160
+ node.is_a?(Prism::CallNode) && MATCHER_TABLE.key?(node.name) && symbol_first_arg?(node)
161
+ end
162
+
163
+ def symbol_first_arg?(call_node)
164
+ args = call_node.arguments&.arguments || []
165
+ !args.empty? && args.first.is_a?(Prism::SymbolNode)
166
+ end
167
+
168
+ # Detects `RSpec.describe(Const) do ... end` and
169
+ # `describe(Const) do ... end`. Either form opens a
170
+ # scope whose anchor is `Const`. The receiver shape
171
+ # (RSpec vs nil) is allowed in both cases.
172
+ def describe_with_constant?(node)
173
+ return false unless node.is_a?(Prism::CallNode)
174
+ return false unless node.name == :describe
175
+ return false unless node.block.is_a?(Prism::BlockNode)
176
+
177
+ args = node.arguments&.arguments || []
178
+ first = args.first
179
+ first.is_a?(Prism::ConstantReadNode) || first.is_a?(Prism::ConstantPathNode)
180
+ end
181
+
182
+ def describe_const_name(node)
183
+ arg = node.arguments.arguments.first
184
+ render_constant_path(arg)
185
+ end
186
+
187
+ def render_constant_path(node)
188
+ case node
189
+ when Prism::ConstantReadNode then node.name.to_s
190
+ when Prism::ConstantPathNode
191
+ parts = []
192
+ current = node
193
+ while current.is_a?(Prism::ConstantPathNode)
194
+ parts.unshift(current.name.to_s)
195
+ current = current.parent
196
+ end
197
+ case current
198
+ when nil then "::#{parts.join('::')}"
199
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
200
+ end
201
+ end
202
+ end
203
+
204
+ # --- diagnostics ---
205
+
206
+ def diagnostic_for(matcher_call, path, anchor, entry)
207
+ lane = MATCHER_TABLE.fetch(matcher_call.name)
208
+ target = matcher_call.arguments.arguments.first.unescaped.to_sym
209
+
210
+ case lane
211
+ when :column
212
+ column_diagnostic(matcher_call, path, anchor, entry, target)
213
+ when :association_singular
214
+ association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :singular)
215
+ when :association_collection
216
+ association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :collection)
217
+ end
218
+ end
219
+
220
+ def column_diagnostic(matcher_call, path, anchor, entry, column_name)
221
+ return nil if entry.column?(column_name)
222
+
223
+ build_diagnostic(
224
+ matcher_call, path,
225
+ rule: "shoulda-matchers.unknown-column",
226
+ message: "#{matcher_call.name}(:#{column_name}) — no column `#{column_name}` on " \
227
+ "#{anchor} (columns: #{entry.column_names.sort.join(', ')})"
228
+ )
229
+ end
230
+
231
+ def association_diagnostic(matcher_call, path, anchor, entry, assoc_name, expected_kind:)
232
+ if entry.association?(assoc_name)
233
+ actual = entry.association(assoc_name)[:kind]
234
+ return nil if actual == expected_kind
235
+
236
+ build_diagnostic(
237
+ matcher_call, path,
238
+ rule: "shoulda-matchers.association-kind-mismatch",
239
+ message: "#{matcher_call.name}(:#{assoc_name}) on #{anchor} — `#{assoc_name}` is " \
240
+ "a #{actual} association; #{matcher_call.name} expects #{expected_kind}"
241
+ )
242
+ else
243
+ build_diagnostic(
244
+ matcher_call, path,
245
+ rule: "shoulda-matchers.unknown-association",
246
+ message: "#{matcher_call.name}(:#{assoc_name}) — no association `#{assoc_name}` on " \
247
+ "#{anchor} (associations: #{entry.association_names.sort.join(', ')})"
248
+ )
249
+ end
250
+ end
251
+
252
+ def build_diagnostic(call_node, path, rule:, message:)
253
+ location = call_node.message_loc || call_node.location
254
+ Diagnostic.new(
255
+ path: path,
256
+ line: location.start_line,
257
+ column: location.start_column + 1,
258
+ severity: :warning,
259
+ rule: rule,
260
+ message: message
261
+ )
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "shoulda_matchers/analyzer"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ # rigor-shoulda-matchers — validates shoulda-matchers
10
+ # matchers against the `:model_index` cross-plugin fact
11
+ # (ADR-9) published by `rigor-activerecord`.
12
+ #
13
+ # The plugin walks every `RSpec.describe <ModelConst> do
14
+ # ... end` block and validates the matchers inside
15
+ # against the model's known columns / associations.
16
+ #
17
+ # ## Recognised matchers (v0.1.0)
18
+ #
19
+ # **Column matchers** (validate the named column exists on
20
+ # the model):
21
+ #
22
+ # validate_presence_of(:col), validate_uniqueness_of(:col),
23
+ # validate_length_of(:col), validate_numericality_of(:col),
24
+ # validate_acceptance_of(:col), validate_inclusion_of(:col),
25
+ # validate_exclusion_of(:col), validate_absence_of(:col),
26
+ # validate_format_of(:col), validate_confirmation_of(:col),
27
+ # have_db_column(:col), have_db_index(:col)
28
+ #
29
+ # **Association matchers** (validate the association exists
30
+ # AND its kind matches):
31
+ #
32
+ # belong_to(:assoc), have_one(:assoc) ← :singular
33
+ # have_many(:assoc), have_and_belong_to_many(:assoc) ← :collection
34
+ #
35
+ # ## Cross-plugin dependency
36
+ #
37
+ # The plugin consumes `:model_index` from `rigor-activerecord`.
38
+ # When `rigor-activerecord` is NOT loaded (or hasn't
39
+ # published an index for the analysed model), the plugin
40
+ # falls silent — the cross-check is opt-in. Adding
41
+ # `rigor-activerecord` to `.rigor.yml` unlocks the
42
+ # diagnostics.
43
+ #
44
+ # ## Limitations (v0.1.0)
45
+ #
46
+ # - **No chained-matcher arg validation.** The chain
47
+ # options on `validate_length_of(:col).is_at_most(50)`,
48
+ # `validate_inclusion_of(:col).in_array([...])`,
49
+ # `allow_value("foo").for(:col)`, etc. are NOT validated
50
+ # (the `.is_at_most` etc. terminals are runtime-only).
51
+ # - **No polymorphic / through validation.** `belong_to(:user).polymorphic`,
52
+ # `have_many(:posts, through: :memberships)` only check
53
+ # the named association; the chain modifiers are
54
+ # ignored.
55
+ # - **No nested-attribute matchers.** `accept_nested_attributes_for(:posts)`
56
+ # not yet covered.
57
+ # - **No callback matchers.** `callback(:before_save).before(:save)`
58
+ # would need a separate slice (overlaps with the
59
+ # model_index's `callbacks` column already exposed but
60
+ # no rspec-side recogniser yet).
61
+ class ShouldaMatchers < Rigor::Plugin::Base
62
+ manifest(
63
+ id: "shoulda-matchers",
64
+ version: "0.1.0",
65
+ description: "Validates shoulda-matchers matchers (validate_presence_of / belong_to / " \
66
+ "have_many / have_db_column / ...) against :model_index from " \
67
+ "rigor-activerecord.",
68
+ consumes: [
69
+ { plugin_id: "activerecord", name: :model_index, optional: true }
70
+ ]
71
+ )
72
+
73
+ def init(services)
74
+ @services = services
75
+ @model_index = nil
76
+ @model_index_resolved = false
77
+ end
78
+
79
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
80
+ index = model_index_or_nil
81
+ return [] if index.nil?
82
+
83
+ Analyzer.diagnose(
84
+ path: path, root: root, model_index: index
85
+ ).map { |diag| build_diagnostic(diag) }
86
+ end
87
+
88
+ private
89
+
90
+ # Lazily resolves `:model_index` from
91
+ # `rigor-activerecord`. Returns nil when the plugin
92
+ # isn't loaded or no index has been published; the
93
+ # analyzer treats nil as "no cross-check available" and
94
+ # falls silent.
95
+ def model_index_or_nil
96
+ return @model_index if @model_index_resolved
97
+
98
+ @model_index = @services.fact_store.read(plugin_id: "activerecord", name: :model_index)
99
+ @model_index_resolved = true
100
+ @model_index
101
+ end
102
+
103
+ def build_diagnostic(diag)
104
+ Rigor::Analysis::Diagnostic.new(
105
+ path: diag.path, line: diag.line, column: diag.column,
106
+ message: diag.message, severity: diag.severity, rule: diag.rule
107
+ )
108
+ end
109
+ end
110
+
111
+ Rigor::Plugin.register(ShouldaMatchers)
112
+ end
113
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/shoulda_matchers"
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Sidekiq < Rigor::Plugin::Base
8
+ # Walks a parsed file's AST looking for
9
+ # `<WorkerClass>.perform_async(...)` /
10
+ # `.perform_inline(...)` / `.perform_in(time, ...)` /
11
+ # `.perform_at(time, ...)` calls and validates each
12
+ # against the {WorkerIndex}.
13
+ #
14
+ # Argument-shape rules:
15
+ #
16
+ # - `perform_async` / `perform_inline` — every
17
+ # argument is forwarded to `#perform`. Validate
18
+ # `actual == #perform.arity`.
19
+ # - `perform_in(interval, ...args)` /
20
+ # `perform_at(time, ...args)` — the FIRST argument
21
+ # is the schedule (a Time / Integer / ActiveSupport
22
+ # duration); the rest are forwarded to `#perform`.
23
+ # Validate `actual_args - 1 == #perform.arity`.
24
+ module Analyzer
25
+ # Methods that delegate to `#perform` 1:1.
26
+ DIRECT_ENTRY_METHODS = %i[perform_async perform_inline].freeze
27
+
28
+ # Methods whose first argument is a schedule (the
29
+ # remaining args are forwarded to `#perform`).
30
+ SCHEDULED_ENTRY_METHODS = %i[perform_in perform_at].freeze
31
+
32
+ ENTRY_METHODS = (DIRECT_ENTRY_METHODS + SCHEDULED_ENTRY_METHODS).freeze
33
+
34
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
35
+
36
+ module_function
37
+
38
+ # @param path [String]
39
+ # @param root [Prism::Node]
40
+ # @param worker_index [WorkerIndex]
41
+ # @return [Array<Diagnostic>]
42
+ def diagnose(path:, root:, worker_index:)
43
+ diagnostics = []
44
+ walk(root) do |call_node|
45
+ class_name = constant_receiver_name(call_node.receiver)
46
+ next if class_name.nil?
47
+
48
+ entry = worker_index.find(class_name) || worker_index.find("::#{class_name}")
49
+ next if entry.nil?
50
+
51
+ diagnostics << info_diagnostic(path, call_node, entry)
52
+ arity_diag = arity_check(path, call_node, entry)
53
+ diagnostics << arity_diag if arity_diag
54
+ end
55
+ diagnostics
56
+ end
57
+
58
+ def walk(node, &)
59
+ return unless node.is_a?(Prism::Node)
60
+
61
+ yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
62
+ node.compact_child_nodes.each { |child| walk(child, &) }
63
+ end
64
+
65
+ def entry_call?(node)
66
+ ENTRY_METHODS.include?(node.name) &&
67
+ (node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode))
68
+ end
69
+
70
+ def info_diagnostic(path, call_node, entry)
71
+ location = call_node.location
72
+ Diagnostic.new(
73
+ path: path,
74
+ line: location.start_line,
75
+ column: location.start_column + 1,
76
+ severity: :info,
77
+ rule: "worker-call",
78
+ message: "`#{entry.class_name}.#{call_node.name}` matches `#perform` " \
79
+ "(arity #{entry.arity_label})"
80
+ )
81
+ end
82
+
83
+ def arity_check(path, call_node, entry)
84
+ all_args = (call_node.arguments&.arguments || []).size
85
+ # Scheduled entries consume the first arg as the
86
+ # schedule; the rest are forwarded.
87
+ forwarded_count = SCHEDULED_ENTRY_METHODS.include?(call_node.name) ? all_args - 1 : all_args
88
+
89
+ if SCHEDULED_ENTRY_METHODS.include?(call_node.name) && all_args.zero?
90
+ return missing_schedule_diagnostic(path, call_node, entry)
91
+ end
92
+
93
+ return nil if forwarded_count.negative?
94
+ return nil if entry.accepts?(forwarded_count)
95
+
96
+ location = call_node.location
97
+ Diagnostic.new(
98
+ path: path,
99
+ line: location.start_line,
100
+ column: location.start_column + 1,
101
+ severity: :error,
102
+ rule: "wrong-arity",
103
+ message: "`#{entry.class_name}.#{call_node.name}` expects " \
104
+ "#{describe_expected(entry, call_node.name)} forwarded to `#perform` " \
105
+ "(arity #{entry.arity_label}), got #{forwarded_count}"
106
+ )
107
+ end
108
+
109
+ def missing_schedule_diagnostic(path, call_node, entry)
110
+ location = call_node.location
111
+ Diagnostic.new(
112
+ path: path,
113
+ line: location.start_line,
114
+ column: location.start_column + 1,
115
+ severity: :error,
116
+ rule: "missing-schedule",
117
+ message: "`#{entry.class_name}.#{call_node.name}` requires a schedule " \
118
+ "(time / interval) as its first argument, got 0 arguments"
119
+ )
120
+ end
121
+
122
+ def describe_expected(entry, method_name)
123
+ if SCHEDULED_ENTRY_METHODS.include?(method_name)
124
+ "#{entry.arity_label} argument(s) (after the schedule)"
125
+ else
126
+ "#{entry.arity_label} argument(s)"
127
+ end
128
+ end
129
+
130
+ def constant_receiver_name(node)
131
+ case node
132
+ when Prism::ConstantReadNode then node.name.to_s
133
+ when Prism::ConstantPathNode then constant_path_name(node)
134
+ end
135
+ end
136
+
137
+ def constant_path_name(node)
138
+ parts = []
139
+ current = node
140
+ while current.is_a?(Prism::ConstantPathNode)
141
+ parts.unshift(current.name.to_s)
142
+ current = current.parent
143
+ end
144
+ case current
145
+ when nil then "::#{parts.join('::')}"
146
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end