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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "rigor/type"
5
+ require "rigor/flow_contribution"
6
+ require "rigor/flow_contribution/fact"
7
+
8
+ module Rigor
9
+ module Plugin
10
+ class Rspec < Rigor::Plugin::Base
11
+ # Pillar 2 Slice 1 — recognises `expect(x).to MATCHER`
12
+ # patterns at `flow_contribution_for` time and emits
13
+ # `post_return_facts` that narrow the named local on the
14
+ # post-call edge.
15
+ #
16
+ # The supported matchers are the lowest-false-positive
17
+ # floor of the RSpec matcher DSL:
18
+ #
19
+ # - `be_a(T)` / `be_kind_of(T)` — `x is_a?(T)` style class
20
+ # membership; narrows `x` to `T`.
21
+ # - `be_instance_of(T)` — exact-class match; narrows
22
+ # `x` to `T` (the engine currently treats Nominal[T]
23
+ # uniformly, so the distinction with `be_a` is observed
24
+ # at the carrier level but not the runtime).
25
+ # - `be_nil` — narrows `x` to `Constant<nil>`.
26
+ # - `eq(LITERAL)` for a literal-value argument — narrows
27
+ # `x` to `Constant<literal>`.
28
+ #
29
+ # The matchers below this floor (`be_truthy` / `be_falsey`
30
+ # / `be_within` / `be > / < / >=` / `include` / `start_with`
31
+ # / `match(regex)` / `raise_error` / receive-style mocks)
32
+ # require either edge-aware fragments (`truthy_facts` /
33
+ # `falsey_facts` rather than `post_return_facts`) or
34
+ # diagnostic-only enforcement; both are queued for follow-up
35
+ # slices.
36
+ #
37
+ # The analyzer fires ONLY when:
38
+ #
39
+ # 1. The call node is one of `<recv>.to(matcher)` /
40
+ # `<recv>.not_to(matcher)` / `<recv>.to_not(matcher)`.
41
+ # `not_to` / `to_not` flip the fact's `negative` flag
42
+ # so the engine narrows AWAY from the matcher's type
43
+ # (e.g. `not_to be_nil` removes nil from the receiver).
44
+ # 2. The receiver is `expect(<local_var>)` — exactly one
45
+ # positional argument that's a LocalVariableReadNode.
46
+ # Composite receivers (`expect(foo.bar)`,
47
+ # `expect { ... }.to raise_error`) fall through.
48
+ # 3. The matcher is one of the recognised forms above
49
+ # (`be_a` / `be_kind_of` / `be_instance_of` /
50
+ # `be_an_instance_of` / `be_nil` / `eq(literal)` /
51
+ # `eql(literal)` / `match(/regex/)`).
52
+ module MatcherAnalyzer
53
+ module_function
54
+
55
+ # @param call_node [Prism::CallNode] the call whose
56
+ # contribution we're computing. Returns nil when the
57
+ # call shape does not match `expect(local).to matcher`.
58
+ # @param environment [Rigor::Environment, nil] the
59
+ # surrounding environment used to resolve a matcher's
60
+ # class-name argument to a `Type::Nominal`. When nil,
61
+ # class-name resolution falls back to a bare
62
+ # `Nominal[<name>]` carrier (sound — the receiver
63
+ # constant may be a user class not in RBS).
64
+ # @return [Rigor::FlowContribution, nil]
65
+ def contribution_for(call_node, environment:)
66
+ verb = assertion_verb(call_node)
67
+ return nil if verb.nil?
68
+ return nil unless call_node.receiver.is_a?(Prism::CallNode)
69
+
70
+ expect_call = call_node.receiver
71
+ return nil unless expect_call?(expect_call)
72
+
73
+ target_local = expect_first_arg_local(expect_call)
74
+ return nil if target_local.nil?
75
+
76
+ matcher = matcher_call(call_node)
77
+ return nil if matcher.nil?
78
+
79
+ narrowed_type = narrowed_type_for(matcher, environment: environment)
80
+ return nil if narrowed_type.nil?
81
+
82
+ fact = Rigor::FlowContribution::Fact.new(
83
+ target_kind: :local,
84
+ target_name: target_local,
85
+ type: narrowed_type,
86
+ negative: verb == :negative
87
+ )
88
+ Rigor::FlowContribution.new(post_return_facts: [fact])
89
+ end
90
+
91
+ # Recognises the assertion verb chained after `expect(...)`:
92
+ # - `.to(<matcher>)` → :positive
93
+ # - `.not_to(<matcher>)` → :negative
94
+ # - `.to_not(<matcher>)` → :negative (older spelling)
95
+ # Returns nil for any other call.
96
+ def assertion_verb(node)
97
+ return nil unless node.is_a?(Prism::CallNode)
98
+ return nil unless node.arguments&.arguments&.size == 1
99
+
100
+ case node.name
101
+ when :to then :positive
102
+ when :not_to, :to_not then :negative
103
+ end
104
+ end
105
+
106
+ def expect_call?(node)
107
+ node.is_a?(Prism::CallNode) && node.name == :expect &&
108
+ node.receiver.nil? && node.arguments&.arguments&.size == 1
109
+ end
110
+
111
+ def expect_first_arg_local(expect_call)
112
+ arg = expect_call.arguments.arguments.first
113
+ return nil unless arg.is_a?(Prism::LocalVariableReadNode)
114
+
115
+ arg.name
116
+ end
117
+
118
+ def matcher_call(to_call)
119
+ to_call.arguments.arguments.first
120
+ end
121
+
122
+ # Translates a recognised matcher CallNode into the
123
+ # narrowed type. Returns nil when the matcher is
124
+ # unrecognised or its argument shape does not match the
125
+ # supported envelope.
126
+ def narrowed_type_for(matcher, environment:)
127
+ return nil unless matcher.is_a?(Prism::CallNode) && matcher.receiver.nil?
128
+
129
+ case matcher.name
130
+ when :be_a, :be_kind_of, :be_instance_of, :be_an_instance_of
131
+ nominal_type_for_class_arg(matcher, environment: environment)
132
+ when :be_nil
133
+ return nil unless empty_args?(matcher)
134
+
135
+ Rigor::Type::Combinator.constant_of(nil)
136
+ when :eq, :eql
137
+ constant_type_for_literal_arg(matcher)
138
+ when :match
139
+ # `match(/regex/)` narrows x to String. `match("...")`
140
+ # or `match(arbitrary_object)` falls through — the
141
+ # broader matcher dispatch needs the receiver to be a
142
+ # String, but we can only assert that for a literal
143
+ # regex.
144
+ string_type_for_regex_arg(matcher)
145
+ end
146
+ end
147
+
148
+ def nominal_type_for_class_arg(matcher, environment:)
149
+ args = matcher.arguments&.arguments || []
150
+ return nil unless args.size == 1
151
+
152
+ class_name = constant_path_name(args.first)
153
+ return nil if class_name.nil?
154
+
155
+ if environment
156
+ environment.nominal_for_name(class_name) ||
157
+ Rigor::Type::Combinator.nominal_of(class_name)
158
+ else
159
+ Rigor::Type::Combinator.nominal_of(class_name)
160
+ end
161
+ end
162
+
163
+ def constant_type_for_literal_arg(matcher)
164
+ args = matcher.arguments&.arguments || []
165
+ return nil unless args.size == 1
166
+
167
+ literal_value = literal_value_for(args.first)
168
+ return nil if literal_value.equal?(NO_LITERAL)
169
+
170
+ Rigor::Type::Combinator.constant_of(literal_value)
171
+ end
172
+
173
+ def string_type_for_regex_arg(matcher)
174
+ args = matcher.arguments&.arguments || []
175
+ return nil unless args.size == 1
176
+
177
+ arg = args.first
178
+ return nil unless arg.is_a?(Prism::RegularExpressionNode) ||
179
+ arg.is_a?(Prism::InterpolatedRegularExpressionNode)
180
+
181
+ Rigor::Type::Combinator.nominal_of("String")
182
+ end
183
+
184
+ NO_LITERAL = Object.new.freeze
185
+ private_constant :NO_LITERAL
186
+
187
+ # Returns the Ruby value of a literal-AST argument, or
188
+ # `NO_LITERAL` when the node isn't a recognised literal
189
+ # shape. Recognised forms cover the common `eq(literal)`
190
+ # case: integer, float, true/false/nil, string, symbol.
191
+ def literal_value_for(node)
192
+ case node
193
+ when Prism::IntegerNode then node.value
194
+ when Prism::FloatNode then node.value
195
+ when Prism::TrueNode then true
196
+ when Prism::FalseNode then false
197
+ when Prism::NilNode then nil
198
+ when Prism::StringNode then node.unescaped
199
+ when Prism::SymbolNode then node.unescaped.to_sym
200
+ else NO_LITERAL
201
+ end
202
+ end
203
+
204
+ def empty_args?(matcher)
205
+ args = matcher.arguments&.arguments
206
+ args.nil? || args.empty?
207
+ end
208
+
209
+ # Renders a `Prism::ConstantReadNode` /
210
+ # `Prism::ConstantPathNode` chain as a `"Foo::Bar"`
211
+ # String. Returns nil when the node isn't a constant
212
+ # reference.
213
+ def constant_path_name(node)
214
+ case node
215
+ when Prism::ConstantReadNode
216
+ node.name.to_s
217
+ when Prism::ConstantPathNode
218
+ parts = []
219
+ current = node
220
+ while current.is_a?(Prism::ConstantPathNode)
221
+ parts.unshift(current.name.to_s)
222
+ current = current.parent
223
+ end
224
+ case current
225
+ when nil then "::#{parts.join('::')}"
226
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Rspec < Rigor::Plugin::Base
8
+ # Walks an RSpec spec file's AST and yields, for each
9
+ # describe / context block (including the outer
10
+ # `RSpec.describe`), a `Scope` value with the `let`
11
+ # and `subject` declarations recorded inside that
12
+ # scope.
13
+ #
14
+ # Scope hierarchy is preserved: each `Scope` carries
15
+ # a list of its `nested_scopes`. The analyzer uses the
16
+ # hierarchy to detect cross-scope shadowing.
17
+ #
18
+ # Recognised scope methods (when called without a
19
+ # receiver, or with `RSpec` as the receiver):
20
+ #
21
+ # - `describe` / `context` — both open a new nested
22
+ # scope.
23
+ # - `RSpec.describe` — the outermost scope.
24
+ #
25
+ # Recognised declaration methods (inside any scope,
26
+ # called without a receiver):
27
+ #
28
+ # - `let(:name) { ... }` — caches the block result
29
+ # per-example; recorded as a Declaration.
30
+ # - `let!(:name) { ... }` — same, but evaluated
31
+ # eagerly via a `before` hook; same shape for our
32
+ # purposes.
33
+ # - `subject(:name) { ... }` — special-cases name
34
+ # `:subject` when called without a name.
35
+ module ScopeWalker
36
+ SCOPE_METHODS = %i[describe context].freeze
37
+ DECLARATION_METHODS = %i[let let! subject].freeze
38
+
39
+ # @!attribute [r] kind
40
+ # `:describe`, `:context`, or `:rspec_describe` for
41
+ # the root.
42
+ # @!attribute [r] declarations
43
+ # `Array<Declaration>` declared in this scope.
44
+ # @!attribute [r] nested_scopes
45
+ # `Array<Scope>` nested under this scope.
46
+ # @!attribute [r] location
47
+ # `Prism::Location` of the call node that opened
48
+ # this scope.
49
+ Scope = Struct.new(:kind, :declarations, :nested_scopes, :location, keyword_init: true)
50
+
51
+ # @!attribute [r] name
52
+ # `Symbol` declared name (`:user`, `:subject`,
53
+ # ...).
54
+ # @!attribute [r] kind
55
+ # `:let`, `:let!`, or `:subject`.
56
+ # @!attribute [r] location
57
+ # `Prism::Location` of the call node.
58
+ # @!attribute [r] block_node
59
+ # `Prism::BlockNode` of the declaration's body.
60
+ Declaration = Struct.new(:name, :kind, :location, :block_node, keyword_init: true)
61
+
62
+ module_function
63
+
64
+ # Walks the parsed file and returns an array of
65
+ # top-level scopes (each `RSpec.describe` is a
66
+ # separate root). Files with no recognised scopes
67
+ # return an empty array.
68
+ def collect_scopes(root)
69
+ scopes = []
70
+ walk_top_level(root, scopes)
71
+ scopes
72
+ end
73
+
74
+ # Walks every scope in a tree (root + descendants)
75
+ # and yields each in turn.
76
+ def each_scope(scope, &)
77
+ yield scope
78
+ scope.nested_scopes.each { |child| each_scope(child, &) }
79
+ end
80
+
81
+ def walk_top_level(node, scopes)
82
+ return unless node.is_a?(Prism::Node)
83
+
84
+ if rspec_describe_call?(node)
85
+ scopes << build_scope(node, kind: :rspec_describe)
86
+ else
87
+ node.compact_child_nodes.each { |child| walk_top_level(child, scopes) }
88
+ end
89
+ end
90
+
91
+ # Returns true for `RSpec.describe ... do |...| ...
92
+ # end` calls.
93
+ def rspec_describe_call?(node)
94
+ return false unless node.is_a?(Prism::CallNode)
95
+ return false unless node.name == :describe
96
+ return false unless node.block.is_a?(Prism::BlockNode)
97
+
98
+ receiver_name = constant_name(node.receiver)
99
+ %w[RSpec ::RSpec].include?(receiver_name)
100
+ end
101
+
102
+ # Returns true for `describe ... do ... end` /
103
+ # `context ... do ... end` (called without an
104
+ # explicit receiver — `RSpec.describe` is handled
105
+ # separately by `rspec_describe_call?`).
106
+ def nested_scope_call?(node)
107
+ node.is_a?(Prism::CallNode) &&
108
+ SCOPE_METHODS.include?(node.name) &&
109
+ node.block.is_a?(Prism::BlockNode) &&
110
+ node.receiver.nil?
111
+ end
112
+
113
+ def declaration_call?(node)
114
+ node.is_a?(Prism::CallNode) &&
115
+ DECLARATION_METHODS.include?(node.name) &&
116
+ node.receiver.nil?
117
+ end
118
+
119
+ # Constructs a Scope from a describe / context /
120
+ # RSpec.describe call node. Walks the block body
121
+ # for declarations + nested scopes.
122
+ def build_scope(call_node, kind:)
123
+ declarations = []
124
+ nested = []
125
+ (call_node.block.body&.compact_child_nodes || []).each do |child|
126
+ classify_child(child, declarations, nested)
127
+ end
128
+
129
+ Scope.new(
130
+ kind: kind,
131
+ declarations: declarations,
132
+ nested_scopes: nested,
133
+ location: call_node.location
134
+ )
135
+ end
136
+
137
+ def classify_child(child, declarations, nested)
138
+ if declaration_call?(child)
139
+ decl = build_declaration(child)
140
+ declarations << decl if decl
141
+ elsif nested_scope_call?(child)
142
+ nested << build_scope(child, kind: child.name)
143
+ end
144
+ end
145
+
146
+ def build_declaration(call_node)
147
+ first_arg = call_node.arguments&.arguments&.first
148
+ # `subject(&block)` (no name) defaults to the
149
+ # implicit subject; record it as `:subject`.
150
+ if first_arg.nil?
151
+ return nil unless call_node.name == :subject
152
+
153
+ return Declaration.new(
154
+ name: :subject,
155
+ kind: call_node.name,
156
+ location: call_node.location,
157
+ block_node: call_node.block
158
+ )
159
+ end
160
+ return nil unless first_arg.is_a?(Prism::SymbolNode)
161
+
162
+ Declaration.new(
163
+ name: first_arg.unescaped.to_sym,
164
+ kind: call_node.name,
165
+ location: call_node.location,
166
+ block_node: call_node.block
167
+ )
168
+ end
169
+
170
+ def constant_name(node)
171
+ case node
172
+ when nil then nil
173
+ when Prism::ConstantReadNode then node.name.to_s
174
+ when Prism::ConstantPathNode
175
+ parts = []
176
+ current = node
177
+ while current.is_a?(Prism::ConstantPathNode)
178
+ parts.unshift(current.name.to_s)
179
+ current = current.parent
180
+ end
181
+ case current
182
+ when nil then "::#{parts.join('::')}"
183
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "rspec/scope_walker"
6
+ require_relative "rspec/analyzer"
7
+ require_relative "rspec/matcher_analyzer"
8
+ require_relative "rspec/let_scope_index"
9
+ require_relative "rspec/let_type_resolver"
10
+
11
+ module Rigor
12
+ module Plugin
13
+ # rigor-rspec — validates RSpec `let` / `subject`
14
+ # declarations within each describe / context scope.
15
+ #
16
+ # Tier 3A of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
17
+ # Deliberately scoped — the roadmap describes a much
18
+ # larger plugin (let-typo detection in `it` bodies,
19
+ # `expect(x).to receive(:method)` mock-target
20
+ # validation). Both are out of scope for v0.1.0; this
21
+ # plugin ships the two checks that have the lowest
22
+ # false-positive risk:
23
+ #
24
+ # 1. **Duplicate `let` / `subject` declarations** in
25
+ # the same scope (`warning`). RSpec's runtime lets
26
+ # the last declaration win, so the first one is
27
+ # silently shadowed — almost always a copy-paste
28
+ # bug.
29
+ # 2. **Self-referencing `let` / `subject`** — calling
30
+ # the declared name *inside* its own block body
31
+ # (`error`). At runtime this infinite-loops; users
32
+ # typically meant to call a different method or
33
+ # forgot to introduce a `super`.
34
+ #
35
+ # ## Configuration
36
+ #
37
+ # No knobs in v0.1.0. The plugin walks every analysed
38
+ # file looking for `RSpec.describe ... do` blocks; spec
39
+ # files outside the project's `paths:` are not scanned.
40
+ #
41
+ # ## Limitations (v0.1.0)
42
+ #
43
+ # - **No let-typo detection.** Detecting an `it`
44
+ # block's reference to a misspelled `let` name
45
+ # requires resolving every method call inside the
46
+ # block against the let scope chain, the included
47
+ # modules, the matchers DSL, and helper methods.
48
+ # Reliable diagnostics here need a much heavier
49
+ # walker — see the README's `Future direction`.
50
+ # - **No mock-target validation.**
51
+ # `expect(x).to receive(:nme)` validating against
52
+ # `x`'s methods is a separate slice; it overlaps with
53
+ # the engine's general method-existence
54
+ # diagnostics and needs careful coordination to avoid
55
+ # double-firing.
56
+ # - **No shared-context resolution.** `include_context`,
57
+ # `shared_context`, and `it_behaves_like` are
58
+ # recognised as scope-opening calls but their
59
+ # declarations are not pulled into the host scope.
60
+ # - **Constant validation is not done here.**
61
+ # `RSpec.describe SomeClass do` does not validate
62
+ # `SomeClass`; the engine's `inference.unresolved-constant`
63
+ # already catches that.
64
+ class Rspec < Rigor::Plugin::Base
65
+ manifest(
66
+ id: "rspec",
67
+ version: "0.3.0",
68
+ description: "Validates RSpec `let` / `subject` declarations within each scope; " \
69
+ "narrows expect(x).to <matcher> assertions downstream in `it` bodies; " \
70
+ "binds let / subject locals to their inferred return type (Pillar 2 " \
71
+ "Slice 2) — `let(:user) { User.new(...) }` / `let(:user) { create(:user) }` / " \
72
+ "`subject { described_class.new(...) }`.",
73
+ consumes: [
74
+ { plugin_id: "factorybot", name: :factory_index, optional: true }
75
+ ]
76
+ )
77
+
78
+ def init(services)
79
+ @services = services
80
+ @factory_index_resolved = false
81
+ @factory_index = nil
82
+ # Per-path `LetScopeIndex` cache. The plugin's
83
+ # `flow_contribution_for` is called for every call
84
+ # node the dispatcher visits; building the index once
85
+ # per file is essential for performance.
86
+ @let_index_cache = {}
87
+ end
88
+
89
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
90
+ # Build the let-scope index for this file while we
91
+ # have the parsed root in hand — `flow_contribution_for`
92
+ # picks it up from `@let_index_cache` keyed on path.
93
+ @let_index_cache[path] ||= LetScopeIndex.build(root)
94
+ Analyzer.diagnose(path: path, root: root).map { |diag| build_diagnostic(diag) }
95
+ end
96
+
97
+ # Pillar 2 Slice 1 — spec-derived flow facts from RSpec
98
+ # matcher assertions (six-matcher floor: `be_a`,
99
+ # `be_kind_of`, `be_instance_of`, `be_nil`, `eq(literal)`,
100
+ # `eql(literal)`; `match(/regex/)`; `not_to` / `to_not`).
101
+ #
102
+ # Pillar 2 Slice 2 (v0.3.0) — additionally binds local
103
+ # reads in `it` / spec bodies to their `let(:name) { ... }`
104
+ # block's inferred return type. Composes with Slice 1:
105
+ # a matcher narrowing fires after the let binding.
106
+ def flow_contribution_for(call_node:, scope:)
107
+ matcher = MatcherAnalyzer.contribution_for(call_node, environment: scope&.environment)
108
+ return matcher if matcher
109
+
110
+ let_binding_contribution(call_node, scope)
111
+ end
112
+
113
+ private
114
+
115
+ # Pillar 2 Slice 2 — when the call node is a no-receiver
116
+ # method call (`user`, `subject`, etc.) inside an RSpec
117
+ # `describe` block whose lets include a matching name,
118
+ # return a `FlowContribution(return_type: <inferred>)`.
119
+ def let_binding_contribution(call_node, scope)
120
+ return nil if scope.nil?
121
+ return nil unless candidate_call?(call_node)
122
+
123
+ index = let_scope_index_for(scope.source_path)
124
+ return nil if index.nil?
125
+
126
+ line = call_node.location.start_line
127
+ block_node = index.let_block_at(line, call_node.name)
128
+ return nil if block_node.nil?
129
+
130
+ describe_const = index.describe_const_at(line)
131
+ type = LetTypeResolver.resolve(
132
+ block_node,
133
+ describe_const: describe_const,
134
+ factory_index: factory_index_or_nil,
135
+ environment: scope.environment
136
+ )
137
+ return nil if type.nil?
138
+
139
+ Rigor::FlowContribution.new(return_type: type)
140
+ end
141
+
142
+ def candidate_call?(call_node)
143
+ call_node.is_a?(Prism::CallNode) &&
144
+ call_node.receiver.nil? &&
145
+ call_node.block.nil? &&
146
+ # Calls with arguments are matcher / DSL invocations,
147
+ # not let-bound name reads. `subject` / `user` etc.
148
+ # without args are the implicit-method-call shape RSpec
149
+ # uses to expose let / subject in `it` bodies.
150
+ (call_node.arguments.nil? || call_node.arguments.arguments.empty?)
151
+ end
152
+
153
+ def let_scope_index_for(path)
154
+ return nil if path.nil?
155
+ return @let_index_cache[path] if @let_index_cache.key?(path)
156
+
157
+ @let_index_cache[path] = build_let_scope_index(path)
158
+ end
159
+
160
+ def build_let_scope_index(path)
161
+ source = io_boundary.read_file(path)
162
+ parse_result = Prism.parse(source)
163
+ return nil unless parse_result.errors.empty?
164
+
165
+ LetScopeIndex.build(parse_result.value)
166
+ rescue Rigor::Plugin::AccessDeniedError, Errno::ENOENT
167
+ nil
168
+ end
169
+
170
+ def factory_index_or_nil
171
+ return @factory_index if @factory_index_resolved
172
+
173
+ @factory_index = @services&.fact_store&.read(plugin_id: "factorybot", name: :factory_index)
174
+ @factory_index_resolved = true
175
+ @factory_index
176
+ end
177
+
178
+ def build_diagnostic(diag)
179
+ Rigor::Analysis::Diagnostic.new(
180
+ path: diag.path, line: diag.line, column: diag.column,
181
+ message: diag.message, severity: diag.severity, rule: diag.rule
182
+ )
183
+ end
184
+ end
185
+
186
+ Rigor::Plugin.register(Rspec)
187
+ end
188
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rspec"