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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/scope.rbs +22 -0
- 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
|