rigortype 0.4.3 → 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 +16 -0
- data/docs/manual/02-cli-reference.md +4 -3
- data/docs/manual/03-configuration.md +16 -5
- data/docs/manual/04-diagnostics.md +44 -2
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +32 -3
- data/docs/manual/18-removing-dead-code.md +11 -1
- 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 +57 -0
- data/lib/rigor/analysis/check_rules.rb +46 -36
- data/lib/rigor/analysis/incremental.rb +12 -0
- data/lib/rigor/analysis/incremental_session.rb +121 -21
- data/lib/rigor/analysis/reachability/graph.rb +23 -3
- data/lib/rigor/analysis/reachability/scan.rb +51 -9
- 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 +5 -3
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
- data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
- data/lib/rigor/analysis/runner.rb +35 -9
- data/lib/rigor/analysis/severity_stamp.rb +34 -1
- data/lib/rigor/analysis/worker_session.rb +57 -1
- data/lib/rigor/cache/descriptor.rb +40 -6
- data/lib/rigor/cache/incremental_snapshot.rb +20 -4
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/config_audit.rb +111 -15
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/environment/rbs_loader.rb +46 -10
- data/lib/rigor/inference/block_call_timing.rb +5 -0
- data/lib/rigor/inference/captured_locals.rb +26 -3
- data/lib/rigor/inference/definer_resolution.rb +16 -7
- data/lib/rigor/inference/expression_typer.rb +59 -4
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/module_function_state.rb +80 -13
- data/lib/rigor/inference/narrowing.rb +184 -68
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +414 -1
- data/lib/rigor/inference/scope_indexer.rb +439 -70
- data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
- data/lib/rigor/inference/statement_evaluator.rb +84 -29
- data/lib/rigor/lens/anchor.rb +3 -3
- data/lib/rigor/lens/contract_reader.rb +15 -0
- data/lib/rigor/lens/document.rb +2 -2
- data/lib/rigor/lens/file_reader.rb +442 -73
- data/lib/rigor/lens/members.rb +65 -15
- data/lib/rigor/lens/run.rb +44 -2
- data/lib/rigor/lens.rb +2 -1
- data/lib/rigor/mcp/server.rb +61 -20
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/project_environment.rb +6 -3
- data/lib/rigor/scope.rb +11 -6
- 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-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
- metadata +4 -1
data/lib/rigor/lens/members.rb
CHANGED
|
@@ -25,25 +25,36 @@ module Rigor
|
|
|
25
25
|
def build
|
|
26
26
|
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
27
27
|
@sites.each do |site|
|
|
28
|
-
site.data
|
|
28
|
+
Members.scopes_of(site.data).each do |scope|
|
|
29
|
+
site.data["members"].each { |name| groups[[site.data["owner"], scope, name]] << site }
|
|
30
|
+
end
|
|
29
31
|
end
|
|
32
|
+
@groups = groups
|
|
30
33
|
result = groups.sort_by { |key, _| key }.map { |key, sites| member(key, sites) }
|
|
31
34
|
mark_inline_sites
|
|
32
35
|
result
|
|
33
36
|
end
|
|
34
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
|
+
|
|
35
44
|
private
|
|
36
45
|
|
|
37
46
|
def member(key, sites)
|
|
38
47
|
owner, scope, name = key
|
|
39
48
|
contract = @contracts[key]
|
|
40
49
|
also = @elsewhere.call(owner, scope, name)
|
|
41
|
-
omitted = @omissions.count
|
|
50
|
+
omitted = @omissions.count do |omission|
|
|
51
|
+
[omission["owner"], omission["scope"]] == [owner, scope] && [name, nil].include?(omission["name"])
|
|
52
|
+
end
|
|
42
53
|
single = sites.size == 1 && omitted.zero? && also.empty? ? sites.first : nil
|
|
43
54
|
{ "owner" => owner, "scope" => scope, "name" => name, "kind" => member_kind(sites),
|
|
44
|
-
"visibility" => common_visibility(sites), "sites" => sites.map { |site| site.data["key"] },
|
|
55
|
+
"visibility" => common_visibility(sites, scope), "sites" => sites.map { |site| site.data["key"] },
|
|
45
56
|
"also_defined_at" => also, "basis" => basis(contract, single),
|
|
46
|
-
"overloads" => overloads(contract, sites.first, name, single) }
|
|
57
|
+
"overloads" => overloads(contract, sites.first, name, single, scope) }
|
|
47
58
|
end
|
|
48
59
|
|
|
49
60
|
# A member a `def` defines anywhere in the requested files is a method, whatever `attr_*` also names it.
|
|
@@ -58,28 +69,32 @@ module Rigor
|
|
|
58
69
|
"contested"
|
|
59
70
|
end
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
63
78
|
values.size == 1 ? values.first : nil
|
|
64
79
|
end
|
|
65
80
|
|
|
66
81
|
# A contested member without a contract has no overloads: its definitions may differ in arity, and each
|
|
67
82
|
# method site carries its own parameter list.
|
|
68
|
-
def overloads(contract, site, name, single)
|
|
83
|
+
def overloads(contract, site, name, single, scope)
|
|
69
84
|
return [] if contract.nil? && single.nil?
|
|
70
85
|
|
|
71
|
-
overloads = contract ? contract_overloads(contract, single) : [body_overload(site, name, single)]
|
|
86
|
+
overloads = contract ? contract_overloads(contract, single, scope) : [body_overload(site, name, single, scope)]
|
|
72
87
|
overloads.map { |overload| overload.merge("rbs" => rbs_unless_unavailable(overload)) }
|
|
73
88
|
end
|
|
74
89
|
|
|
75
90
|
# A contract's overloads. A defaulted return (`inferred-return`) is inferred as for a member without a
|
|
76
91
|
# contract, and its `untyped` parameters are unknown: Rigor cannot tell an authored `untyped` from a
|
|
77
92
|
# defaulted one.
|
|
78
|
-
def contract_overloads(contract, single)
|
|
93
|
+
def contract_overloads(contract, single, scope)
|
|
79
94
|
contract.overloads.map do |overload|
|
|
80
95
|
next overload.except("return_rbs") unless contract.return_inferred
|
|
81
96
|
|
|
82
|
-
slot = inferred_return(single)
|
|
97
|
+
slot = inferred_return(single, scope)
|
|
83
98
|
params = overload["params"]&.map do |param|
|
|
84
99
|
param["type"]["type"] == "untyped" ? param.merge("type" => Slot.unavailable("not_computed")) : param
|
|
85
100
|
end
|
|
@@ -92,16 +107,47 @@ module Rigor
|
|
|
92
107
|
|
|
93
108
|
# A member with no contract: its parameters as written, each `untyped` with ADR-75's undeclared-parameter
|
|
94
109
|
# cause, and the return from its one body.
|
|
95
|
-
def body_overload(site, name, single)
|
|
110
|
+
def body_overload(site, name, single, scope)
|
|
96
111
|
return attribute_overload(name, single) if site.data["kind"] == "attribute"
|
|
97
112
|
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
100
118
|
block_rbs = block ? " ?{ (?) -> untyped }" : ""
|
|
101
119
|
{ "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
|
|
102
120
|
"return" => slot }
|
|
103
121
|
end
|
|
104
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
|
+
|
|
105
151
|
# An attribute without a contract: Rigor does not report the ivar's type in revision 1.
|
|
106
152
|
def attribute_overload(name, _single)
|
|
107
153
|
unknown = Slot.unavailable("not_computed")
|
|
@@ -113,10 +159,14 @@ module Rigor
|
|
|
113
159
|
end
|
|
114
160
|
end
|
|
115
161
|
|
|
116
|
-
|
|
162
|
+
# A module function's singleton copy has its own return reading (`singleton_body_return`).
|
|
163
|
+
def inferred_return(single, scope)
|
|
117
164
|
return Slot.unavailable("contested") unless single
|
|
118
165
|
|
|
119
|
-
|
|
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"]
|
|
120
170
|
return Slot.unavailable("not_computed") if body.nil?
|
|
121
171
|
return body if Slot.unavailable?(body)
|
|
122
172
|
|
data/lib/rigor/lens/run.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Rigor
|
|
|
18
18
|
# The check-equivalent computation behind `rigor lens` (docs/internal-spec/lens.md § Computation). The session
|
|
19
19
|
# is the `--incremental` one rooted at the configuration's `paths:`, so it shares the snapshot with
|
|
20
20
|
# `rigor check --incremental`; the targets are forced into the run and read inside it, on the seeded index.
|
|
21
|
-
class Run
|
|
21
|
+
class Run # rubocop:disable Metrics/ClassLength
|
|
22
22
|
# One target's answer: `status` is `ok` or a failure reason; the rest is set only for `ok`.
|
|
23
23
|
Target = Data.define(:argument, :path, :absolute, :status, :reason, :reader, :diagnostics, :inputs)
|
|
24
24
|
Outcome = Data.define(:targets, :member_rows, :plugins, :warm)
|
|
@@ -127,11 +127,53 @@ module Rigor
|
|
|
127
127
|
spell: speller(scope.environment)).read
|
|
128
128
|
reader.sites.each do |site|
|
|
129
129
|
@site_paths[site.data["key"]] = absolute
|
|
130
|
-
site
|
|
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
|
|
131
134
|
end
|
|
132
135
|
@captured[absolute] = reader
|
|
133
136
|
end
|
|
134
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
|
+
|
|
135
177
|
def read_contract(owner, member_scope, name, scope)
|
|
136
178
|
key = [owner, member_scope, name]
|
|
137
179
|
return if @contracts.key?(key)
|
data/lib/rigor/lens.rb
CHANGED
|
@@ -9,7 +9,8 @@ module Rigor
|
|
|
9
9
|
SCHEMA = "lens/v0"
|
|
10
10
|
# Bumped on every breaking change to the `lens/v0` document (docs/internal-spec/lens.md § Status).
|
|
11
11
|
REVISION = 1
|
|
12
|
-
CAPABILITIES = %w[class module method attribute
|
|
12
|
+
CAPABILITIES = %w[class module method attribute constant module_function builder alias body_return
|
|
13
|
+
diagnostics inputs].freeze
|
|
13
14
|
|
|
14
15
|
module_function
|
|
15
16
|
|
data/lib/rigor/mcp/server.rb
CHANGED
|
@@ -63,6 +63,25 @@ module Rigor
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
name: "rigor_lens",
|
|
68
|
+
description: "Map the classes, modules, methods and attributes the given Ruby files declare, with the " \
|
|
69
|
+
"type `rigor check` gives each one and where that type came from (sig/, an inline " \
|
|
70
|
+
"comment, a plugin, a library, or inference). Returns the lens/v0 JSON document " \
|
|
71
|
+
"(experimental). Pass every file you need in one call: each call pays one analysis run.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
files: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string" },
|
|
78
|
+
description: "Ruby files to map (required)"
|
|
79
|
+
},
|
|
80
|
+
config: { type: "string", description: "Path to .rigor.yml (optional)" }
|
|
81
|
+
},
|
|
82
|
+
required: ["files"]
|
|
83
|
+
}
|
|
84
|
+
},
|
|
66
85
|
{
|
|
67
86
|
name: "rigor_annotate",
|
|
68
87
|
description: "Return the given Ruby source file with each line's last-expression type " \
|
|
@@ -180,13 +199,12 @@ module Rigor
|
|
|
180
199
|
out_io = StringIO.new
|
|
181
200
|
err_io = StringIO.new
|
|
182
201
|
require_relative "../cli"
|
|
183
|
-
exit_code = CLI.new(argv, out: out_io, err: err_io).run
|
|
184
|
-
|
|
185
|
-
is_error = exit_code == CLI::EXIT_USAGE
|
|
186
|
-
text = out_io.string
|
|
187
|
-
text = err_io.string if text.empty? && is_error
|
|
202
|
+
exit_code = with_captured_streams(out_io, err_io) { CLI.new(argv, out: out_io, err: err_io).run }
|
|
188
203
|
|
|
189
|
-
success(id,
|
|
204
|
+
success(id, tool_result(out_io.string, err_io.string, exit_code))
|
|
205
|
+
rescue SystemExit => e
|
|
206
|
+
# Defence against any command that exits: it must not take the server down with it.
|
|
207
|
+
success(id, { content: [{ type: "text", text: "Command exited (status #{e.status})" }], isError: true })
|
|
190
208
|
rescue StandardError => e
|
|
191
209
|
@err.puts("rigor mcp: #{e.class}: #{e.message}")
|
|
192
210
|
@err.puts(e.backtrace.first(5).join("\n")) if e.backtrace
|
|
@@ -196,6 +214,26 @@ module Rigor
|
|
|
196
214
|
})
|
|
197
215
|
end
|
|
198
216
|
|
|
217
|
+
# A non-zero exit with nothing on stdout printed only an error; exit 1 with a document (check, lens) is a
|
|
218
|
+
# result, not an error.
|
|
219
|
+
def tool_result(out, err, exit_code)
|
|
220
|
+
is_error = [CLI::EXIT_USAGE, CLI::EXIT_INTERNAL_ERROR].include?(exit_code) || (out.empty? && !exit_code.zero?)
|
|
221
|
+
{ content: [{ type: "text", text: out.empty? ? err : out }], isError: is_error }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The JSON-RPC channel is the process's stdout, so nothing a command writes past its `out:` (an option
|
|
225
|
+
# parser's `--help`, a stray `puts`) may reach it while the command runs.
|
|
226
|
+
def with_captured_streams(out_io, err_io)
|
|
227
|
+
stdout = $stdout
|
|
228
|
+
stderr = $stderr
|
|
229
|
+
$stdout = out_io
|
|
230
|
+
$stderr = err_io
|
|
231
|
+
yield
|
|
232
|
+
ensure
|
|
233
|
+
$stdout = stdout
|
|
234
|
+
$stderr = stderr
|
|
235
|
+
end
|
|
236
|
+
|
|
199
237
|
def build_argv(name, args) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
|
|
200
238
|
effective_config = args["config"] || @config_path
|
|
201
239
|
|
|
@@ -203,49 +241,52 @@ module Rigor
|
|
|
203
241
|
when "rigor_check"
|
|
204
242
|
argv = ["check", "--format=json", "--no-stats"]
|
|
205
243
|
argv << "--config=#{effective_config}" if effective_config
|
|
206
|
-
argv
|
|
207
|
-
argv
|
|
244
|
+
argv + ["--", *Array(args["paths"])]
|
|
208
245
|
|
|
209
246
|
when "rigor_type_of"
|
|
210
247
|
return nil unless args["file"] && args["line"] && args["col"]
|
|
211
248
|
|
|
212
249
|
argv = ["type-of", "--format=json"]
|
|
213
250
|
argv << "--config=#{effective_config}" if effective_config
|
|
214
|
-
|
|
215
|
-
argv
|
|
251
|
+
# `--` ends the options, so a file named like a flag is a file.
|
|
252
|
+
argv + ["--", "#{args['file']}:#{args['line']}:#{args['col']}"]
|
|
216
253
|
|
|
217
254
|
when "rigor_triage"
|
|
218
255
|
argv = ["triage", "--format=json"]
|
|
219
256
|
argv << "--config=#{effective_config}" if effective_config
|
|
220
257
|
argv << "--top=#{args['top']}" if args["top"]
|
|
221
|
-
argv
|
|
222
|
-
|
|
258
|
+
argv + ["--", *Array(args["paths"])]
|
|
259
|
+
|
|
260
|
+
when "rigor_lens"
|
|
261
|
+
files = args["files"]
|
|
262
|
+
return nil unless files.is_a?(Array) && !files.empty? && files.all?(String)
|
|
263
|
+
|
|
264
|
+
argv = ["lens", "--format=json"]
|
|
265
|
+
argv << "--config=#{effective_config}" if effective_config
|
|
266
|
+
# `--` ends the options, so a file named like a flag (`--version`) is a file.
|
|
267
|
+
argv + ["--", *files]
|
|
223
268
|
|
|
224
269
|
when "rigor_annotate"
|
|
225
270
|
return nil unless args["file"]
|
|
226
271
|
|
|
227
272
|
argv = ["annotate", "--no-color"]
|
|
228
273
|
argv << "--config=#{effective_config}" if effective_config
|
|
229
|
-
argv
|
|
230
|
-
argv
|
|
274
|
+
argv + ["--", args["file"]]
|
|
231
275
|
|
|
232
276
|
when "rigor_sig_gen"
|
|
233
277
|
argv = ["sig-gen", "--print", "--format=json"]
|
|
234
278
|
argv << "--config=#{effective_config}" if effective_config
|
|
235
279
|
argv << "--params=#{args['params']}" if args["params"]
|
|
236
|
-
argv
|
|
237
|
-
argv
|
|
280
|
+
argv + ["--", *Array(args["paths"])]
|
|
238
281
|
|
|
239
282
|
when "rigor_explain"
|
|
240
283
|
argv = ["explain", "--format=json"]
|
|
241
|
-
|
|
242
|
-
argv
|
|
284
|
+
args["rule"] ? argv + ["--", args["rule"]] : argv
|
|
243
285
|
|
|
244
286
|
when "rigor_coverage"
|
|
245
287
|
argv = ["coverage", "--format=json"]
|
|
246
288
|
argv << "--config=#{effective_config}" if effective_config
|
|
247
|
-
argv
|
|
248
|
-
argv
|
|
289
|
+
argv + ["--", *Array(args["paths"])]
|
|
249
290
|
end
|
|
250
291
|
end
|
|
251
292
|
|
|
@@ -15,7 +15,8 @@ module Rigor
|
|
|
15
15
|
# under `RUBY_BOX=1` and must print the right answer. That admits a 4.1.0dev build with the fix and a
|
|
16
16
|
# future 4.0.x that backports it, and refuses a development build cut before the fix — none of which a
|
|
17
17
|
# `RUBY_VERSION` bound gets right. The probe costs one short-lived `ruby --disable-gems` process, paid
|
|
18
|
-
# only when a run opts into `ruby_box
|
|
18
|
+
# only when a run opts into `ruby_box` or starts with `RUBY_BOX` already set, and only once per Ruby
|
|
19
|
+
# build: {cached_unsupported_reason} remembers the verdict.
|
|
19
20
|
#
|
|
20
21
|
# Loaded by `exe/rigor` before anything else, so this file requires nothing from Rigor.
|
|
21
22
|
module BoxProbe
|
|
@@ -36,20 +37,166 @@ module Rigor
|
|
|
36
37
|
# counted as a failure, so a wedged probe cannot hang the launcher (or `rigor lsp` behind it).
|
|
37
38
|
DEADLINE_SECONDS = 10
|
|
38
39
|
|
|
40
|
+
# Set on the environment of every re-exec this module asks for, so the re-exec'ed launcher neither
|
|
41
|
+
# probes again nor re-execs again. The launcher deletes it before Rigor loads, so nothing Rigor spawns
|
|
42
|
+
# inherits it.
|
|
43
|
+
REEXEC_MARKER = "RIGOR_BOX_PROBED"
|
|
44
|
+
|
|
45
|
+
# The signals the bug's NULL-box dereference ends a child with. A child killed by any other signal
|
|
46
|
+
# (an OOM killer's `SIGKILL`, an operator's `SIGTERM`) is still refused, but that verdict is not cached.
|
|
47
|
+
CRASH_SIGNALS = %w[SEGV BUS ABRT ILL].freeze
|
|
48
|
+
|
|
49
|
+
CACHE_FORMAT = "rigor-box-probe-v1"
|
|
50
|
+
|
|
39
51
|
module_function
|
|
40
52
|
|
|
53
|
+
# Decides what `exe/rigor` does before Rigor loads, editing `env` (the real `ENV` in the launcher) in
|
|
54
|
+
# place. Returns true when the launcher must re-exec itself with `env` as now edited.
|
|
55
|
+
#
|
|
56
|
+
# - `RUBY_BOX` already set (by hand or by a wrapper), and `Ruby::Box` active in this process: the
|
|
57
|
+
# process booted inside the box before any Rigor code ran, so on a Ruby without the Bug #22260 fix
|
|
58
|
+
# the first Ractor-shareable lambda Rigor calls segfaults. Probe (through the cache); when the Ruby
|
|
59
|
+
# is refused, print a notice, drop `RUBY_BOX` and the `ruby_box` request, and re-exec. When it
|
|
60
|
+
# passes, keep `RUBY_BOX` as the user set it.
|
|
61
|
+
# - `ruby_box` requested and `RUBY_BOX` unset: probe; re-exec under `RUBY_BOX=1` when it passes,
|
|
62
|
+
# otherwise warn and drop the request.
|
|
63
|
+
# - Otherwise nothing.
|
|
64
|
+
def needs_reexec?(env, stderr: $stderr, box_active: box_active?, cache_dir: default_cache_dir(env))
|
|
65
|
+
return false if env.delete(REEXEC_MARKER)
|
|
66
|
+
|
|
67
|
+
if env["RUBY_BOX"].to_s.empty?
|
|
68
|
+
reexec_for_box_request?(env, stderr, cache_dir)
|
|
69
|
+
else
|
|
70
|
+
reexec_without_preset_box?(env, stderr, box_active, cache_dir)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reexec_for_box_request?(env, stderr, cache_dir)
|
|
75
|
+
isolation = env["RIGOR_PLUGIN_ISOLATION"].to_s
|
|
76
|
+
return false unless isolation == "ruby_box" || !env["RIGOR_BOX"].to_s.empty?
|
|
77
|
+
|
|
78
|
+
if (reason = cached_unsupported_reason(cache_dir: cache_dir))
|
|
79
|
+
# `puts` on stderr, not `warn`: `-W0` silences `warn`, and this fallback must never be silent.
|
|
80
|
+
stderr.puts "rigor: ruby_box plugin isolation is unavailable: #{reason}. " \
|
|
81
|
+
"Falling back to the configured plugins_isolation strategy (default: process)."
|
|
82
|
+
drop_box_request(env)
|
|
83
|
+
false
|
|
84
|
+
else
|
|
85
|
+
env["RUBY_BOX"] = "1"
|
|
86
|
+
env["RIGOR_PLUGIN_ISOLATION"] = "ruby_box" if isolation.empty?
|
|
87
|
+
env[REEXEC_MARKER] = "1"
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reexec_without_preset_box?(env, stderr, box_active, cache_dir)
|
|
93
|
+
return false unless box_active
|
|
94
|
+
|
|
95
|
+
reason = cached_unsupported_reason(cache_dir: cache_dir)
|
|
96
|
+
return false unless reason
|
|
97
|
+
|
|
98
|
+
stderr.puts "rigor: RUBY_BOX is set, but #{reason}. " \
|
|
99
|
+
"Re-running without RUBY_BOX; plugins will not run inside Ruby::Box."
|
|
100
|
+
env.delete("RUBY_BOX")
|
|
101
|
+
drop_box_request(env)
|
|
102
|
+
env[REEXEC_MARKER] = "1"
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def drop_box_request(env)
|
|
107
|
+
env.delete("RIGOR_PLUGIN_ISOLATION") if env["RIGOR_PLUGIN_ISOLATION"].to_s == "ruby_box"
|
|
108
|
+
env.delete("RIGOR_BOX")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether this process booted inside `Ruby::Box`. False on a Ruby without the feature, which ignores
|
|
112
|
+
# `RUBY_BOX`, so a preset variable there costs nothing.
|
|
113
|
+
def box_active?
|
|
114
|
+
return false unless defined?(::Ruby::Box) && ::Ruby::Box.respond_to?(:enabled?)
|
|
115
|
+
|
|
116
|
+
::Ruby::Box.enabled? == true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# `$XDG_CACHE_HOME/rigor` when that is an absolute path, else `$HOME/.cache/rigor` when `HOME` is an
|
|
120
|
+
# absolute path (the passwd entry when `HOME` is unset); nil otherwise, which turns the cache off
|
|
121
|
+
# rather than writing under the working directory.
|
|
122
|
+
def default_cache_dir(env)
|
|
123
|
+
xdg = env["XDG_CACHE_HOME"].to_s
|
|
124
|
+
return File.join(xdg, "rigor") if xdg.start_with?("/")
|
|
125
|
+
|
|
126
|
+
home = env.key?("HOME") ? env["HOME"].to_s : Dir.home
|
|
127
|
+
File.absolute_path?(home) ? File.join(home, ".cache", "rigor") : nil
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# {unsupported_reason}, remembered per Ruby build so a launcher that probes on every run pays for the
|
|
133
|
+
# child process once. A pass is stored only when the child printed the right answer, and a refusal
|
|
134
|
+
# only when it is a property of the Ruby (no `Ruby::Box`, or a crash signal); a probe that could not
|
|
135
|
+
# run, timed out, or failed some other way is answered but not stored. An unreadable or corrupt
|
|
136
|
+
# cache file reads as a miss; a write that fails is ignored.
|
|
137
|
+
def cached_unsupported_reason(ruby = RbConfig.ruby, cache_dir:)
|
|
138
|
+
path = cache_dir && cache_path(cache_dir, ruby)
|
|
139
|
+
cached = path && read_cache(path)
|
|
140
|
+
return cached[:reason] if cached
|
|
141
|
+
|
|
142
|
+
reason, cacheable = verdict(ruby)
|
|
143
|
+
write_cache(path, reason) if path && cacheable
|
|
144
|
+
reason
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The cache file for `ruby`: keyed on the Ruby build that runs the launcher (its version, patch level,
|
|
148
|
+
# revision and description) together with the executable path and the probe script itself.
|
|
149
|
+
def cache_path(cache_dir, ruby)
|
|
150
|
+
require "digest/sha2"
|
|
151
|
+
key = [RUBY_VERSION, RUBY_PATCHLEVEL, RUBY_REVISION, RUBY_DESCRIPTION, File.expand_path(ruby), SCRIPT]
|
|
152
|
+
File.join(cache_dir, "ruby-box-probe", Digest::SHA256.hexdigest(key.join("\0")))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def read_cache(path)
|
|
156
|
+
format, verdict, reason = File.read(path).split("\n", 3)
|
|
157
|
+
return nil unless format == CACHE_FORMAT
|
|
158
|
+
|
|
159
|
+
case verdict
|
|
160
|
+
when "pass" then reason.nil? ? { reason: nil } : nil
|
|
161
|
+
when "fail" then reason.to_s.chomp.empty? ? nil : { reason: reason.chomp }
|
|
162
|
+
end
|
|
163
|
+
rescue SystemCallError, IOError, ArgumentError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def write_cache(path, reason)
|
|
168
|
+
require "fileutils"
|
|
169
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
170
|
+
body = reason ? "#{CACHE_FORMAT}\nfail\n#{reason}\n" : "#{CACHE_FORMAT}\npass"
|
|
171
|
+
temp = "#{path}.#{::Process.pid}.tmp"
|
|
172
|
+
File.write(temp, body)
|
|
173
|
+
File.rename(temp, path)
|
|
174
|
+
rescue SystemCallError, IOError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
41
178
|
# nil when `ruby` can run Rigor under `RUBY_BOX=1`; otherwise a short reason for the launcher's
|
|
42
179
|
# warning. Only a child killed by a signal is blamed on the bug; any other failure to answer is
|
|
43
180
|
# reported as the probe failing, never as a pass.
|
|
44
181
|
def unsupported_reason(ruby = RbConfig.ruby)
|
|
182
|
+
verdict(ruby).first
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# `[reason, cacheable]` — {unsupported_reason}'s answer, and whether it is a property of the Ruby
|
|
186
|
+
# build rather than of this one attempt.
|
|
187
|
+
def verdict(ruby)
|
|
45
188
|
output, status = run(ruby)
|
|
46
|
-
return "the Ruby::Box probe could not run #{ruby}" if status.nil?
|
|
47
|
-
return nil if status.success? && output == EXPECTED_OUTPUT
|
|
48
|
-
return "Ruby::Box is not available in Ruby #{RUBY_VERSION}" if status.exitstatus == 2
|
|
49
|
-
return "the Ruby::Box probe failed (#{status})" unless status.signaled?
|
|
189
|
+
return ["the Ruby::Box probe could not run #{ruby}", false] if status.nil?
|
|
190
|
+
return [nil, true] if status.success? && output == EXPECTED_OUTPUT
|
|
191
|
+
return ["Ruby::Box is not available in Ruby #{RUBY_VERSION}", true] if status.exitstatus == 2
|
|
192
|
+
return ["the Ruby::Box probe failed (#{status})", false] unless status.signaled?
|
|
193
|
+
|
|
194
|
+
["Ruby #{RUBY_VERSION} crashes running Ractor-shareable procs inside Ruby::Box " \
|
|
195
|
+
"(Ruby Bug #22260; a Ruby carrying its fix is needed)", crash_signal?(status.termsig)]
|
|
196
|
+
end
|
|
50
197
|
|
|
51
|
-
|
|
52
|
-
|
|
198
|
+
def crash_signal?(signo)
|
|
199
|
+
CRASH_SIGNALS.any? { |name| Signal.list[name] == signo }
|
|
53
200
|
end
|
|
54
201
|
|
|
55
202
|
# `RUBYOPT` is cleared so a `-r` of the caller's (bootsnap, a debugger, a coverage hook) cannot fail
|
|
@@ -56,9 +56,12 @@ module Rigor
|
|
|
56
56
|
# `source_rbs_synthesizer` runs over them at env-build time; an empty list contributes no synthesized RBS.
|
|
57
57
|
# @param feature_sources — path => source text the required-feature scan reads in place of that file (an
|
|
58
58
|
# editor buffer bound with `--tmp-file` / `--instead-of`).
|
|
59
|
-
# @param project_patched_methods — the `pre_eval:` registry
|
|
60
|
-
#
|
|
61
|
-
|
|
59
|
+
# @param project_patched_methods — the `pre_eval:` registry. Defaults to the configuration's own (see
|
|
60
|
+
# {.project_patched_methods}), so every probe command (`type-scan`, `trace`, `annotate`, `sig-gen`) types a
|
|
61
|
+
# patched method as `rigor check` does (issue #1895); `type-of` passes its buffer-aware one. An explicit
|
|
62
|
+
# nil keeps the unpatched universe.
|
|
63
|
+
def build(configuration:, source_files:, feature_sources: {},
|
|
64
|
+
project_patched_methods: ProjectEnvironment.project_patched_methods(configuration))
|
|
62
65
|
features = required_features(configuration, source_files, sources: feature_sources)
|
|
63
66
|
Environment.for_project(
|
|
64
67
|
project_patched_methods: project_patched_methods,
|
data/lib/rigor/scope.rb
CHANGED
|
@@ -876,13 +876,18 @@ module Rigor
|
|
|
876
876
|
STDOUT_ALIASES = { :$stdout => :$>, :$> => :$stdout }.freeze
|
|
877
877
|
private_constant :STDOUT_ALIASES
|
|
878
878
|
|
|
879
|
-
# Issue #1429 — {#forget_guard_narrowings} for the one name `[kind, name]`.
|
|
879
|
+
# Issue #1429 — {#forget_guard_narrowings} for the one name `[kind, name]`. Issue #1596 — an `:ivar` kind too,
|
|
880
|
+
# for a self-call proven to write only some instance variables.
|
|
880
881
|
def forget_guard_narrowing_of(kind, name)
|
|
881
882
|
key = [kind, name]
|
|
882
883
|
pre_guard = @guard_records[key]
|
|
883
884
|
return self if pre_guard.nil?
|
|
884
885
|
|
|
885
|
-
if kind == :
|
|
886
|
+
if kind == :ivar
|
|
887
|
+
current = @ivars[name]
|
|
888
|
+
ivars = current ? @ivars.merge(name => Type::Combinator.union(pre_guard, current)).freeze : @ivars
|
|
889
|
+
rebuild(ivars: ivars, guard_records: @guard_records.except(key).freeze)
|
|
890
|
+
elsif kind == :global
|
|
886
891
|
current = @globals[name]
|
|
887
892
|
globals = current ? @globals.merge(name => Type::Combinator.union(pre_guard, current)).freeze : @globals
|
|
888
893
|
rebuild(globals: globals, guard_records: @guard_records.except(key).freeze)
|
|
@@ -1921,10 +1926,10 @@ module Rigor
|
|
|
1921
1926
|
private_constant :EMPTY_PARAMETER_ENVELOPES
|
|
1922
1927
|
|
|
1923
1928
|
# v0.1.2 — per-class table mapping `method_name (Symbol) → :public | :private | :protected`. Populated by
|
|
1924
|
-
# `ScopeIndexer` for every `def` it sees inside a class body, with the visibility taken from
|
|
1925
|
-
# `private` / `
|
|
1926
|
-
#
|
|
1927
|
-
# surface a diagnostic.
|
|
1929
|
+
# `ScopeIndexer` for every `def` and `attr_*` member it sees inside a class body, with the visibility taken from
|
|
1930
|
+
# a wrapping `private def` / `private attr_reader` call, else the surrounding `private` / `protected` / `public`
|
|
1931
|
+
# modifier state, plus any post-hoc `private :name, ...` named-argument calls. Consumed by the
|
|
1932
|
+
# `def.method-visibility-mismatch` rule so explicit-non-self calls to a private method surface a diagnostic.
|
|
1928
1933
|
def discovered_method_visibility(class_name, method_name)
|
|
1929
1934
|
table = @discovery.discovered_method_visibilities[class_name.to_s]
|
|
1930
1935
|
return nil unless table
|