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,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "scope_walker"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Rspec < Rigor::Plugin::Base
10
+ # Per-file index of RSpec scope hierarchy. Each
11
+ # describe scope carries:
12
+ #
13
+ # - `outer_range`: `Range[Integer]` of line numbers
14
+ # covered by the describe's block body (start_line..end_line).
15
+ # - `describe_const`: the model constant string named in
16
+ # `RSpec.describe SomeModel do` / `describe SomeModel do`,
17
+ # or `nil` when the describe's first arg is a String
18
+ # (`describe ".foo"`).
19
+ # - `lets`: `Hash{Symbol => Prism::BlockNode}` of `let(:name)
20
+ # { ... }` and `subject(:name) { ... }` declarations.
21
+ # `:subject` is the key for the implicit `subject { ... }`.
22
+ #
23
+ # Pillar 2 Slice 2 — used by the plugin's
24
+ # `flow_contribution_for` hook to bind `let`-named
25
+ # method-shape calls inside `it` bodies to the let
26
+ # block's inferred type.
27
+ class LetScopeIndex
28
+ Record = Struct.new(:outer_range, :describe_const, :lets, keyword_init: true) do
29
+ def contains?(line) = outer_range.cover?(line)
30
+ def let?(name) = lets.key?(name.to_sym)
31
+ def let_block(name) = lets[name.to_sym]
32
+ end
33
+
34
+ attr_reader :records
35
+
36
+ def initialize(records)
37
+ @records = records.freeze
38
+ freeze
39
+ end
40
+
41
+ # Returns every record whose outer_range contains the
42
+ # given line, ordered from outermost to innermost. The
43
+ # innermost wins on `let` name collisions per Ruby's
44
+ # method-resolution order (RSpec nested `let` shadows
45
+ # the outer `let`).
46
+ def records_at(line)
47
+ @records.select { |rec| rec.contains?(line) }
48
+ end
49
+
50
+ # Resolves a `let` name at the given line by walking
51
+ # records innermost to outermost.
52
+ # @return [Prism::BlockNode, nil]
53
+ def let_block_at(line, name)
54
+ name_sym = name.to_sym
55
+ records_at(line).reverse.each do |rec|
56
+ return rec.let_block(name_sym) if rec.let?(name_sym)
57
+ end
58
+ nil
59
+ end
60
+
61
+ # Resolves the `describe`-anchor constant name at the
62
+ # given line. The innermost describe with a constant
63
+ # anchor wins; describe-with-String anchors are skipped.
64
+ # @return [String, nil]
65
+ def describe_const_at(line)
66
+ records_at(line).reverse.each do |rec|
67
+ return rec.describe_const if rec.describe_const
68
+ end
69
+ nil
70
+ end
71
+
72
+ # Builds an index from a parsed root node.
73
+ def self.build(root)
74
+ records = []
75
+ collect(root, anchor: nil, accumulator: records)
76
+ new(records)
77
+ end
78
+
79
+ def self.collect(node, anchor:, accumulator:)
80
+ return unless node.is_a?(Prism::Node)
81
+
82
+ if describe_call?(node)
83
+ record = build_record(node, anchor)
84
+ accumulator << record
85
+ if node.block&.body
86
+ collect(node.block.body, anchor: record.describe_const || anchor, accumulator: accumulator)
87
+ end
88
+ return
89
+ end
90
+
91
+ node.compact_child_nodes.each do |child|
92
+ collect(child, anchor: anchor, accumulator: accumulator)
93
+ end
94
+ end
95
+
96
+ def self.build_record(describe_call, parent_anchor)
97
+ block_body = describe_call.block.body
98
+ start_line = block_body&.location&.start_line || describe_call.location.start_line
99
+ end_line = block_body&.location&.end_line || describe_call.block.location.end_line
100
+
101
+ Record.new(
102
+ outer_range: (start_line..end_line),
103
+ describe_const: describe_call_const(describe_call) || parent_anchor,
104
+ lets: collect_lets(describe_call.block.body)
105
+ )
106
+ end
107
+
108
+ def self.describe_call?(node)
109
+ return false unless node.is_a?(Prism::CallNode)
110
+ return false unless ScopeWalker::SCOPE_METHODS.include?(node.name) || node.name == :describe
111
+ return false unless node.block.is_a?(Prism::BlockNode)
112
+
113
+ # `RSpec.describe ...` has receiver = RSpec; bare
114
+ # `describe ... do` has nil receiver. Accept both.
115
+ node.receiver.nil? || describe_receiver?(node.receiver)
116
+ end
117
+
118
+ def self.describe_receiver?(receiver)
119
+ case receiver
120
+ when Prism::ConstantReadNode then receiver.name == :RSpec
121
+ when Prism::ConstantPathNode then receiver.name == :RSpec && receiver.parent.nil?
122
+ else false
123
+ end
124
+ end
125
+
126
+ def self.describe_call_const(describe_call)
127
+ first_arg = describe_call.arguments&.arguments&.first
128
+ render_const(first_arg)
129
+ end
130
+
131
+ def self.render_const(node)
132
+ case node
133
+ when Prism::ConstantReadNode then node.name.to_s
134
+ when Prism::ConstantPathNode
135
+ parts = []
136
+ current = node
137
+ while current.is_a?(Prism::ConstantPathNode)
138
+ parts.unshift(current.name.to_s)
139
+ current = current.parent
140
+ end
141
+ case current
142
+ when nil then "::#{parts.join('::')}"
143
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
144
+ end
145
+ end
146
+ end
147
+
148
+ # Walks the describe's body shallowly: collects every
149
+ # top-level `let(:name) { ... }` / `subject(:name) { ... }`
150
+ # / `subject { ... }` declaration. Does NOT recurse into
151
+ # nested describe / context blocks — those have their
152
+ # own record built by `collect` above.
153
+ def self.collect_lets(body)
154
+ return {} unless body.is_a?(Prism::Node)
155
+
156
+ lets = {}
157
+ statements_of(body).each do |stmt|
158
+ decl = let_declaration(stmt)
159
+ next if decl.nil?
160
+
161
+ lets[decl[:name]] = decl[:block]
162
+ end
163
+ lets.freeze
164
+ end
165
+
166
+ def self.statements_of(node)
167
+ case node
168
+ when Prism::StatementsNode then node.body
169
+ else [node]
170
+ end
171
+ end
172
+
173
+ def self.let_declaration(node)
174
+ return nil unless node.is_a?(Prism::CallNode)
175
+ return nil unless node.receiver.nil?
176
+ return nil unless %i[let let! subject].include?(node.name)
177
+ return nil unless node.block.is_a?(Prism::BlockNode)
178
+
179
+ name = let_declaration_name(node)
180
+ return nil if name.nil?
181
+
182
+ { name: name, block: node.block }
183
+ end
184
+
185
+ def self.let_declaration_name(call_node)
186
+ first_arg = call_node.arguments&.arguments&.first
187
+ if first_arg.nil?
188
+ # `subject { ... }` with no name — implicit :subject.
189
+ return :subject if call_node.name == :subject
190
+
191
+ return nil
192
+ end
193
+ return nil unless first_arg.is_a?(Prism::SymbolNode)
194
+
195
+ first_arg.unescaped.to_sym
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "rigor/type"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ class Rspec < Rigor::Plugin::Base
9
+ # Pillar 2 Slice 2 — resolves the runtime type of a
10
+ # `let(:name) { body }` or `subject(:name) { body }`
11
+ # block by pattern-matching its body's tail expression.
12
+ #
13
+ # Recognised body shapes (floor):
14
+ #
15
+ # - `ConstantClass.new(...)` → `Nominal[ConstantClass]`
16
+ # - `Foo::Bar.new(...)` → `Nominal[Foo::Bar]`
17
+ # - `described_class.new(...)` → `Nominal[<describe const>]`
18
+ # when the surrounding describe block has a constant
19
+ # anchor.
20
+ # - `create(:factory)` / `build(:factory)` /
21
+ # `build_stubbed(:factory)` / `attributes_for(:factory)`
22
+ # → `Nominal[<factory.model_class>]` when the
23
+ # `:factory_index` ADR-9 fact is available and the
24
+ # factory name is known.
25
+ # - `FactoryBot.create(:factory)` etc. — same as the
26
+ # implicit-receiver factorybot form.
27
+ #
28
+ # Returns `nil` for any other body shape. Multi-statement
29
+ # bodies use the LAST statement (the block's return
30
+ # value).
31
+ module LetTypeResolver
32
+ FACTORYBOT_ENTRY_METHODS = %i[
33
+ create build build_stubbed attributes_for
34
+ create_list build_list build_stubbed_list
35
+ ].freeze
36
+
37
+ module_function
38
+
39
+ # @param block_node [Prism::BlockNode]
40
+ # @param describe_const [String, nil]
41
+ # @param factory_index [Object, nil] something
42
+ # responding to `find(factory_name)` and returning
43
+ # an entry that responds to `model_class`.
44
+ # @param environment [Rigor::Environment, nil]
45
+ # @return [Rigor::Type, nil]
46
+ def resolve(block_node, describe_const:, factory_index:, environment:)
47
+ tail = body_tail(block_node)
48
+ return nil if tail.nil?
49
+
50
+ nominal_for_call(
51
+ tail,
52
+ describe_const: describe_const,
53
+ factory_index: factory_index,
54
+ environment: environment
55
+ )
56
+ end
57
+
58
+ def body_tail(block_node)
59
+ body = block_node.body
60
+ return nil if body.nil?
61
+
62
+ case body
63
+ when Prism::StatementsNode
64
+ body.body.last
65
+ else
66
+ body
67
+ end
68
+ end
69
+
70
+ def nominal_for_call(node, describe_const:, factory_index:, environment:)
71
+ return nil unless node.is_a?(Prism::CallNode)
72
+
73
+ # `<Const>.new(...)` / `<ConstPath>.new(...)`
74
+ if node.name == :new && const_receiver?(node.receiver)
75
+ class_name = render_const(node.receiver)
76
+ return nominal_for(class_name, environment: environment) if class_name
77
+ end
78
+
79
+ # `described_class.new(...)`
80
+ if node.name == :new && described_class_receiver?(node.receiver)
81
+ return nil if describe_const.nil?
82
+
83
+ return nominal_for(describe_const, environment: environment)
84
+ end
85
+
86
+ # `create(:name)` / `build(:name)` / `FactoryBot.create(:name)` / `*_list`
87
+ factorybot_nominal(node, factory_index: factory_index, environment: environment)
88
+ end
89
+
90
+ def factorybot_nominal(call_node, factory_index:, environment:)
91
+ return nil if factory_index.nil?
92
+ return nil unless factorybot_call?(call_node)
93
+
94
+ factory_name = factorybot_first_arg(call_node)
95
+ return nil if factory_name.nil?
96
+
97
+ entry = factory_index.find(factory_name)
98
+ return nil if entry.nil?
99
+
100
+ model_class = entry.respond_to?(:model_class) ? entry.model_class : nil
101
+ return nil if model_class.nil?
102
+
103
+ nominal_for(model_class, environment: environment)
104
+ end
105
+
106
+ # Recognises both `create(:name)` (implicit receiver,
107
+ # via `include FactoryBot::Syntax::Methods`) and the
108
+ # explicit `FactoryBot.create(:name)` /
109
+ # `FactoryGirl.create(:name)` (legacy) forms.
110
+ def factorybot_call?(call_node)
111
+ return false unless FACTORYBOT_ENTRY_METHODS.include?(call_node.name)
112
+
113
+ recv = call_node.receiver
114
+ recv.nil? || factorybot_constant?(recv)
115
+ end
116
+
117
+ def factorybot_constant?(node)
118
+ case node
119
+ when Prism::ConstantReadNode then %i[FactoryBot FactoryGirl].include?(node.name)
120
+ when Prism::ConstantPathNode
121
+ node.parent.nil? && %i[FactoryBot FactoryGirl].include?(node.name)
122
+ else false
123
+ end
124
+ end
125
+
126
+ def factorybot_first_arg(call_node)
127
+ first_arg = call_node.arguments&.arguments&.first
128
+ case first_arg
129
+ when Prism::SymbolNode then first_arg.unescaped
130
+ when Prism::StringNode then first_arg.unescaped
131
+ end
132
+ end
133
+
134
+ def const_receiver?(node)
135
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
136
+ end
137
+
138
+ def described_class_receiver?(node)
139
+ node.is_a?(Prism::CallNode) && node.name == :described_class && node.receiver.nil?
140
+ end
141
+
142
+ def render_const(node)
143
+ case node
144
+ when Prism::ConstantReadNode then node.name.to_s
145
+ when Prism::ConstantPathNode
146
+ parts = []
147
+ current = node
148
+ while current.is_a?(Prism::ConstantPathNode)
149
+ parts.unshift(current.name.to_s)
150
+ current = current.parent
151
+ end
152
+ case current
153
+ when nil then "::#{parts.join('::')}"
154
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
155
+ end
156
+ end
157
+ end
158
+
159
+ def nominal_for(class_name, environment:)
160
+ if environment
161
+ environment.nominal_for_name(class_name) ||
162
+ Rigor::Type::Combinator.nominal_of(class_name)
163
+ else
164
+ Rigor::Type::Combinator.nominal_of(class_name)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -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