rigortype 0.0.1
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 +7 -0
- data/LICENSE +373 -0
- data/README.md +152 -0
- data/exe/rigor +9 -0
- data/lib/rigor/analysis/check_rules.rb +503 -0
- data/lib/rigor/analysis/diagnostic.rb +35 -0
- data/lib/rigor/analysis/fact_store.rb +133 -0
- data/lib/rigor/analysis/result.rb +29 -0
- data/lib/rigor/analysis/runner.rb +119 -0
- data/lib/rigor/ast/type_node.rb +41 -0
- data/lib/rigor/ast.rb +22 -0
- data/lib/rigor/cli/type_of_command.rb +160 -0
- data/lib/rigor/cli/type_of_renderer.rb +88 -0
- data/lib/rigor/cli/type_scan_command.rb +160 -0
- data/lib/rigor/cli/type_scan_renderer.rb +165 -0
- data/lib/rigor/cli/type_scan_report.rb +32 -0
- data/lib/rigor/cli.rb +195 -0
- data/lib/rigor/configuration.rb +49 -0
- data/lib/rigor/environment/class_registry.rb +141 -0
- data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
- data/lib/rigor/environment/rbs_loader.rb +244 -0
- data/lib/rigor/environment.rb +177 -0
- data/lib/rigor/inference/acceptance.rb +444 -0
- data/lib/rigor/inference/block_parameter_binder.rb +198 -0
- data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
- data/lib/rigor/inference/coverage_scanner.rb +85 -0
- data/lib/rigor/inference/expression_typer.rb +831 -0
- data/lib/rigor/inference/fallback.rb +35 -0
- data/lib/rigor/inference/fallback_tracer.rb +64 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
- data/lib/rigor/inference/method_dispatcher.rb +213 -0
- data/lib/rigor/inference/method_parameter_binder.rb +257 -0
- data/lib/rigor/inference/multi_target_binder.rb +143 -0
- data/lib/rigor/inference/narrowing.rb +1008 -0
- data/lib/rigor/inference/rbs_type_translator.rb +219 -0
- data/lib/rigor/inference/scope_indexer.rb +468 -0
- data/lib/rigor/inference/statement_evaluator.rb +1017 -0
- data/lib/rigor/rbs_extended.rb +98 -0
- data/lib/rigor/scope.rb +340 -0
- data/lib/rigor/source/node_locator.rb +104 -0
- data/lib/rigor/source/node_walker.rb +37 -0
- data/lib/rigor/source.rb +15 -0
- data/lib/rigor/testing.rb +65 -0
- data/lib/rigor/trinary.rb +108 -0
- data/lib/rigor/type/accepts_result.rb +109 -0
- data/lib/rigor/type/bot.rb +57 -0
- data/lib/rigor/type/combinator.rb +148 -0
- data/lib/rigor/type/constant.rb +90 -0
- data/lib/rigor/type/dynamic.rb +60 -0
- data/lib/rigor/type/hash_shape.rb +246 -0
- data/lib/rigor/type/nominal.rb +83 -0
- data/lib/rigor/type/singleton.rb +65 -0
- data/lib/rigor/type/top.rb +56 -0
- data/lib/rigor/type/tuple.rb +84 -0
- data/lib/rigor/type/union.rb +65 -0
- data/lib/rigor/type.rb +23 -0
- data/lib/rigor/version.rb +5 -0
- data/lib/rigor.rb +29 -0
- data/sig/rigor/analysis/fact_store.rbs +51 -0
- data/sig/rigor/ast.rbs +11 -0
- data/sig/rigor/environment.rbs +59 -0
- data/sig/rigor/inference.rbs +151 -0
- data/sig/rigor/rbs_extended.rbs +22 -0
- data/sig/rigor/scope.rbs +49 -0
- data/sig/rigor/source.rbs +20 -0
- data/sig/rigor/testing.rbs +9 -0
- data/sig/rigor/trinary.rbs +29 -0
- data/sig/rigor/type.rbs +171 -0
- data/sig/rigor.rbs +70 -0
- metadata +260 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../source/node_walker"
|
|
6
|
+
require_relative "../type"
|
|
7
|
+
require_relative "diagnostic"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Analysis
|
|
11
|
+
# First-preview catalogue of `rigor check` diagnostic rules.
|
|
12
|
+
#
|
|
13
|
+
# The rules are intentionally narrow: they fire ONLY when the
|
|
14
|
+
# engine is confident enough to make a useful claim, and they
|
|
15
|
+
# MUST NOT raise on unrecognised AST shapes, RBS gaps, or
|
|
16
|
+
# missing scope information. Each rule consumes the per-node
|
|
17
|
+
# scope index produced by
|
|
18
|
+
# `Rigor::Inference::ScopeIndexer.index` and yields zero or
|
|
19
|
+
# more `Rigor::Analysis::Diagnostic` values.
|
|
20
|
+
#
|
|
21
|
+
# The first shipped rule, `UndefinedMethodOnTypedReceiver`,
|
|
22
|
+
# flags an explicit-receiver `Prism::CallNode` whose receiver
|
|
23
|
+
# statically resolves to a `Type::Nominal` or `Type::Singleton`
|
|
24
|
+
# known to the analyzer's RBS environment AND whose method
|
|
25
|
+
# name does not appear on that class's instance / singleton
|
|
26
|
+
# method table. This is the canonical "type check" signal
|
|
27
|
+
# ("Foo has no method bar"), but it explicitly does NOT fire
|
|
28
|
+
# for:
|
|
29
|
+
#
|
|
30
|
+
# - implicit-self calls (no `node.receiver`) — too noisy
|
|
31
|
+
# without per-method RBS for every helper in the class.
|
|
32
|
+
# - dynamic / unknown receivers (`Dynamic[T]`, `Top`, `Union`)
|
|
33
|
+
# — by definition we cannot enumerate the method set.
|
|
34
|
+
# - shape carriers (`Tuple`, `HashShape`, `Constant`) — their
|
|
35
|
+
# dispatch goes through `ShapeDispatch` / `ConstantFolding`
|
|
36
|
+
# which the rule does not yet model.
|
|
37
|
+
# - receivers whose class name is NOT registered in the
|
|
38
|
+
# loader (RBS-blind environments, unknown stdlib).
|
|
39
|
+
#
|
|
40
|
+
# The above list is the deliberate conservative envelope of
|
|
41
|
+
# the first preview; later slices broaden it.
|
|
42
|
+
# rubocop:disable Metrics/ModuleLength
|
|
43
|
+
module CheckRules
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Yields diagnostics for every unrecognised method call on
|
|
47
|
+
# a typed receiver in `root`'s subtree. The caller MUST
|
|
48
|
+
# have already produced `scope_index` through
|
|
49
|
+
# `Rigor::Inference::ScopeIndexer.index(root, default_scope:)`.
|
|
50
|
+
#
|
|
51
|
+
# @param path [String] used to populate
|
|
52
|
+
# `Diagnostic#path`; the rule does not open files.
|
|
53
|
+
# @param root [Prism::Node]
|
|
54
|
+
# @param scope_index [Hash{Prism::Node => Rigor::Scope}]
|
|
55
|
+
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
56
|
+
def diagnose(path:, root:, scope_index:) # rubocop:disable Metrics/CyclomaticComplexity
|
|
57
|
+
diagnostics = []
|
|
58
|
+
Source::NodeWalker.each(root) do |node|
|
|
59
|
+
next unless node.is_a?(Prism::CallNode)
|
|
60
|
+
|
|
61
|
+
diagnostic = undefined_method_diagnostic(path, node, scope_index)
|
|
62
|
+
diagnostics << diagnostic if diagnostic
|
|
63
|
+
|
|
64
|
+
arity_diagnostic = wrong_arity_diagnostic(path, node, scope_index)
|
|
65
|
+
diagnostics << arity_diagnostic if arity_diagnostic
|
|
66
|
+
|
|
67
|
+
nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
|
|
68
|
+
diagnostics << nil_diagnostic if nil_diagnostic
|
|
69
|
+
|
|
70
|
+
dump_diagnostic = dump_type_diagnostic(path, node, scope_index)
|
|
71
|
+
diagnostics << dump_diagnostic if dump_diagnostic
|
|
72
|
+
|
|
73
|
+
assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
|
|
74
|
+
diagnostics << assert_diagnostic if assert_diagnostic
|
|
75
|
+
end
|
|
76
|
+
diagnostics
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# rubocop:disable Metrics/ClassLength
|
|
80
|
+
class << self
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def undefined_method_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
84
|
+
return nil if call_node.receiver.nil?
|
|
85
|
+
|
|
86
|
+
scope = scope_index[call_node]
|
|
87
|
+
return nil if scope.nil?
|
|
88
|
+
|
|
89
|
+
receiver_type = scope.type_of(call_node.receiver)
|
|
90
|
+
class_name = concrete_class_name(receiver_type)
|
|
91
|
+
return nil if class_name.nil?
|
|
92
|
+
|
|
93
|
+
# Slice 7 phase 12 — suppress when the user has
|
|
94
|
+
# declared the method in source (instance `def`,
|
|
95
|
+
# `def self.foo`, or recognised `define_method`).
|
|
96
|
+
kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
|
|
97
|
+
return nil if scope.discovered_method?(class_name, call_node.name, kind)
|
|
98
|
+
|
|
99
|
+
loader = scope.environment.rbs_loader
|
|
100
|
+
return nil if loader.nil?
|
|
101
|
+
return nil unless loader.class_known?(class_name)
|
|
102
|
+
|
|
103
|
+
# When the loader cannot build a class definition for a
|
|
104
|
+
# name it nominally knows (constant-decl aliases such
|
|
105
|
+
# as `YAML` → `Psych`, or RBS-build failures for
|
|
106
|
+
# malformed signatures), we cannot enumerate methods
|
|
107
|
+
# so we MUST NOT emit a false positive. Skip the rule
|
|
108
|
+
# in that case.
|
|
109
|
+
return nil unless definition_available?(loader, receiver_type, class_name)
|
|
110
|
+
|
|
111
|
+
method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
|
|
112
|
+
return nil if method_def
|
|
113
|
+
|
|
114
|
+
build_undefined_method_diagnostic(path, call_node, receiver_type)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns a qualified class name for the in-scope check.
|
|
118
|
+
# Nominal / Singleton carry a single-class identity
|
|
119
|
+
# directly. Constant projects to its value's class.
|
|
120
|
+
# Tuple projects to "Array" and HashShape to "Hash" so
|
|
121
|
+
# arity / dispatch checks against the underlying class
|
|
122
|
+
# still apply. Dynamic / Top / Union / Bot do not name a
|
|
123
|
+
# single class and return nil to skip the rule.
|
|
124
|
+
def concrete_class_name(type)
|
|
125
|
+
case type
|
|
126
|
+
when Type::Nominal, Type::Singleton then type.class_name
|
|
127
|
+
when Type::Tuple then "Array"
|
|
128
|
+
when Type::HashShape then "Hash"
|
|
129
|
+
when Type::Constant then constant_class_name(type.value)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
CONSTANT_CLASSES = {
|
|
134
|
+
Integer => "Integer", Float => "Float", String => "String",
|
|
135
|
+
Symbol => "Symbol", Range => "Range",
|
|
136
|
+
TrueClass => "TrueClass", FalseClass => "FalseClass",
|
|
137
|
+
NilClass => "NilClass"
|
|
138
|
+
}.freeze
|
|
139
|
+
private_constant :CONSTANT_CLASSES
|
|
140
|
+
|
|
141
|
+
def constant_class_name(value)
|
|
142
|
+
CONSTANT_CLASSES.each { |klass, name| return name if value.is_a?(klass) }
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def definition_available?(loader, receiver_type, class_name)
|
|
147
|
+
if receiver_type.is_a?(Type::Singleton)
|
|
148
|
+
!loader.singleton_definition(class_name).nil?
|
|
149
|
+
else
|
|
150
|
+
!loader.instance_definition(class_name).nil?
|
|
151
|
+
end
|
|
152
|
+
rescue StandardError
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def lookup_method(loader, receiver_type, class_name, method_name)
|
|
157
|
+
if receiver_type.is_a?(Type::Singleton)
|
|
158
|
+
loader.singleton_method(class_name: class_name, method_name: method_name)
|
|
159
|
+
else
|
|
160
|
+
loader.instance_method(class_name: class_name, method_name: method_name)
|
|
161
|
+
end
|
|
162
|
+
rescue StandardError
|
|
163
|
+
# The loader is best-effort and may raise on malformed
|
|
164
|
+
# RBS. Treat any failure as "method exists" so we do
|
|
165
|
+
# NOT emit a false positive when our knowledge of the
|
|
166
|
+
# receiver class is structurally incomplete.
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Slice 7 phase 11 — wrong-arity diagnostic. Fires when
|
|
171
|
+
# an explicit-receiver `Prism::CallNode` resolves to a
|
|
172
|
+
# method whose declared overloads do not admit the
|
|
173
|
+
# supplied positional argument count. The rule applies
|
|
174
|
+
# ONLY to the simplest overload shape (single overload,
|
|
175
|
+
# no `rest_positionals`, no keyword parameters, no
|
|
176
|
+
# block-required positionals); calls with `*splat`
|
|
177
|
+
# arguments, keyword arguments, or block-pass arguments
|
|
178
|
+
# are silently skipped to avoid false positives. The
|
|
179
|
+
# check piggybacks on the same scope-index lookup used
|
|
180
|
+
# by `undefined_method_diagnostic`; it returns nil
|
|
181
|
+
# when the call's receiver / RBS coverage / call shape
|
|
182
|
+
# disqualifies the rule.
|
|
183
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
184
|
+
def wrong_arity_diagnostic(path, call_node, scope_index)
|
|
185
|
+
return nil if call_node.receiver.nil?
|
|
186
|
+
return nil unless plain_positional_call?(call_node)
|
|
187
|
+
|
|
188
|
+
scope = scope_index[call_node]
|
|
189
|
+
return nil if scope.nil?
|
|
190
|
+
|
|
191
|
+
receiver_type = scope.type_of(call_node.receiver)
|
|
192
|
+
class_name = concrete_class_name(receiver_type)
|
|
193
|
+
return nil if class_name.nil?
|
|
194
|
+
|
|
195
|
+
kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
|
|
196
|
+
return nil if scope.discovered_method?(class_name, call_node.name, kind)
|
|
197
|
+
|
|
198
|
+
loader = scope.environment.rbs_loader
|
|
199
|
+
return nil if loader.nil?
|
|
200
|
+
return nil unless loader.class_known?(class_name)
|
|
201
|
+
return nil unless definition_available?(loader, receiver_type, class_name)
|
|
202
|
+
|
|
203
|
+
method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
|
|
204
|
+
return nil if method_def.nil? || method_def == true
|
|
205
|
+
|
|
206
|
+
arity_envelope = compute_arity_envelope(method_def)
|
|
207
|
+
return nil if arity_envelope.nil?
|
|
208
|
+
|
|
209
|
+
actual = (call_node.arguments&.arguments || []).size
|
|
210
|
+
min, max = arity_envelope
|
|
211
|
+
return nil if actual.between?(min, max)
|
|
212
|
+
|
|
213
|
+
build_arity_diagnostic(path, call_node, class_name, min, max, actual)
|
|
214
|
+
end
|
|
215
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
216
|
+
|
|
217
|
+
def plain_positional_call?(call_node)
|
|
218
|
+
arguments = call_node.arguments
|
|
219
|
+
return true if arguments.nil?
|
|
220
|
+
|
|
221
|
+
arguments.arguments.all? { |arg| simple_positional?(arg) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def simple_positional?(arg)
|
|
225
|
+
return false if arg.is_a?(Prism::SplatNode)
|
|
226
|
+
return false if arg.is_a?(Prism::KeywordHashNode)
|
|
227
|
+
return false if arg.is_a?(Prism::BlockArgumentNode)
|
|
228
|
+
return false if arg.is_a?(Prism::ForwardingArgumentsNode)
|
|
229
|
+
|
|
230
|
+
true
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Returns `[min, max]` positional-argument arity for the
|
|
234
|
+
# method (across all overloads), or nil when the rule
|
|
235
|
+
# does not apply. We disqualify only when the method
|
|
236
|
+
# uses required keyword arguments (which the caller MUST
|
|
237
|
+
# pass at the call site, and our plain-positional check
|
|
238
|
+
# would not have caught) or trailing positionals (rare,
|
|
239
|
+
# complex). `optional_keywords` and `rest_keywords` do
|
|
240
|
+
# NOT affect positional arity. `rest_positionals` raises
|
|
241
|
+
# `max` to `Float::INFINITY`.
|
|
242
|
+
def compute_arity_envelope(method_def)
|
|
243
|
+
mins = []
|
|
244
|
+
maxes = []
|
|
245
|
+
method_def.method_types.each do |mt|
|
|
246
|
+
function = mt.type
|
|
247
|
+
return nil unless arity_eligible?(function)
|
|
248
|
+
|
|
249
|
+
min_arity = function.required_positionals.size
|
|
250
|
+
max_arity =
|
|
251
|
+
if function.rest_positionals
|
|
252
|
+
Float::INFINITY
|
|
253
|
+
else
|
|
254
|
+
min_arity + function.optional_positionals.size
|
|
255
|
+
end
|
|
256
|
+
mins << min_arity
|
|
257
|
+
maxes << max_arity
|
|
258
|
+
end
|
|
259
|
+
return nil if mins.empty?
|
|
260
|
+
|
|
261
|
+
[mins.min, maxes.max]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def arity_eligible?(function)
|
|
265
|
+
function.required_keywords.empty? && function.trailing_positionals.empty?
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Slice 7 phase 14 — nil-receiver diagnostic. Fires when
|
|
269
|
+
# the receiver type is a `Type::Union` containing a
|
|
270
|
+
# nil-bearing member (`Constant[nil]` or
|
|
271
|
+
# `Nominal[NilClass]`) AND the called method does not
|
|
272
|
+
# exist on `NilClass`. This is the canonical "you forgot
|
|
273
|
+
# to nil-check before calling X" signal: the engine has
|
|
274
|
+
# proved that on at least one execution path the receiver
|
|
275
|
+
# is nil, and the call would raise NoMethodError.
|
|
276
|
+
#
|
|
277
|
+
# The rule deliberately ignores receivers that are
|
|
278
|
+
# exactly `Constant[nil]` / `Nominal[NilClass]` (those
|
|
279
|
+
# are already covered by `undefined_method_diagnostic`)
|
|
280
|
+
# and union receivers where every member already
|
|
281
|
+
# disqualifies the call (avoid duplicating the
|
|
282
|
+
# undefined-method diagnostic).
|
|
283
|
+
def nil_receiver_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
284
|
+
return nil if call_node.receiver.nil?
|
|
285
|
+
# Safe-navigation calls (`recv&.method`) already
|
|
286
|
+
# short-circuit on nil at runtime, so a nil-bearing
|
|
287
|
+
# receiver is not a bug for them.
|
|
288
|
+
return nil if call_node.safe_navigation?
|
|
289
|
+
# Restrict to direct local-variable reads. Local
|
|
290
|
+
# narrowing (Slice 6 phase 1) is the only narrowing
|
|
291
|
+
# surface that can prove a guard like
|
|
292
|
+
# `return if x.nil?` removed nil from the union, so
|
|
293
|
+
# firing on chained / method-call receivers would
|
|
294
|
+
# produce false positives we cannot suppress.
|
|
295
|
+
return nil unless call_node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
296
|
+
|
|
297
|
+
scope = scope_index[call_node]
|
|
298
|
+
return nil if scope.nil?
|
|
299
|
+
|
|
300
|
+
receiver_type = scope.type_of(call_node.receiver)
|
|
301
|
+
return nil unless receiver_type.is_a?(Type::Union)
|
|
302
|
+
|
|
303
|
+
loader = scope.environment.rbs_loader
|
|
304
|
+
return nil if loader.nil?
|
|
305
|
+
|
|
306
|
+
return nil unless union_contains_nil?(receiver_type)
|
|
307
|
+
return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, loader, scope)
|
|
308
|
+
return nil if nil_class_has_method?(call_node.name, loader)
|
|
309
|
+
|
|
310
|
+
build_nil_receiver_diagnostic(path, call_node)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def union_contains_nil?(union)
|
|
314
|
+
union.members.any? { |member| nil_member?(member) }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def nil_member?(member)
|
|
318
|
+
(member.is_a?(Type::Constant) && member.value.nil?) ||
|
|
319
|
+
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# The non-nil members must collectively support the
|
|
323
|
+
# method (i.e. for every non-nil member, the method
|
|
324
|
+
# exists on its class via RBS or in-source discovery).
|
|
325
|
+
# Without this guard, the rule would also fire on calls
|
|
326
|
+
# that are unsound on the non-nil branch — that is the
|
|
327
|
+
# `undefined_method_diagnostic` rule's job, and we want
|
|
328
|
+
# exactly one diagnostic per offending call site.
|
|
329
|
+
def union_method_present_on_non_nil?(union, method_name, loader, scope)
|
|
330
|
+
non_nil_members = union.members.reject { |m| nil_member?(m) }
|
|
331
|
+
return false if non_nil_members.empty?
|
|
332
|
+
|
|
333
|
+
non_nil_members.all? { |m| method_present_anywhere?(m, method_name, loader, scope) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def method_present_anywhere?(member, method_name, loader, scope)
|
|
337
|
+
class_name = concrete_class_name(member)
|
|
338
|
+
return true if class_name.nil? # Dynamic / Top / Bot — be permissive.
|
|
339
|
+
return true if scope.discovered_method?(class_name, method_name, :instance)
|
|
340
|
+
return true unless loader.class_known?(class_name)
|
|
341
|
+
return true unless definition_available?(loader, member, class_name)
|
|
342
|
+
|
|
343
|
+
!lookup_method(loader, member, class_name, method_name).nil?
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def nil_class_has_method?(method_name, loader)
|
|
347
|
+
return false unless loader.class_known?("NilClass")
|
|
348
|
+
|
|
349
|
+
definition = loader.instance_definition("NilClass")
|
|
350
|
+
return false if definition.nil?
|
|
351
|
+
|
|
352
|
+
!definition.methods[method_name.to_sym].nil?
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Slice 7 phase 19 — PHPStan-style `dump_type(value)`.
|
|
356
|
+
# When the engine recognises a call to `dump_type` (with
|
|
357
|
+
# any of the supported receiver shapes — implicit self
|
|
358
|
+
# after `include Rigor::Testing`, `Rigor::Testing.dump_type`,
|
|
359
|
+
# or `Rigor.dump_type`), it emits an `:info` diagnostic
|
|
360
|
+
# showing the inferred type of the argument expression.
|
|
361
|
+
# The diagnostic does NOT count toward `Result#error_count`
|
|
362
|
+
# so a fixture peppered with `dump_type` calls still
|
|
363
|
+
# passes `rigor check`.
|
|
364
|
+
def dump_type_diagnostic(path, call_node, scope_index)
|
|
365
|
+
return nil unless rigor_testing_call?(call_node, :dump_type)
|
|
366
|
+
return nil if call_node.arguments.nil? || call_node.arguments.arguments.empty?
|
|
367
|
+
|
|
368
|
+
arg = call_node.arguments.arguments.first
|
|
369
|
+
scope = scope_index[arg] || scope_index[call_node]
|
|
370
|
+
return nil if scope.nil?
|
|
371
|
+
|
|
372
|
+
type = scope.type_of(arg)
|
|
373
|
+
location = call_node.message_loc || call_node.location
|
|
374
|
+
Diagnostic.new(
|
|
375
|
+
path: path,
|
|
376
|
+
line: location.start_line,
|
|
377
|
+
column: location.start_column + 1,
|
|
378
|
+
message: "dump_type: #{type.describe(:short)}",
|
|
379
|
+
severity: :info
|
|
380
|
+
)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Slice 7 phase 19 — PHPStan-style `assert_type("...", value)`.
|
|
384
|
+
# The first argument MUST be a string literal containing
|
|
385
|
+
# the expected `Type#describe(:short)` rendering. When
|
|
386
|
+
# the inferred type's short description does not equal
|
|
387
|
+
# the expected literal, an `:error`-severity diagnostic
|
|
388
|
+
# is emitted; matching calls produce no output. This
|
|
389
|
+
# lets a fixture document its expected types inline:
|
|
390
|
+
# subsequent `rigor check` runs flag any drift.
|
|
391
|
+
def assert_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity
|
|
392
|
+
return nil unless rigor_testing_call?(call_node, :assert_type)
|
|
393
|
+
return nil if call_node.arguments.nil? || call_node.arguments.arguments.size < 2
|
|
394
|
+
|
|
395
|
+
expected_node = call_node.arguments.arguments.first
|
|
396
|
+
return nil unless expected_node.is_a?(Prism::StringNode)
|
|
397
|
+
|
|
398
|
+
value_node = call_node.arguments.arguments[1]
|
|
399
|
+
scope = scope_index[value_node] || scope_index[call_node]
|
|
400
|
+
return nil if scope.nil?
|
|
401
|
+
|
|
402
|
+
actual = scope.type_of(value_node).describe(:short)
|
|
403
|
+
expected = expected_node.unescaped.to_s
|
|
404
|
+
return nil if actual == expected
|
|
405
|
+
|
|
406
|
+
build_assert_type_diagnostic(path, call_node, expected, actual)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Recognises any of:
|
|
410
|
+
# `dump_type(x)` (implicit self after `include Rigor::Testing`)
|
|
411
|
+
# `Testing.dump_type(x)`
|
|
412
|
+
# `Rigor.dump_type(x)`
|
|
413
|
+
# `Rigor::Testing.dump_type(x)`
|
|
414
|
+
# The receiver check is purely structural — we do not
|
|
415
|
+
# consult RBS — because the helpers are no-op stubs the
|
|
416
|
+
# user MAY shadow with their own definition; a name
|
|
417
|
+
# clash is the deliberate trade-off for ergonomic
|
|
418
|
+
# invocation.
|
|
419
|
+
RIGOR_TESTING_RECEIVERS = ["Rigor", "Rigor::Testing", "Testing"].freeze
|
|
420
|
+
private_constant :RIGOR_TESTING_RECEIVERS
|
|
421
|
+
|
|
422
|
+
def rigor_testing_call?(call_node, method_name)
|
|
423
|
+
return false unless call_node.name == method_name
|
|
424
|
+
|
|
425
|
+
receiver = call_node.receiver
|
|
426
|
+
return true if receiver.nil?
|
|
427
|
+
|
|
428
|
+
name = constant_name_of(receiver)
|
|
429
|
+
return false if name.nil?
|
|
430
|
+
|
|
431
|
+
RIGOR_TESTING_RECEIVERS.include?(name)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def constant_name_of(node)
|
|
435
|
+
case node
|
|
436
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
437
|
+
when Prism::ConstantPathNode then render_constant_path(node)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def render_constant_path(node)
|
|
442
|
+
parent = node.parent
|
|
443
|
+
base = constant_name_of(parent)
|
|
444
|
+
return nil if parent && base.nil?
|
|
445
|
+
|
|
446
|
+
parent ? "#{base}::#{node.name}" : node.name.to_s
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def build_assert_type_diagnostic(path, call_node, expected, actual)
|
|
450
|
+
location = call_node.message_loc || call_node.location
|
|
451
|
+
Diagnostic.new(
|
|
452
|
+
path: path,
|
|
453
|
+
line: location.start_line,
|
|
454
|
+
column: location.start_column + 1,
|
|
455
|
+
message: "assert_type mismatch: expected #{expected.inspect}, got #{actual.inspect}",
|
|
456
|
+
severity: :error
|
|
457
|
+
)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def build_nil_receiver_diagnostic(path, call_node)
|
|
461
|
+
location = call_node.message_loc || call_node.location
|
|
462
|
+
Diagnostic.new(
|
|
463
|
+
path: path,
|
|
464
|
+
line: location.start_line,
|
|
465
|
+
column: location.start_column + 1,
|
|
466
|
+
message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
|
|
467
|
+
severity: :error
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# rubocop:disable Metrics/ParameterLists
|
|
472
|
+
def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
|
|
473
|
+
location = call_node.message_loc || call_node.location
|
|
474
|
+
range = min == max ? min.to_s : "#{min}..#{max}"
|
|
475
|
+
method_label = "`#{call_node.name}' on #{class_name}"
|
|
476
|
+
message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
|
|
477
|
+
Diagnostic.new(
|
|
478
|
+
path: path,
|
|
479
|
+
line: location.start_line,
|
|
480
|
+
column: location.start_column + 1,
|
|
481
|
+
message: message,
|
|
482
|
+
severity: :error
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
# rubocop:enable Metrics/ParameterLists
|
|
486
|
+
|
|
487
|
+
def build_undefined_method_diagnostic(path, call_node, receiver_type)
|
|
488
|
+
location = call_node.message_loc || call_node.location
|
|
489
|
+
rendered_receiver = receiver_type.describe
|
|
490
|
+
Diagnostic.new(
|
|
491
|
+
path: path,
|
|
492
|
+
line: location.start_line,
|
|
493
|
+
column: location.start_column + 1,
|
|
494
|
+
message: "undefined method `#{call_node.name}' for #{rendered_receiver}",
|
|
495
|
+
severity: :error
|
|
496
|
+
)
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
# rubocop:enable Metrics/ClassLength
|
|
500
|
+
end
|
|
501
|
+
# rubocop:enable Metrics/ModuleLength
|
|
502
|
+
end
|
|
503
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
class Diagnostic
|
|
6
|
+
attr_reader :path, :line, :column, :message, :severity
|
|
7
|
+
|
|
8
|
+
def initialize(path:, line:, column:, message:, severity: :error)
|
|
9
|
+
@path = path
|
|
10
|
+
@line = line
|
|
11
|
+
@column = column
|
|
12
|
+
@message = message
|
|
13
|
+
@severity = severity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def error?
|
|
17
|
+
severity == :error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
"path" => path,
|
|
23
|
+
"line" => line,
|
|
24
|
+
"column" => column,
|
|
25
|
+
"severity" => severity.to_s,
|
|
26
|
+
"message" => message
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_s
|
|
31
|
+
"#{path}:#{line}:#{column}: #{severity}: #{message}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
# Immutable storage for flow-sensitive facts attached to a Scope snapshot.
|
|
6
|
+
#
|
|
7
|
+
# The first implementation keeps the bucket model deliberately small:
|
|
8
|
+
# callers can record local-binding and relational facts, invalidate all
|
|
9
|
+
# facts that mention a target, and conservatively join two stores by
|
|
10
|
+
# retaining only facts that both incoming edges share.
|
|
11
|
+
class FactStore
|
|
12
|
+
BUCKETS = %i[
|
|
13
|
+
local_binding
|
|
14
|
+
captured_local
|
|
15
|
+
object_content
|
|
16
|
+
global_storage
|
|
17
|
+
dynamic_origin
|
|
18
|
+
relational
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
Target = Data.define(:kind, :name) do
|
|
22
|
+
def self.local(name)
|
|
23
|
+
new(kind: :local, name: name.to_sym)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(kind:, name:)
|
|
27
|
+
super(kind: kind.to_sym, name: name)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Fact = Data.define(:bucket, :target, :predicate, :payload, :polarity, :stability) do
|
|
32
|
+
def initialize(bucket:, target:, predicate:, payload: nil, polarity: :positive, stability: :local_binding)
|
|
33
|
+
bucket = bucket.to_sym
|
|
34
|
+
raise ArgumentError, "unknown fact bucket #{bucket.inspect}" unless BUCKETS.include?(bucket)
|
|
35
|
+
|
|
36
|
+
super(
|
|
37
|
+
bucket: bucket,
|
|
38
|
+
target: target,
|
|
39
|
+
predicate: predicate.to_sym,
|
|
40
|
+
payload: payload,
|
|
41
|
+
polarity: polarity.to_sym,
|
|
42
|
+
stability: stability.to_sym
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :facts
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
def empty
|
|
51
|
+
@empty ||= new
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(facts: [])
|
|
56
|
+
@facts = normalize(facts)
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def empty?
|
|
61
|
+
facts.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def with_fact(fact)
|
|
65
|
+
self.class.new(facts: facts + [fact])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def with_local_fact(name, predicate:, payload: nil, bucket: :local_binding, polarity: :positive)
|
|
69
|
+
with_fact(
|
|
70
|
+
Fact.new(
|
|
71
|
+
bucket: bucket,
|
|
72
|
+
target: Target.local(name),
|
|
73
|
+
predicate: predicate,
|
|
74
|
+
payload: payload,
|
|
75
|
+
polarity: polarity,
|
|
76
|
+
stability: :local_binding
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def facts_for(target: nil, bucket: nil)
|
|
82
|
+
selected_bucket = bucket&.to_sym
|
|
83
|
+
facts.select do |fact|
|
|
84
|
+
(target.nil? || fact_targets(fact).include?(target)) &&
|
|
85
|
+
(selected_bucket.nil? || fact.bucket == selected_bucket)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def invalidate_target(target, buckets: nil)
|
|
90
|
+
selected = buckets&.map(&:to_sym)
|
|
91
|
+
kept = facts.reject do |fact|
|
|
92
|
+
fact_targets(fact).include?(target) && (selected.nil? || selected.include?(fact.bucket))
|
|
93
|
+
end
|
|
94
|
+
return self if kept == facts
|
|
95
|
+
|
|
96
|
+
self.class.new(facts: kept)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def join(other)
|
|
100
|
+
unless other.is_a?(FactStore)
|
|
101
|
+
raise ArgumentError, "join requires a Rigor::Analysis::FactStore, got #{other.class}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
self.class.new(facts: facts.select { |fact| other.facts.include?(fact) })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ==(other)
|
|
108
|
+
other.is_a?(FactStore) && facts == other.facts
|
|
109
|
+
end
|
|
110
|
+
alias eql? ==
|
|
111
|
+
|
|
112
|
+
def hash
|
|
113
|
+
[FactStore, facts].hash
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def normalize(raw_facts)
|
|
119
|
+
unique = []
|
|
120
|
+
raw_facts.each do |fact|
|
|
121
|
+
raise ArgumentError, "expected Rigor::Analysis::FactStore::Fact, got #{fact.class}" unless fact.is_a?(Fact)
|
|
122
|
+
|
|
123
|
+
unique << fact unless unique.include?(fact)
|
|
124
|
+
end
|
|
125
|
+
unique.freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fact_targets(fact)
|
|
129
|
+
Array(fact.target)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :diagnostics
|
|
7
|
+
|
|
8
|
+
def initialize(diagnostics: [])
|
|
9
|
+
@diagnostics = diagnostics
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def success?
|
|
13
|
+
diagnostics.none?(&:error?)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def error_count
|
|
17
|
+
diagnostics.count(&:error?)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
"success" => success?,
|
|
23
|
+
"error_count" => error_count,
|
|
24
|
+
"diagnostics" => diagnostics.map(&:to_h)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|