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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- 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 +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -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 +273 -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 +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -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 +34 -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 +463 -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 +277 -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 +167 -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 +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -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/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- 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
|