rigortype 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "anchor"
6
+ require_relative "slot"
7
+ require_relative "parameters"
8
+ require_relative "../inference/def_return_typer"
9
+ require_relative "../inference/origin_lookup"
10
+ require_relative "../sig_gen/type_elaborator"
11
+
12
+ module Rigor
13
+ module Lens
14
+ # Reads one analysed file into sites and omissions (docs/internal-spec/lens.md § Files, § Sites). Runs inside
15
+ # the check run, on the seeded scope index the check rules read, so a body return is the type `check` gives
16
+ # that body. Every declaration the walk meets is either a site or an omission; none is dropped.
17
+ class FileReader # rubocop:disable Metrics/ClassLength
18
+ # A site plus what the member pass needs from it: the declaration node and the contract comments above it.
19
+ Site = Data.define(:data, :node, :contract_comments)
20
+ Result = Data.define(:sites, :omissions, :digest)
21
+ # The running visibility of one class body: Ruby's default for its later `def`s, and whether a toggle the
22
+ # walk cannot place (under an `if`, a `begin`, a block) leaves that default unknown.
23
+ # A `detached` body stands for code that is not the class body (a block, a method body, a superclass
24
+ # expression, an omitted class): its toggles and named calls stay local and change no site.
25
+ Body = Struct.new(:current, :uncertain, :detached)
26
+
27
+ ATTRIBUTE_CALLS = { attr_reader: "reader", attr_writer: "writer", attr_accessor: "accessor" }.freeze
28
+ OMITTED_CALLS = {
29
+ alias_method: "alias", define_method: "define_method", module_function: "module_function",
30
+ refine: "refine"
31
+ }.freeze
32
+ VISIBILITY_CALLS = %i[private protected public].freeze
33
+ CLASS_VISIBILITY_CALLS = { private_class_method: "private", public_class_method: "public" }.freeze
34
+ BLOCK_NODES = [Prism::BlockNode, Prism::LambdaNode].freeze
35
+ CONSTANT_WRITES = [
36
+ Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode,
37
+ Prism::ConstantPathOrWriteNode, Prism::ConstantAndWriteNode, Prism::ConstantPathAndWriteNode,
38
+ Prism::ConstantOperatorWriteNode, Prism::ConstantPathOperatorWriteNode
39
+ ].freeze
40
+
41
+ # @param path — the file, relative to the project root
42
+ # @param spell — callable `(type, owner) -> String` spelling a type as sig-gen writes it
43
+ def initialize(path:, parse_result:, index:, spell:)
44
+ @path = path
45
+ @root = parse_result.value
46
+ @source = parse_result.source.source
47
+ @index = index
48
+ @spell = spell
49
+ @anchor = Anchor.new(@source, parse_result.comments)
50
+ @bodies = []
51
+ @node_visibility = {}.compare_by_identity
52
+ @sites = []
53
+ @omissions = []
54
+ end
55
+
56
+ def read
57
+ walk(@root, [], singleton: false, in_block: false)
58
+ Result.new(sites: @sites, omissions: @omissions, digest: Lens.digest(@source.b))
59
+ end
60
+
61
+ private
62
+
63
+ def walk(node, nesting, singleton:, in_block:)
64
+ case node
65
+ when Prism::ClassNode, Prism::ModuleNode then walk_namespace(node, nesting, in_block)
66
+ when Prism::SingletonClassNode then walk_singleton_class(node, nesting, singleton, in_block)
67
+ when Prism::DefNode then read_def(node, nesting, singleton, in_block)
68
+ when Prism::CallNode then walk_call(node, nesting, singleton, in_block)
69
+ when Prism::AliasMethodNode
70
+ omit("alias", node.new_name.slice.delete_prefix(":"), node, owner: nesting.last, singleton: singleton)
71
+ when Prism::MultiWriteNode then walk_multi_write(node, nesting, singleton, in_block)
72
+ when *CONSTANT_WRITES then walk_constant(node, nesting)
73
+ else walk_children(node, nesting, singleton, in_block)
74
+ end
75
+ end
76
+
77
+ def walk_children(node, nesting, singleton, in_block)
78
+ node.compact_child_nodes.each do |child|
79
+ if BLOCK_NODES.include?(child.class)
80
+ detached { walk(child, nesting, singleton: singleton, in_block: true) }
81
+ else
82
+ walk(child, nesting, singleton: singleton, in_block: in_block)
83
+ end
84
+ end
85
+ end
86
+
87
+ def detached
88
+ @bodies.push(Body.new("public", false, true))
89
+ yield
90
+ ensure
91
+ @bodies.pop
92
+ end
93
+
94
+ def walk_namespace(node, nesting, in_block)
95
+ kind = node.is_a?(Prism::ClassNode) ? "class" : "module"
96
+ name = qualify(nesting, node.constant_path)
97
+ if node.is_a?(Prism::ClassNode) && node.superclass
98
+ detached { walk(node.superclass, nesting, singleton: false, in_block: true) }
99
+ end
100
+ if in_block || name.nil?
101
+ omit(kind, name || node.constant_path.slice, node)
102
+ detached { walk(node.body, nesting, singleton: false, in_block: true) } if node.body
103
+ return
104
+ end
105
+
106
+ add_site(node, { "kind" => kind, "name" => name, "owner" => nil, "scope" => nil, "attribute" => nil,
107
+ "members" => [], "visibility" => nil, "params" => nil, "block" => nil,
108
+ "body_return" => nil })
109
+ walk_body(node.body, nesting + [name], singleton: false)
110
+ end
111
+
112
+ # `class << self` directly in a class or module body opens its singleton class. Any other `class << x`, one
113
+ # nested in another, or one at the top level (which opens `main`'s) is not modelled.
114
+ def walk_singleton_class(node, nesting, singleton, in_block)
115
+ if in_block || singleton || nesting.empty? || !node.expression.is_a?(Prism::SelfNode)
116
+ omit("singleton_class", nil, node)
117
+ return detached { walk(node.body, nesting, singleton: singleton, in_block: true) } if node.body
118
+ end
119
+
120
+ walk_body(node.body, nesting, singleton: true)
121
+ end
122
+
123
+ # A class body's statements, tracking the running visibility and `module_function`. A bare `module_function`
124
+ # makes every later `def` of the body a method on both sides: one site, two members, which revision 1 does
125
+ # not model.
126
+ def walk_body(body, nesting, singleton:)
127
+ return if body.nil?
128
+
129
+ @bodies.push(Body.new("public", false, false))
130
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
131
+ module_function_on = false
132
+ statements.each do |child|
133
+ if module_function_on && child.is_a?(Prism::DefNode)
134
+ omit("module_function", child.name.to_s, child, owner: nesting.last, singleton: singleton)
135
+ next
136
+ end
137
+ module_function_on = true if bare_call?(child, :module_function)
138
+ next if toggle_visibility?(child)
139
+
140
+ visibility_call(child, nesting, singleton, direct: true) if visibility_call?(child)
141
+ walk(child, nesting, singleton: singleton, in_block: false)
142
+ end
143
+ ensure
144
+ @bodies.pop if body
145
+ end
146
+
147
+ # A bare `private` / `protected` / `public` written directly in the body sets the default for later defs.
148
+ def toggle_visibility?(child)
149
+ return false unless VISIBILITY_CALLS.any? { |name| bare_call?(child, name) }
150
+
151
+ @bodies.last.current = child.name.to_s
152
+ @bodies.last.uncertain = false
153
+ true
154
+ end
155
+
156
+ def walk_call(node, nesting, singleton, in_block)
157
+ if node.receiver.nil? && ATTRIBUTE_CALLS.key?(node.name)
158
+ read_attribute(node, nesting, singleton, in_block)
159
+ elsif node.receiver.nil? && OMITTED_CALLS.key?(node.name)
160
+ return if bare_call?(node, :module_function)
161
+
162
+ omit(OMITTED_CALLS.fetch(node.name), first_argument_name(node), node,
163
+ owner: node.name == :refine ? nil : nesting.last, singleton: singleton)
164
+ walk_children(node, nesting, singleton, true) unless node.name == :refine
165
+ else
166
+ @visited_visibility_calls ||= {}.compare_by_identity
167
+ if visibility_call?(node) && !@visited_visibility_calls.key?(node)
168
+ visibility_call(node, nesting, singleton, direct: false)
169
+ end
170
+ walk_children(node, nesting, singleton, in_block)
171
+ end
172
+ end
173
+
174
+ def visibility_call?(node)
175
+ node.is_a?(Prism::CallNode) && node.receiver.nil? &&
176
+ (VISIBILITY_CALLS.include?(node.name) || CLASS_VISIBILITY_CALLS.key?(node.name))
177
+ end
178
+
179
+ # `private :x`, `private %i[x]`, `private def x`, `private attr_reader :x`, `private_class_method :x`, and a
180
+ # bare toggle the walk cannot place in the body's statement list. A named call changes members only when it
181
+ # is written directly in the body (`direct`); one under an `if`, a block or a method body runs at a time
182
+ # Rigor does not know, so the members it names read unknown.
183
+ def visibility_call(node, nesting, singleton, direct:)
184
+ @visited_visibility_calls ||= {}.compare_by_identity
185
+ @visited_visibility_calls[node] = true
186
+ direct &&= !@bodies.last&.detached
187
+ if VISIBILITY_CALLS.include?(node.name)
188
+ return mark_uncertain if node.arguments.nil?
189
+
190
+ apply_visibility(node, node.name.to_s, nesting.last, singleton, direct)
191
+ else
192
+ apply_visibility(node, CLASS_VISIBILITY_CALLS.fetch(node.name), nesting.last, true, direct)
193
+ end
194
+ end
195
+
196
+ def mark_uncertain
197
+ @bodies.last&.uncertain = true
198
+ end
199
+
200
+ def apply_visibility(node, visibility, owner, singleton, direct)
201
+ scope = singleton ? "singleton" : "instance"
202
+ node.arguments.arguments.each do |argument|
203
+ names = argument_names(argument)
204
+ if wrapped_declaration?(argument, singleton)
205
+ @node_visibility[argument] = visibility
206
+ elsif argument.is_a?(Prism::DefNode)
207
+ # `private def self.x`: Ruby looks `x` up among instance methods and rejects it, so the result is unknown.
208
+ @node_visibility[argument] = nil
209
+ elsif names.nil?
210
+ # `private *NAMES`: which members change is unknown.
211
+ unknown_visibility(owner, scope)
212
+ else
213
+ names.each { |name| name_visibility(owner, scope, name, direct ? visibility : nil) }
214
+ end
215
+ end
216
+ end
217
+
218
+ # A `def` or `attr_*` the visibility call wraps. `private def self.x` is not one: `private` looks the
219
+ # name up among instance methods.
220
+ def wrapped_declaration?(argument, singleton)
221
+ case argument
222
+ when Prism::DefNode then argument.receiver.nil? || singleton
223
+ when Prism::CallNode then argument.receiver.nil? && ATTRIBUTE_CALLS.key?(argument.name)
224
+ else false
225
+ end
226
+ end
227
+
228
+ # The names a visibility call's argument lists, or nil when it is not a literal list of them.
229
+ def argument_names(argument)
230
+ case argument
231
+ when Prism::SymbolNode, Prism::StringNode then [argument.unescaped]
232
+ when Prism::ArrayNode
233
+ names = argument.elements.map { |element| argument_names(element)&.first }
234
+ names.include?(nil) ? nil : names
235
+ end
236
+ end
237
+
238
+ def unknown_visibility(owner, scope)
239
+ @sites.each do |site|
240
+ site.data["visibility"] = nil if site.data["owner"] == owner && site.data["scope"] == scope
241
+ end
242
+ mark_uncertain
243
+ end
244
+
245
+ # `private :x` changes the visibility of the members already defined under that name in this file.
246
+ def name_visibility(owner, scope, name, visibility)
247
+ @sites.each do |site|
248
+ data = site.data
249
+ next unless data["owner"] == owner && data["scope"] == scope && data["members"].include?(name)
250
+
251
+ data["visibility"] = data["members"] == [name] ? visibility : nil
252
+ end
253
+ end
254
+
255
+ def walk_multi_write(node, nesting, singleton, in_block)
256
+ (node.lefts + [node.rest&.expression].compact + node.rights).each do |target|
257
+ next unless target.is_a?(Prism::ConstantTargetNode) || target.is_a?(Prism::ConstantPathTargetNode)
258
+
259
+ omit("constant", qualify(nesting, target) || target.slice, target)
260
+ end
261
+ walk(node.value, nesting, singleton: singleton, in_block: in_block)
262
+ end
263
+
264
+ def walk_constant(node, nesting)
265
+ target = node.respond_to?(:target) ? node.target : node
266
+ name = node.respond_to?(:target) ? qualify(nesting, node.target) : qualify_name(nesting, node.name.to_s)
267
+ kind = case data_or_struct(node.value)
268
+ when :data then "data_class"
269
+ when :struct then "struct_class"
270
+ else "constant"
271
+ end
272
+ omit(kind, name || target.slice, node)
273
+ walk(node.value, nesting, singleton: false, in_block: true)
274
+ end
275
+
276
+ def read_def(node, nesting, singleton, in_block)
277
+ scope = def_scope(node, nesting, singleton)
278
+ if in_block || scope.nil?
279
+ omit("method", node.name.to_s, node)
280
+ else
281
+ owner = nesting.last || "Object"
282
+ data = { "kind" => "method", "name" => node.name.to_s, "owner" => owner, "scope" => scope.to_s,
283
+ "attribute" => nil, "members" => [node.name.to_s],
284
+ "visibility" => visibility(node, nesting, scope) }
285
+ params, block, = Parameters.read(node.parameters, yields: Parameters.yields?(node.body))
286
+ shape_params, shape_block = Parameters.shape(params, block)
287
+ data.merge!("params" => shape_params, "block" => shape_block,
288
+ "body_return" => body_return(node, owner, scope))
289
+ add_site(node, data)
290
+ end
291
+ # A declaration inside a method body runs only when the method does: not modelled.
292
+ detached { walk(node.body, nesting, singleton: singleton, in_block: true) } if node.body
293
+ end
294
+
295
+ def read_attribute(node, nesting, singleton, in_block)
296
+ arguments = node.arguments&.arguments || []
297
+ names = arguments.map { |arg| arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode) ? arg.unescaped : nil }
298
+ if in_block || nesting.empty? || names.empty? || names.include?(nil)
299
+ return omit("attribute", names.compact.first, node)
300
+ end
301
+
302
+ owner = nesting.last
303
+ scope = singleton ? :singleton : :instance
304
+ attribute = ATTRIBUTE_CALLS.fetch(node.name)
305
+ names.each_with_index do |name, ordinal|
306
+ add_site(node, { "kind" => "attribute", "name" => name, "owner" => owner, "scope" => scope.to_s,
307
+ "attribute" => attribute, "members" => attribute_members(name, attribute),
308
+ "visibility" => visibility(node, nesting, scope),
309
+ "params" => nil, "block" => nil, "body_return" => nil }, ordinal: ordinal)
310
+ end
311
+ end
312
+
313
+ def add_site(node, data, ordinal: 0)
314
+ location = node.location
315
+ anchor = @anchor.for_span(location.start_offset, location.end_offset, location.start_line,
316
+ location.end_line)
317
+ key = "#{@path}:#{location.start_offset}-#{location.end_offset}:#{ordinal}"
318
+ tail = { "ordinal" => ordinal, "span" => Lens.span(location),
319
+ "contract_span" => Lens.contract_span(anchor.contract_comments), "anchor" => anchor.token,
320
+ "contract" => nil }
321
+ @sites << Site.new(data: { "key" => key }.merge(data).merge(tail), node: node,
322
+ contract_comments: anchor.contract_comments)
323
+ end
324
+
325
+ # `owner` / `singleton` name the member the declaration would define, when Rigor can tell.
326
+ def omit(kind, name, node, owner: nil, singleton: false)
327
+ member = owner && name
328
+ @omissions << { "kind" => kind, "owner" => member ? owner : nil,
329
+ "scope" => member ? scope_name(singleton) : nil, "name" => name,
330
+ "span" => Lens.span(node.location), "reason" => "unsupported_kind" }
331
+ nil
332
+ end
333
+
334
+ def scope_name(singleton)
335
+ singleton ? "singleton" : "instance"
336
+ end
337
+
338
+ def attribute_members(name, attribute)
339
+ case attribute
340
+ when "reader" then [name]
341
+ when "writer" then ["#{name}="]
342
+ else [name, "#{name}="]
343
+ end
344
+ end
345
+
346
+ # The member's visibility as Ruby gives it: a `private def` / `private attr_reader` wrapper, else the running
347
+ # default of the body (unknown after a toggle the walk could not place). A class body's toggles do not reach
348
+ # a `def self.x`. A later `private :x` revises it ({#name_visibility}). A top-level `def` is private.
349
+ def visibility(node, nesting, scope)
350
+ return "private" if nesting.empty?
351
+ return @node_visibility[node] if @node_visibility.key?(node)
352
+ return "public" if scope == :singleton && node.is_a?(Prism::DefNode) && node.receiver
353
+
354
+ body = @bodies.last
355
+ return nil if body.nil? || body.uncertain
356
+
357
+ body.current
358
+ end
359
+
360
+ # `def x` in a class body is an instance method, in `class << self` a singleton one; `def self.x` is a
361
+ # singleton method of a class body. A `def self.x` inside `class << self`, a top-level `def self.x`, and any
362
+ # other receiver define methods revision 1 does not model.
363
+ def def_scope(node, nesting, singleton)
364
+ return (singleton ? :singleton : :instance) if node.receiver.nil?
365
+ return nil unless node.receiver.is_a?(Prism::SelfNode)
366
+ return nil if singleton || nesting.empty?
367
+
368
+ :singleton
369
+ end
370
+
371
+ # The body's return under the seeded index, with its parameters as declared (§ Sites).
372
+ def body_return(node, owner, scope)
373
+ return Slot.typed("void", "inferred") if scope == :instance && node.name == :initialize
374
+ return Slot.typed("nil", "inferred") if node.body.nil?
375
+
376
+ type = Inference::DefReturnTyper.call(node, @index)
377
+ return Slot.unavailable("not_computed") if type.nil?
378
+ return Slot.dynamic("inferred", return_cause(node)) if type.is_a?(Type::Dynamic)
379
+
380
+ spelled = @spell.call(type, owner)
381
+ spelled == "untyped" ? Slot.dynamic("inferred", return_cause(node)) : Slot.typed(spelled, "inferred")
382
+ rescue StandardError
383
+ Slot.unavailable("not_computed")
384
+ end
385
+
386
+ def return_cause(node)
387
+ last = Inference::DefReturnTyper.body_last_expression(node.body)
388
+ scope = last && (@index[last] || @index[node.body])
389
+ scope && Inference::OriginLookup.origin_for(scope, last)
390
+ end
391
+
392
+ # The qualified name a constant path declares under `nesting`, or nil when it is not a plain constant path
393
+ # (`self::Foo`, `expr::Foo`).
394
+ def qualify(nesting, path)
395
+ case path
396
+ when Prism::ConstantReadNode, Prism::ConstantTargetNode then qualify_name(nesting, path.name.to_s)
397
+ when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
398
+ return path.name.to_s if path.parent.nil?
399
+
400
+ parent = qualify(nesting, path.parent)
401
+ parent && "#{parent}::#{path.name}"
402
+ end
403
+ end
404
+
405
+ def qualify_name(nesting, name)
406
+ nesting.empty? ? name : "#{nesting.last}::#{name}"
407
+ end
408
+
409
+ def bare_call?(node, name)
410
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == name && node.arguments.nil? &&
411
+ node.block.nil?
412
+ end
413
+
414
+ def first_argument_name(node)
415
+ argument = node.arguments&.arguments&.first
416
+ case argument
417
+ when Prism::SymbolNode, Prism::StringNode then argument.unescaped
418
+ when Prism::DefNode then argument.name.to_s
419
+ end
420
+ end
421
+
422
+ def data_or_struct(value)
423
+ return nil unless value.is_a?(Prism::CallNode) && value.receiver
424
+
425
+ receiver = value.receiver.slice.delete_prefix("::")
426
+ return :data if receiver == "Data" && value.name == :define
427
+ return :struct if receiver == "Struct" && value.name == :new
428
+
429
+ nil
430
+ end
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slot"
4
+ require_relative "parameters"
5
+
6
+ module Rigor
7
+ module Lens
8
+ # Groups the sites of the requested files into members and fills their signatures (docs/internal-spec/lens.md
9
+ # § Members). A member is one `(owner, scope, Ruby method name)`; an `attr_accessor :a` site defines `a` and
10
+ # `a=`. An inferred slot comes from a site's body only when the member has exactly one project definition.
11
+ class Members
12
+ # @param sites — every {FileReader::Site} of the requested files
13
+ # @param omissions — every omission of the requested files
14
+ # @param contracts — `{[owner, scope, name] => ContractReader::Contract or nil}`
15
+ # @param elsewhere — callable `(owner, scope, name) -> [{path, line}]`: other project files defining it
16
+ # @param site_paths — `{site key => absolute path of its file}`, to match an inline contract's buffer
17
+ def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:)
18
+ @sites = sites
19
+ @omissions = omissions
20
+ @contracts = contracts
21
+ @elsewhere = elsewhere
22
+ @site_paths = site_paths
23
+ end
24
+
25
+ def build
26
+ groups = Hash.new { |hash, key| hash[key] = [] }
27
+ @sites.each do |site|
28
+ site.data["members"].each { |name| groups[[site.data["owner"], site.data["scope"], name]] << site }
29
+ end
30
+ result = groups.sort_by { |key, _| key }.map { |key, sites| member(key, sites) }
31
+ mark_inline_sites
32
+ result
33
+ end
34
+
35
+ private
36
+
37
+ def member(key, sites)
38
+ owner, scope, name = key
39
+ contract = @contracts[key]
40
+ also = @elsewhere.call(owner, scope, name)
41
+ omitted = @omissions.count { |omission| [omission["owner"], omission["scope"], omission["name"]] == key }
42
+ single = sites.size == 1 && omitted.zero? && also.empty? ? sites.first : nil
43
+ { "owner" => owner, "scope" => scope, "name" => name, "kind" => member_kind(sites),
44
+ "visibility" => common_visibility(sites), "sites" => sites.map { |site| site.data["key"] },
45
+ "also_defined_at" => also, "basis" => basis(contract, single),
46
+ "overloads" => overloads(contract, sites.first, name, single) }
47
+ end
48
+
49
+ # A member a `def` defines anywhere in the requested files is a method, whatever `attr_*` also names it.
50
+ def member_kind(sites)
51
+ sites.any? { |site| site.data["kind"] == "method" } ? "method" : "attribute"
52
+ end
53
+
54
+ def basis(contract, single)
55
+ return "contract" if contract
56
+ return "single_site" if single
57
+
58
+ "contested"
59
+ end
60
+
61
+ def common_visibility(sites)
62
+ values = sites.map { |site| site.data["visibility"] }.uniq
63
+ values.size == 1 ? values.first : nil
64
+ end
65
+
66
+ # A contested member without a contract has no overloads: its definitions may differ in arity, and each
67
+ # method site carries its own parameter list.
68
+ def overloads(contract, site, name, single)
69
+ return [] if contract.nil? && single.nil?
70
+
71
+ overloads = contract ? contract_overloads(contract, single) : [body_overload(site, name, single)]
72
+ overloads.map { |overload| overload.merge("rbs" => rbs_unless_unavailable(overload)) }
73
+ end
74
+
75
+ # A contract's overloads. A defaulted return (`inferred-return`) is inferred as for a member without a
76
+ # contract, and its `untyped` parameters are unknown: Rigor cannot tell an authored `untyped` from a
77
+ # defaulted one.
78
+ def contract_overloads(contract, single)
79
+ contract.overloads.map do |overload|
80
+ next overload.except("return_rbs") unless contract.return_inferred
81
+
82
+ slot = inferred_return(single)
83
+ params = overload["params"]&.map do |param|
84
+ param["type"]["type"] == "untyped" ? param.merge("type" => Slot.unavailable("not_computed")) : param
85
+ end
86
+ rbs = overload["rbs"]
87
+ suffix = overload["return_rbs"]
88
+ rbs = rbs.delete_suffix(suffix) + return_spelling(slot) if rbs && suffix && rbs.end_with?(suffix)
89
+ overload.except("return_rbs").merge("rbs" => rbs, "params" => params, "return" => slot)
90
+ end
91
+ end
92
+
93
+ # A member with no contract: its parameters as written, each `untyped` with ADR-75's undeclared-parameter
94
+ # cause, and the return from its one body.
95
+ def body_overload(site, name, single)
96
+ return attribute_overload(name, single) if site.data["kind"] == "attribute"
97
+
98
+ params, block, head = Parameters.read(site.node.parameters, yields: Parameters.yields?(site.node.body))
99
+ slot = inferred_return(single)
100
+ block_rbs = block ? " ?{ (?) -> untyped }" : ""
101
+ { "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
102
+ "return" => slot }
103
+ end
104
+
105
+ # An attribute without a contract: Rigor does not report the ivar's type in revision 1.
106
+ def attribute_overload(name, _single)
107
+ unknown = Slot.unavailable("not_computed")
108
+ if name.end_with?("=")
109
+ param = { "name" => nil, "kind" => "required", "type" => Slot.unavailable("not_computed") }
110
+ { "rbs" => nil, "params" => [param], "block" => nil, "return" => unknown }
111
+ else
112
+ { "rbs" => nil, "params" => [], "block" => nil, "return" => unknown }
113
+ end
114
+ end
115
+
116
+ def inferred_return(single)
117
+ return Slot.unavailable("contested") unless single
118
+
119
+ body = single.data["body_return"]
120
+ return Slot.unavailable("not_computed") if body.nil?
121
+ return body if Slot.unavailable?(body)
122
+
123
+ body.merge("from_site" => single.data["key"])
124
+ end
125
+
126
+ def return_spelling(slot)
127
+ spelled = Slot.rbs_spelling(slot)
128
+ spelled.include?(" | ") ? "(#{spelled})" : spelled
129
+ end
130
+
131
+ # `rbs` is null when any slot of the overload is unavailable: an `untyped` there would read as an assertion.
132
+ def rbs_unless_unavailable(overload)
133
+ slots = [overload["return"], overload.dig("block", "type")] +
134
+ (overload["params"] || []).map { |param| param["type"] }
135
+ slots.any? { |slot| Slot.unavailable?(slot) } ? nil : overload["rbs"]
136
+ end
137
+
138
+ # A site reads `contract: inline` when its own contract comments produced an inline declaration of a member
139
+ # it defines.
140
+ def mark_inline_sites
141
+ @sites.each do |site|
142
+ path = @site_paths.fetch(site.data["key"])
143
+ asserted = site.contract_comments.any? { |comment| !comment.location.slice.match?(/\A#\s*@extrbs\b/) }
144
+ inline = asserted && site.data["members"].any? do |name|
145
+ contract = @contracts[[site.data["owner"], site.data["scope"], name]]
146
+ contract&.provenance == "inline" && contract.buffers.any? { |buffer| buffer.end_with?(":#{path}") }
147
+ end
148
+ site.data["contract"] = inline ? "inline" : nil
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "slot"
6
+
7
+ module Rigor
8
+ module Lens
9
+ # A `def`'s parameter list as slots and as the RBS head sig-gen would write for an undeclared method.
10
+ module Parameters
11
+ module_function
12
+
13
+ UNDECLARED = "inferred_return_untyped"
14
+
15
+ # [params (nil for an argument-forwarding list), block or nil, "(…)" head]. A definition takes a block when
16
+ # it declares `&blk` or `yields` (its body yields).
17
+ def read(node, yields: false)
18
+ block = yields ? undeclared_block : nil
19
+ return [[], block, "()"] if node.nil?
20
+ return [nil, block, "(?)"] if node.keyword_rest.is_a?(Prism::ForwardingParameterNode)
21
+
22
+ entries = entries(node)
23
+ params = entries.map { |kind, name| { "name" => name&.to_s, "kind" => kind, "type" => undeclared } }
24
+ block = undeclared_block if node.block
25
+ [params, block, "(#{entries.map { |kind, name| spell(kind, name) }.join(', ')})"]
26
+ end
27
+
28
+ # `[kind, name]` per parameter, in declaration order.
29
+ def entries(node)
30
+ node.requireds.map { |param| ["required", name_of(param)] } +
31
+ node.optionals.map { |param| ["optional", param.name] } +
32
+ rest(node.rest) +
33
+ node.posts.map { |param| ["post", name_of(param)] } +
34
+ node.keywords.map { |param| [keyword_kind(param), param.name] } +
35
+ keyword_rest(node.keyword_rest)
36
+ end
37
+
38
+ def undeclared
39
+ Slot.dynamic("inferred", UNDECLARED)
40
+ end
41
+
42
+ def undeclared_block
43
+ { "required" => false, "type" => undeclared }
44
+ end
45
+
46
+ # Whether a `def` body yields, outside the bodies whose `yield` belongs elsewhere: a nested `def`, a lambda,
47
+ # a `define_method` block.
48
+ def yields?(node)
49
+ return false if node.nil?
50
+ return true if node.is_a?(Prism::YieldNode)
51
+ return false if node.is_a?(Prism::DefNode) || node.is_a?(Prism::LambdaNode)
52
+ return false if node.is_a?(Prism::CallNode) && node.name == :define_method && node.receiver.nil?
53
+
54
+ node.compact_child_nodes.any? { |child| yields?(child) }
55
+ end
56
+
57
+ # The shape of a parameter list: names and kinds, with every type null (lens.md § Sites).
58
+ def shape(params, block)
59
+ [params&.map { |param| param.merge("type" => nil) }, block&.merge("type" => nil)]
60
+ end
61
+
62
+ def name_of(param)
63
+ param.respond_to?(:name) ? param.name : nil
64
+ end
65
+
66
+ def keyword_kind(param)
67
+ param.is_a?(Prism::RequiredKeywordParameterNode) ? "keyreq" : "key"
68
+ end
69
+
70
+ def rest(node)
71
+ node.is_a?(Prism::RestParameterNode) ? [["rest", node.name]] : []
72
+ end
73
+
74
+ def keyword_rest(node)
75
+ node.is_a?(Prism::KeywordRestParameterNode) ? [["keyrest", node.name]] : []
76
+ end
77
+
78
+ def spell(kind, name)
79
+ label = name ? " #{name}" : ""
80
+ case kind
81
+ when "required", "post" then "untyped#{label}"
82
+ when "optional" then "?untyped#{label}"
83
+ when "rest" then "*untyped#{label}"
84
+ when "keyreq" then "#{name}: untyped"
85
+ when "key" then "?#{name}: untyped"
86
+ when "keyrest" then "**untyped#{label}"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end