rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -0,0 +1,203 @@
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
+ Members.scopes_of(site.data).each do |scope|
29
+ site.data["members"].each { |name| groups[[site.data["owner"], scope, name]] << site }
30
+ end
31
+ end
32
+ @groups = groups
33
+ result = groups.sort_by { |key, _| key }.map { |key, sites| member(key, sites) }
34
+ mark_inline_sites
35
+ result
36
+ end
37
+
38
+ # The scopes a site's members live in: a module function (`module_function`) defines a private instance method
39
+ # and a public singleton copy from one `def`.
40
+ def self.scopes_of(data)
41
+ data["module_function"] ? %w[instance singleton] : [data["scope"]]
42
+ end
43
+
44
+ private
45
+
46
+ def member(key, sites)
47
+ owner, scope, name = key
48
+ contract = @contracts[key]
49
+ also = @elsewhere.call(owner, scope, name)
50
+ omitted = @omissions.count do |omission|
51
+ [omission["owner"], omission["scope"]] == [owner, scope] && [name, nil].include?(omission["name"])
52
+ end
53
+ single = sites.size == 1 && omitted.zero? && also.empty? ? sites.first : nil
54
+ { "owner" => owner, "scope" => scope, "name" => name, "kind" => member_kind(sites),
55
+ "visibility" => common_visibility(sites, scope), "sites" => sites.map { |site| site.data["key"] },
56
+ "also_defined_at" => also, "basis" => basis(contract, single),
57
+ "overloads" => overloads(contract, sites.first, name, single, scope) }
58
+ end
59
+
60
+ # A member a `def` defines anywhere in the requested files is a method, whatever `attr_*` also names it.
61
+ def member_kind(sites)
62
+ sites.any? { |site| site.data["kind"] == "method" } ? "method" : "attribute"
63
+ end
64
+
65
+ def basis(contract, single)
66
+ return "contract" if contract
67
+ return "single_site" if single
68
+
69
+ "contested"
70
+ end
71
+
72
+ # A module function's singleton copy is public; its instance method carries the site's (private) visibility.
73
+ def common_visibility(sites, scope)
74
+ values = sites.map do |site|
75
+ copy = site.data["module_function"] && scope == "singleton"
76
+ copy ? site.data["singleton_visibility"] : site.data["visibility"]
77
+ end.uniq
78
+ values.size == 1 ? values.first : nil
79
+ end
80
+
81
+ # A contested member without a contract has no overloads: its definitions may differ in arity, and each
82
+ # method site carries its own parameter list.
83
+ def overloads(contract, site, name, single, scope)
84
+ return [] if contract.nil? && single.nil?
85
+
86
+ overloads = contract ? contract_overloads(contract, single, scope) : [body_overload(site, name, single, scope)]
87
+ overloads.map { |overload| overload.merge("rbs" => rbs_unless_unavailable(overload)) }
88
+ end
89
+
90
+ # A contract's overloads. A defaulted return (`inferred-return`) is inferred as for a member without a
91
+ # contract, and its `untyped` parameters are unknown: Rigor cannot tell an authored `untyped` from a
92
+ # defaulted one.
93
+ def contract_overloads(contract, single, scope)
94
+ contract.overloads.map do |overload|
95
+ next overload.except("return_rbs") unless contract.return_inferred
96
+
97
+ slot = inferred_return(single, scope)
98
+ params = overload["params"]&.map do |param|
99
+ param["type"]["type"] == "untyped" ? param.merge("type" => Slot.unavailable("not_computed")) : param
100
+ end
101
+ rbs = overload["rbs"]
102
+ suffix = overload["return_rbs"]
103
+ rbs = rbs.delete_suffix(suffix) + return_spelling(slot) if rbs && suffix && rbs.end_with?(suffix)
104
+ overload.except("return_rbs").merge("rbs" => rbs, "params" => params, "return" => slot)
105
+ end
106
+ end
107
+
108
+ # A member with no contract: its parameters as written, each `untyped` with ADR-75's undeclared-parameter
109
+ # cause, and the return from its one body.
110
+ def body_overload(site, name, single, scope)
111
+ return attribute_overload(name, single) if site.data["kind"] == "attribute"
112
+
113
+ node = site.data["alias_of"] ? resolved_alias(site) : site.node
114
+ return unknown_overload if node.nil?
115
+
116
+ params, block, head = Parameters.read(node.parameters, yields: Parameters.yields?(node.body))
117
+ slot = inferred_return(single, scope)
118
+ block_rbs = block ? " ?{ (?) -> untyped }" : ""
119
+ { "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
120
+ "return" => slot }
121
+ end
122
+
123
+ # An alias whose original Rigor cannot pin to one `def`: nothing about its signature is known.
124
+ def unknown_overload
125
+ { "rbs" => nil, "params" => nil, "block" => nil, "return" => Slot.unavailable("not_computed") }
126
+ end
127
+
128
+ # The `def` an alias site copied, when its original has no other project definition (one site in the
129
+ # requested files, no omission naming it, none elsewhere) and no contract. Otherwise which `def` Ruby copied
130
+ # depends on load order, and `check` reads the last one it saw.
131
+ def resolved_alias(site)
132
+ node = site.data["aliased_node"]
133
+ return nil if node.nil?
134
+
135
+ owner = site.data["owner"]
136
+ old_name = site.data["alias_of"]
137
+ omitted = @omissions.any? do |omission|
138
+ [omission["owner"], omission["scope"]] == [owner, "instance"] && [old_name, nil].include?(omission["name"])
139
+ end
140
+ olds = @groups.fetch([owner, "instance", old_name], [])
141
+ return nil if omitted || olds.size != 1 || !@elsewhere.call(owner, "instance", old_name).empty?
142
+ # The original's contract is not the alias's: whether a declaration carries it over (`alias` in RBS) or
143
+ # not (`alias_method` under rbs-inline), the alias's undeclared parameters would contradict it.
144
+ return nil if @contracts[[owner, "instance", old_name]]
145
+ # An alias of an alias holds only if the alias it copied does.
146
+ return nil if olds.first.data["alias_of"] && resolved_alias(olds.first).nil?
147
+
148
+ node
149
+ end
150
+
151
+ # An attribute without a contract: Rigor does not report the ivar's type in revision 1.
152
+ def attribute_overload(name, _single)
153
+ unknown = Slot.unavailable("not_computed")
154
+ if name.end_with?("=")
155
+ param = { "name" => nil, "kind" => "required", "type" => Slot.unavailable("not_computed") }
156
+ { "rbs" => nil, "params" => [param], "block" => nil, "return" => unknown }
157
+ else
158
+ { "rbs" => nil, "params" => [], "block" => nil, "return" => unknown }
159
+ end
160
+ end
161
+
162
+ # A module function's singleton copy has its own return reading (`singleton_body_return`).
163
+ def inferred_return(single, scope)
164
+ return Slot.unavailable("contested") unless single
165
+
166
+ return Slot.unavailable("not_computed") if single.data["alias_of"] && resolved_alias(single).nil?
167
+
168
+ copy = single.data["module_function"] && scope == "singleton"
169
+ body = copy ? single.data["singleton_body_return"] : single.data["body_return"]
170
+ return Slot.unavailable("not_computed") if body.nil?
171
+ return body if Slot.unavailable?(body)
172
+
173
+ body.merge("from_site" => single.data["key"])
174
+ end
175
+
176
+ def return_spelling(slot)
177
+ spelled = Slot.rbs_spelling(slot)
178
+ spelled.include?(" | ") ? "(#{spelled})" : spelled
179
+ end
180
+
181
+ # `rbs` is null when any slot of the overload is unavailable: an `untyped` there would read as an assertion.
182
+ def rbs_unless_unavailable(overload)
183
+ slots = [overload["return"], overload.dig("block", "type")] +
184
+ (overload["params"] || []).map { |param| param["type"] }
185
+ slots.any? { |slot| Slot.unavailable?(slot) } ? nil : overload["rbs"]
186
+ end
187
+
188
+ # A site reads `contract: inline` when its own contract comments produced an inline declaration of a member
189
+ # it defines.
190
+ def mark_inline_sites
191
+ @sites.each do |site|
192
+ path = @site_paths.fetch(site.data["key"])
193
+ asserted = site.contract_comments.any? { |comment| !comment.location.slice.match?(/\A#\s*@extrbs\b/) }
194
+ inline = asserted && site.data["members"].any? do |name|
195
+ contract = @contracts[[site.data["owner"], site.data["scope"], name]]
196
+ contract&.provenance == "inline" && contract.buffers.any? { |buffer| buffer.end_with?(":#{path}") }
197
+ end
198
+ site.data["contract"] = inline ? "inline" : nil
199
+ end
200
+ end
201
+ end
202
+ end
203
+ 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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../version"
4
+
5
+ module Rigor
6
+ module Lens
7
+ # `context.plugins` (docs/internal-spec/lens.md § Context): the plugins a run loaded, by manifest id.
8
+ module Plugins
9
+ BUNDLED_PLUGINS = File.expand_path("../../../plugins", __dir__).freeze
10
+
11
+ module_function
12
+
13
+ # `[{id, version}]` by manifest id (the id slot provenance uses). The version is Rigor's for a plugin bundled
14
+ # with it, else the version of the gem whose files define the plugin class, else null. A plugin id is not a gem
15
+ # name: the bundled `rbs-inline` plugin shares its id with the upstream gem.
16
+ def loaded(environment)
17
+ plugins = environment&.plugin_registry&.plugins || []
18
+ rows = plugins.map { |plugin| { "id" => plugin.manifest.id.to_s, "version" => version(plugin) } }
19
+ rows.uniq.sort_by { |plugin| plugin["id"] }
20
+ rescue StandardError
21
+ []
22
+ end
23
+
24
+ def version(plugin)
25
+ file = Object.const_source_location(plugin.class.name)&.first
26
+ return nil if file.nil?
27
+
28
+ absolute = File.expand_path(file)
29
+ return Rigor::VERSION if absolute.start_with?("#{BUNDLED_PLUGINS}/")
30
+
31
+ spec = Gem.loaded_specs.each_value.find do |gem|
32
+ absolute.start_with?("#{gem.full_gem_path}/") # rigor:disable undefined-method
33
+ end
34
+ spec&.version&.to_s
35
+ rescue StandardError
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "file_reader"
6
+ require_relative "contract_reader"
7
+ require_relative "members"
8
+ require_relative "plugins"
9
+ require_relative "slot"
10
+ require_relative "../analysis/incremental_session"
11
+ require_relative "../cache/incremental_snapshot"
12
+ require_relative "../cache/store"
13
+ require_relative "../sig_gen/alias_index"
14
+ require_relative "../sig_gen/type_elaborator"
15
+
16
+ module Rigor
17
+ module Lens
18
+ # The check-equivalent computation behind `rigor lens` (docs/internal-spec/lens.md § Computation). The session
19
+ # is the `--incremental` one rooted at the configuration's `paths:`, so it shares the snapshot with
20
+ # `rigor check --incremental`; the targets are forced into the run and read inside it, on the seeded index.
21
+ class Run # rubocop:disable Metrics/ClassLength
22
+ # One target's answer: `status` is `ok` or a failure reason; the rest is set only for `ok`.
23
+ Target = Data.define(:argument, :path, :absolute, :status, :reason, :reader, :diagnostics, :inputs)
24
+ Outcome = Data.define(:targets, :member_rows, :plugins, :warm)
25
+
26
+ # @param root — the project root (absolute); every path in the answer is relative to it
27
+ def initialize(configuration:, root:, arguments:, workers:)
28
+ @configuration = configuration
29
+ @root = root
30
+ @arguments = arguments
31
+ @argument_paths = arguments.to_set { |argument| File.expand_path(argument) }
32
+ @workers = workers
33
+ reset_captures
34
+ @capture_errors = {}
35
+ @plugins = []
36
+ end
37
+
38
+ def call
39
+ session = build_session
40
+ project = session.project_files.to_h { |path| [File.expand_path(path), path] }
41
+ resolved = mark_duplicates(@arguments.map { |argument| resolve(argument, project) })
42
+ forced = resolved.filter_map { |entry| entry[:session_path] }
43
+ session.force_targets = forced.uniq
44
+ diagnostics, warm = analyse(session, forced.uniq)
45
+ targets = resolved.map { |entry| target(entry, diagnostics, session) }
46
+ Outcome.new(targets: targets, member_rows: members(targets, session), plugins: @plugins, warm: warm)
47
+ end
48
+
49
+ private
50
+
51
+ def build_session
52
+ cache_root = @configuration.cache_path
53
+ Analysis::IncrementalSession.new(
54
+ configuration: @configuration,
55
+ cache_store: Cache::Store.new(root: cache_root, max_bytes: @configuration.cache_max_bytes),
56
+ workers: @workers, on_file_indexed: method(:capture)
57
+ )
58
+ end
59
+
60
+ # A warm run is a recheck that forced the targets. A cold one is a baseline: a sequential baseline indexes
61
+ # every target in this process, a pooled one in workers where the callback never runs. The session also
62
+ # falls back to a baseline after a recheck whose seeds it no longer trusts, and the captures that recheck
63
+ # made are stale. So after a pooled baseline the captures are dropped and one forced recheck, on the
64
+ # baseline's fresh state, reads the targets again.
65
+ def analyse(session, forced)
66
+ fingerprint = Cache::IncrementalSnapshot.fingerprint(configuration: @configuration,
67
+ roots: @configuration.paths)
68
+ snapshot = Cache::IncrementalSnapshot.new(root: @configuration.cache_path)
69
+ diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
70
+ missing = forced.reject { |path| @captured.key?(File.expand_path(path)) }
71
+ if (!warm && @workers.positive?) || !missing.empty?
72
+ reset_captures
73
+ diagnostics = session.recheck.diagnostics
74
+ end
75
+ [diagnostics, warm]
76
+ end
77
+
78
+ def reset_captures
79
+ @captured = {}
80
+ @contracts = {}
81
+ @site_paths = {}
82
+ end
83
+
84
+ # A file requested again, in any spelling, is answered once: the later entries read `duplicate`, naming the
85
+ # argument that carries the answer, so every site key stays unique in the document.
86
+ def mark_duplicates(entries)
87
+ first = {}
88
+ entries.map do |entry|
89
+ next entry unless entry[:status] == "ok"
90
+
91
+ seen = first[entry[:absolute]]
92
+ first[entry[:absolute]] ||= entry[:argument]
93
+ next entry unless seen
94
+
95
+ entry.merge(status: "duplicate", reason: "the same file as #{seen}", session_path: nil)
96
+ end
97
+ end
98
+
99
+ def resolve(argument, project)
100
+ absolute = File.expand_path(argument)
101
+ entry = { argument: argument, absolute: absolute, path: relative(absolute) }
102
+ return entry.merge(status: "not_found", reason: "no such file") unless File.file?(absolute)
103
+ return entry.merge(status: "not_ruby", reason: "not a Ruby source file") unless absolute.end_with?(".rb")
104
+
105
+ session_path = project[absolute]
106
+ return entry.merge(status: "outside_project", reason: "not under the configured paths:") unless session_path
107
+
108
+ entry.merge(status: "ok", session_path: session_path)
109
+ end
110
+
111
+ # The runner's index callback. An error here never reaches the run: it is recorded, and the target reads
112
+ # `failed` with the reason.
113
+ def capture(path, parse_result, scope, index)
114
+ absolute = File.expand_path(path)
115
+ return unless @argument_paths.include?(absolute)
116
+
117
+ @plugins = Plugins.loaded(scope.environment) if @plugins.empty?
118
+ read_target(absolute, parse_result, scope, index)
119
+ @capture_errors.delete(absolute)
120
+ rescue StandardError => e
121
+ @captured.delete(absolute)
122
+ @capture_errors[absolute] = "#{e.class}: #{e.message}"
123
+ end
124
+
125
+ def read_target(absolute, parse_result, scope, index)
126
+ reader = FileReader.new(path: relative(absolute), parse_result: parse_result, index: index,
127
+ spell: speller(scope.environment)).read
128
+ reader.sites.each do |site|
129
+ @site_paths[site.data["key"]] = absolute
130
+ read_constant_value(site, scope) if site.data["kind"] == "constant"
131
+ Members.scopes_of(site.data).product(site.data["members"]).each do |member_scope, name|
132
+ read_contract(site.data["owner"], member_scope, name, scope)
133
+ end
134
+ end
135
+ @captured[absolute] = reader
136
+ end
137
+
138
+ # The constant's type as `check` reads it ({Reflection.constant_type_for}): the value the project's source
139
+ # publishes for the name (every write joined, a mutated one widened) over any declaration, else its RBS
140
+ # declaration, else unknown.
141
+ def read_constant_value(site, scope)
142
+ name = site.data["name"]
143
+ published = scope.in_source_constants[name]
144
+ site.data["value"] =
145
+ if published
146
+ published_slot(published, scope.environment)
147
+ else
148
+ declared_slot(site, contract_reader(scope.environment).read_constant(name, scope.environment))
149
+ end
150
+ end
151
+
152
+ # The rbs-inline plugin declares every constant of a file it reads, typing an unannotated one from its
153
+ # literal (`[]` as `Array[untyped]`), and reads only a `#:` trailing the assignment as its type: an inline
154
+ # declaration is the constant's own only when that is the site's one contract comment.
155
+ def declared_slot(site, declared)
156
+ return Slot.unavailable("not_computed") if declared.nil?
157
+ return Slot.unavailable("not_computed") if declared["provenance"] == "inline" && !trailing_type?(site)
158
+
159
+ declared
160
+ end
161
+
162
+ def trailing_type?(site)
163
+ comments = site.contract_comments
164
+ comments.size == 1 && comments.first.location.start_line == site.node.location.end_line &&
165
+ comments.first.location.slice.start_with?("#:")
166
+ end
167
+
168
+ def published_slot(type, environment)
169
+ return Slot.dynamic("inferred", nil) if type.is_a?(Type::Dynamic)
170
+
171
+ spelled = speller(environment).call(type, nil)
172
+ spelled == "untyped" ? Slot.dynamic("inferred", nil) : Slot.typed(spelled, "inferred")
173
+ rescue StandardError
174
+ Slot.unavailable("not_computed")
175
+ end
176
+
177
+ def read_contract(owner, member_scope, name, scope)
178
+ key = [owner, member_scope, name]
179
+ return if @contracts.key?(key)
180
+
181
+ @contracts[key] = contract_reader(scope.environment).read(owner, member_scope.to_sym, name, scope)
182
+ end
183
+
184
+ def target(entry, diagnostics, session)
185
+ base = { argument: entry[:argument], path: entry[:path], absolute: entry[:absolute], status: entry[:status],
186
+ reason: entry[:reason], reader: nil, diagnostics: nil, inputs: nil }
187
+ return Target.new(**base) unless entry[:status] == "ok"
188
+
189
+ reader = @captured[entry[:absolute]]
190
+ own = diagnostics.select { |diagnostic| File.expand_path(diagnostic.path.to_s) == entry[:absolute] }
191
+ if reader
192
+ return Target.new(**base, reader: reader, diagnostics: own,
193
+ inputs: inputs(session, entry[:session_path]))
194
+ end
195
+
196
+ Target.new(**base, **uncaptured(entry[:absolute], own))
197
+ end
198
+
199
+ # A forced target the run did not read: one that does not parse stops before indexing; otherwise reading it
200
+ # failed, or the run never indexed it.
201
+ def uncaptured(absolute, own)
202
+ if parse_errors?(absolute)
203
+ { status: "parse_error", reason: "the file does not parse", diagnostics: own }
204
+ else
205
+ { status: "failed", reason: @capture_errors.fetch(absolute, "the file was not analysed") }
206
+ end
207
+ rescue StandardError => e
208
+ # The parse itself failed (an unsupported `target_ruby`, say): the run's own parse did too.
209
+ { status: "failed", reason: "#{e.class}: #{e.message}" }
210
+ end
211
+
212
+ def parse_errors?(absolute)
213
+ Prism.parse_file(absolute, version: @configuration.target_ruby).errors.any?
214
+ end
215
+
216
+ def inputs(session, session_path)
217
+ rows = session.dependency_sources(session_path).filter_map do |source|
218
+ absolute = File.expand_path(source)
219
+ next if absolute == File.expand_path(session_path) || !File.file?(absolute)
220
+
221
+ { "path" => relative(absolute), "digest" => Lens.digest(File.binread(absolute)) }
222
+ end
223
+ rows.sort_by { |input| input["path"] }
224
+ end
225
+
226
+ def members(targets, session)
227
+ # A file requested twice, in any spelling, contributes its declarations once.
228
+ readers = targets.filter_map { |target| target.reader && [target.absolute, target.reader] }.to_h
229
+ sites = readers.values.flat_map(&:sites)
230
+ omissions = readers.values.flat_map(&:omissions)
231
+ requested = readers.keys.to_set
232
+ Members.new(sites: sites, omissions: omissions, contracts: @contracts,
233
+ elsewhere: elsewhere(session, requested), site_paths: @site_paths).build
234
+ end
235
+
236
+ # Other project files that define a member, from the per-file discovery bundles the session keeps. The bundle
237
+ # paths are resolved once; each member then costs one lookup per bundle.
238
+ def elsewhere(session, requested)
239
+ bundles = session.seed_bundles.filter_map do |path, bundle|
240
+ absolute = File.expand_path(path)
241
+ [relative(absolute), bundle] unless requested.include?(absolute)
242
+ end
243
+ lambda do |owner, scope, name|
244
+ entries = bundles.filter_map do |path, bundle|
245
+ line = defined_line(bundle, owner, scope, name)
246
+ line == false ? nil : { "path" => path, "line" => line }
247
+ end
248
+ entries.sort_by { |entry| entry["path"] }
249
+ end
250
+ end
251
+
252
+ # The line a bundle records for the member, nil when it defines the member without a recorded line (an
253
+ # `attr_*`), or false when it does not define it.
254
+ # A top-level `def` is recorded under discovery's own key, apart from `class Object`; both count for
255
+ # `Object#name`.
256
+ def defined_line(bundle, owner, scope, name)
257
+ owners = if owner == "Object" && scope == "instance"
258
+ [owner,
259
+ Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY]
260
+ else
261
+ [owner]
262
+ end
263
+ owners.each do |key|
264
+ line = defined_line_for(bundle, key, scope, name)
265
+ return line unless line == false
266
+ end
267
+ false
268
+ end
269
+
270
+ def defined_line_for(bundle, owner, scope, name)
271
+ table = scope == "singleton" ? bundle[:singleton_def_sources] : bundle[:def_sources]
272
+ source = table&.dig(owner, name.to_sym)
273
+ return Integer(source.to_s.split(":").last) if source
274
+
275
+ kinds = bundle[:methods]&.dig(owner, name.to_sym)
276
+ return false if kinds.nil?
277
+ return nil if kinds == :both || kinds.to_s == scope
278
+
279
+ false
280
+ end
281
+
282
+ def speller(environment)
283
+ @alias_index ||= SigGen::AliasIndex.build(environment: environment, signature_paths: signature_roots,
284
+ excluded_paths: plugin_signature_paths(environment))
285
+ lambda do |type, owner|
286
+ @alias_index.fold(SigGen::TypeElaborator.elaborate(type, environment: environment).erase_to_rbs, owner)
287
+ end
288
+ end
289
+
290
+ def contract_reader(environment)
291
+ @contract_reader ||= ContractReader.new(
292
+ Provenance.new(root: @root, signature_roots: signature_roots.map { |path| File.expand_path(path.to_s) },
293
+ plugin_roots: plugin_roots(environment))
294
+ )
295
+ end
296
+
297
+ # `{absolute signature directory => plugin id}` for every loaded plugin that ships signatures.
298
+ def plugin_roots(environment)
299
+ plugins = environment&.plugin_registry&.plugins || []
300
+ plugins.each_with_object({}) do |plugin, roots|
301
+ Array(plugin.signature_paths).each { |path| roots[File.expand_path(path.to_s)] = plugin.manifest.id }
302
+ end
303
+ rescue StandardError
304
+ {}
305
+ end
306
+
307
+ def signature_roots
308
+ @signature_roots ||= Environment.project_signature_roots(@configuration.signature_paths)
309
+ end
310
+
311
+ def plugin_signature_paths(environment)
312
+ environment&.plugin_registry&.signature_paths || []
313
+ rescue StandardError
314
+ []
315
+ end
316
+
317
+ def relative(absolute)
318
+ Pathname(absolute).relative_path_from(Pathname(@root)).to_s
319
+ end
320
+ end
321
+ end
322
+ end