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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +28 -5
- data/docs/manual/02-cli-reference.md +23 -4
- data/docs/manual/03-configuration.md +21 -8
- data/docs/manual/04-diagnostics.md +44 -2
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +32 -3
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +26 -9
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
- data/lib/rigor/analysis/check_rules.rb +68 -57
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental.rb +12 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +207 -40
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +139 -15
- data/lib/rigor/analysis/reachability/scan.rb +374 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/rule_catalog.rb +6 -1
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +6 -4
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
- data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
- data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
- data/lib/rigor/analysis/runner.rb +62 -18
- data/lib/rigor/analysis/severity_stamp.rb +34 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/cache/descriptor.rb +40 -6
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +56 -12
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/config_audit.rb +111 -15
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +79 -10
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/block_call_timing.rb +5 -0
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +69 -6
- data/lib/rigor/inference/definer_resolution.rb +16 -7
- data/lib/rigor/inference/expression_typer.rb +117 -29
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/module_function_state.rb +80 -13
- data/lib/rigor/inference/narrowing.rb +184 -68
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +416 -1
- data/lib/rigor/inference/scope_indexer.rb +565 -61
- data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
- data/lib/rigor/inference/statement_evaluator.rb +307 -60
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +133 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +802 -0
- data/lib/rigor/lens/members.rb +203 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +322 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +41 -0
- data/lib/rigor/mcp/server.rb +61 -20
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/project_environment.rb +23 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +33 -17
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/sig_gen/layout_index.rb +36 -0
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/writer.rb +47 -34
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- 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
|