rigortype 0.4.3 → 0.4.5
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 +5 -5
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +104 -15
- data/docs/manual/02-cli-reference.md +43 -12
- data/docs/manual/03-configuration.md +20 -7
- data/docs/manual/04-diagnostics.md +56 -3
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +3 -2
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +40 -4
- data/docs/manual/18-removing-dead-code.md +23 -2
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-rspec.md +101 -1
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
- data/lib/rigor/analysis/check_rules.rb +274 -51
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +61 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +270 -28
- data/lib/rigor/analysis/path_expansion.rb +3 -1
- data/lib/rigor/analysis/reachability/graph.rb +111 -14
- data/lib/rigor/analysis/reachability/scan.rb +305 -23
- data/lib/rigor/analysis/rule_catalog.rb +22 -4
- 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 +179 -31
- data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
- data/lib/rigor/analysis/runner.rb +102 -72
- data/lib/rigor/analysis/severity_stamp.rb +45 -1
- data/lib/rigor/analysis/worker_session.rb +69 -2
- data/lib/rigor/cache/descriptor.rb +79 -12
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +39 -5
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +32 -11
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +163 -22
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +29 -3
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +67 -11
- data/lib/rigor/inference/block_call_timing.rb +12 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/captured_locals.rb +67 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/definer_resolution.rb +101 -25
- data/lib/rigor/inference/expression_typer.rb +94 -9
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher.rb +67 -3
- data/lib/rigor/inference/module_function_state.rb +213 -51
- data/lib/rigor/inference/narrowing.rb +235 -75
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +120 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +414 -1
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer.rb +1061 -122
- data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +512 -150
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/lens/anchor.rb +3 -3
- data/lib/rigor/lens/contract_reader.rb +60 -2
- data/lib/rigor/lens/document.rb +4 -3
- data/lib/rigor/lens/file_reader.rb +452 -73
- data/lib/rigor/lens/members.rb +81 -17
- data/lib/rigor/lens/run.rb +73 -4
- data/lib/rigor/lens.rb +2 -1
- data/lib/rigor/mcp/server.rb +63 -22
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/project_environment.rb +6 -3
- data/lib/rigor/protection/discovery_seed.rb +9 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +27 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +56 -8
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +167 -22
- data/lib/rigor/sig_gen/layout_index.rb +40 -1
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +137 -11
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/renderer.rb +13 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +50 -35
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/constant.rb +10 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- 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-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -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/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +4 -3
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +16 -1
data/lib/rigor/lens/members.rb
CHANGED
|
@@ -14,10 +14,13 @@ module Rigor
|
|
|
14
14
|
# @param contracts — `{[owner, scope, name] => ContractReader::Contract or nil}`
|
|
15
15
|
# @param elsewhere — callable `(owner, scope, name) -> [{path, line}]`: other project files defining it
|
|
16
16
|
# @param site_paths — `{site key => absolute path of its file}`, to match an inline contract's buffer
|
|
17
|
-
|
|
17
|
+
# @param defaulted_blocks — `{[owner, scope, name] => {required, type}}`: the block a member's otherwise
|
|
18
|
+
# defaulted inline declaration declares (ContractReader#read_defaulted_block)
|
|
19
|
+
def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:, defaulted_blocks: {})
|
|
18
20
|
@sites = sites
|
|
19
21
|
@omissions = omissions
|
|
20
22
|
@contracts = contracts
|
|
23
|
+
@defaulted_blocks = defaulted_blocks
|
|
21
24
|
@elsewhere = elsewhere
|
|
22
25
|
@site_paths = site_paths
|
|
23
26
|
end
|
|
@@ -25,25 +28,36 @@ module Rigor
|
|
|
25
28
|
def build
|
|
26
29
|
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
27
30
|
@sites.each do |site|
|
|
28
|
-
site.data
|
|
31
|
+
Members.scopes_of(site.data).each do |scope|
|
|
32
|
+
site.data["members"].each { |name| groups[[site.data["owner"], scope, name]] << site }
|
|
33
|
+
end
|
|
29
34
|
end
|
|
35
|
+
@groups = groups
|
|
30
36
|
result = groups.sort_by { |key, _| key }.map { |key, sites| member(key, sites) }
|
|
31
37
|
mark_inline_sites
|
|
32
38
|
result
|
|
33
39
|
end
|
|
34
40
|
|
|
41
|
+
# The scopes a site's members live in: a module function (`module_function`) defines a private instance method
|
|
42
|
+
# and a public singleton copy from one `def`.
|
|
43
|
+
def self.scopes_of(data)
|
|
44
|
+
data["module_function"] ? %w[instance singleton] : [data["scope"]]
|
|
45
|
+
end
|
|
46
|
+
|
|
35
47
|
private
|
|
36
48
|
|
|
37
49
|
def member(key, sites)
|
|
38
50
|
owner, scope, name = key
|
|
39
51
|
contract = @contracts[key]
|
|
40
52
|
also = @elsewhere.call(owner, scope, name)
|
|
41
|
-
omitted = @omissions.count
|
|
53
|
+
omitted = @omissions.count do |omission|
|
|
54
|
+
[omission["owner"], omission["scope"]] == [owner, scope] && [name, nil].include?(omission["name"])
|
|
55
|
+
end
|
|
42
56
|
single = sites.size == 1 && omitted.zero? && also.empty? ? sites.first : nil
|
|
43
57
|
{ "owner" => owner, "scope" => scope, "name" => name, "kind" => member_kind(sites),
|
|
44
|
-
"visibility" => common_visibility(sites), "sites" => sites.map { |site| site.data["key"] },
|
|
58
|
+
"visibility" => common_visibility(sites, scope), "sites" => sites.map { |site| site.data["key"] },
|
|
45
59
|
"also_defined_at" => also, "basis" => basis(contract, single),
|
|
46
|
-
"overloads" => overloads(contract, sites.first, name, single) }
|
|
60
|
+
"overloads" => overloads(contract, sites.first, name, single, scope) }
|
|
47
61
|
end
|
|
48
62
|
|
|
49
63
|
# A member a `def` defines anywhere in the requested files is a method, whatever `attr_*` also names it.
|
|
@@ -58,28 +72,32 @@ module Rigor
|
|
|
58
72
|
"contested"
|
|
59
73
|
end
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
# A module function's singleton copy is public; its instance method carries the site's (private) visibility.
|
|
76
|
+
def common_visibility(sites, scope)
|
|
77
|
+
values = sites.map do |site|
|
|
78
|
+
copy = site.data["module_function"] && scope == "singleton"
|
|
79
|
+
copy ? site.data["singleton_visibility"] : site.data["visibility"]
|
|
80
|
+
end.uniq
|
|
63
81
|
values.size == 1 ? values.first : nil
|
|
64
82
|
end
|
|
65
83
|
|
|
66
84
|
# A contested member without a contract has no overloads: its definitions may differ in arity, and each
|
|
67
85
|
# method site carries its own parameter list.
|
|
68
|
-
def overloads(contract, site, name, single)
|
|
86
|
+
def overloads(contract, site, name, single, scope)
|
|
69
87
|
return [] if contract.nil? && single.nil?
|
|
70
88
|
|
|
71
|
-
overloads = contract ? contract_overloads(contract, single) : [body_overload(site, name, single)]
|
|
89
|
+
overloads = contract ? contract_overloads(contract, single, scope) : [body_overload(site, name, single, scope)]
|
|
72
90
|
overloads.map { |overload| overload.merge("rbs" => rbs_unless_unavailable(overload)) }
|
|
73
91
|
end
|
|
74
92
|
|
|
75
93
|
# A contract's overloads. A defaulted return (`inferred-return`) is inferred as for a member without a
|
|
76
94
|
# contract, and its `untyped` parameters are unknown: Rigor cannot tell an authored `untyped` from a
|
|
77
95
|
# defaulted one.
|
|
78
|
-
def contract_overloads(contract, single)
|
|
96
|
+
def contract_overloads(contract, single, scope)
|
|
79
97
|
contract.overloads.map do |overload|
|
|
80
98
|
next overload.except("return_rbs") unless contract.return_inferred
|
|
81
99
|
|
|
82
|
-
slot = inferred_return(single)
|
|
100
|
+
slot = inferred_return(single, scope)
|
|
83
101
|
params = overload["params"]&.map do |param|
|
|
84
102
|
param["type"]["type"] == "untyped" ? param.merge("type" => Slot.unavailable("not_computed")) : param
|
|
85
103
|
end
|
|
@@ -92,16 +110,58 @@ module Rigor
|
|
|
92
110
|
|
|
93
111
|
# A member with no contract: its parameters as written, each `untyped` with ADR-75's undeclared-parameter
|
|
94
112
|
# cause, and the return from its one body.
|
|
95
|
-
def body_overload(site, name, single)
|
|
113
|
+
def body_overload(site, name, single, scope)
|
|
96
114
|
return attribute_overload(name, single) if site.data["kind"] == "attribute"
|
|
97
115
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
node = site.data["alias_of"] ? resolved_alias(site) : site.node
|
|
117
|
+
return unknown_overload if node.nil?
|
|
118
|
+
|
|
119
|
+
params, block, head = Parameters.read(node.parameters, yields: Parameters.yields?(node.body))
|
|
120
|
+
slot = inferred_return(single, scope)
|
|
121
|
+
declared = !site.data["alias_of"] && @defaulted_blocks[[site.data["owner"], scope, name]]
|
|
122
|
+
block = declared || block
|
|
123
|
+
block_rbs = block_spelling(block, declared)
|
|
101
124
|
{ "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
|
|
102
125
|
"return" => slot }
|
|
103
126
|
end
|
|
104
127
|
|
|
128
|
+
# A block an otherwise defaulted declaration declares reads as declared (`{ (Integer) -> void }`); one the
|
|
129
|
+
# definition merely takes reads as `?{ (?) -> untyped }`.
|
|
130
|
+
def block_spelling(block, declared)
|
|
131
|
+
return "" if block.nil?
|
|
132
|
+
return " ?{ (?) -> untyped }" unless declared
|
|
133
|
+
|
|
134
|
+
" #{'?' unless declared['required']}{ #{declared['type']['type']} }"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# An alias whose original Rigor cannot pin to one `def`: nothing about its signature is known.
|
|
138
|
+
def unknown_overload
|
|
139
|
+
{ "rbs" => nil, "params" => nil, "block" => nil, "return" => Slot.unavailable("not_computed") }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The `def` an alias site copied, when its original has no other project definition (one site in the
|
|
143
|
+
# requested files, no omission naming it, none elsewhere) and no contract. Otherwise which `def` Ruby copied
|
|
144
|
+
# depends on load order, and `check` reads the last one it saw.
|
|
145
|
+
def resolved_alias(site)
|
|
146
|
+
node = site.data["aliased_node"]
|
|
147
|
+
return nil if node.nil?
|
|
148
|
+
|
|
149
|
+
owner = site.data["owner"]
|
|
150
|
+
old_name = site.data["alias_of"]
|
|
151
|
+
omitted = @omissions.any? do |omission|
|
|
152
|
+
[omission["owner"], omission["scope"]] == [owner, "instance"] && [old_name, nil].include?(omission["name"])
|
|
153
|
+
end
|
|
154
|
+
olds = @groups.fetch([owner, "instance", old_name], [])
|
|
155
|
+
return nil if omitted || olds.size != 1 || !@elsewhere.call(owner, "instance", old_name).empty?
|
|
156
|
+
# The original's contract is not the alias's: whether a declaration carries it over (`alias` in RBS) or
|
|
157
|
+
# not (`alias_method` under rbs-inline), the alias's undeclared parameters would contradict it.
|
|
158
|
+
return nil if @contracts[[owner, "instance", old_name]]
|
|
159
|
+
# An alias of an alias holds only if the alias it copied does.
|
|
160
|
+
return nil if olds.first.data["alias_of"] && resolved_alias(olds.first).nil?
|
|
161
|
+
|
|
162
|
+
node
|
|
163
|
+
end
|
|
164
|
+
|
|
105
165
|
# An attribute without a contract: Rigor does not report the ivar's type in revision 1.
|
|
106
166
|
def attribute_overload(name, _single)
|
|
107
167
|
unknown = Slot.unavailable("not_computed")
|
|
@@ -113,10 +173,14 @@ module Rigor
|
|
|
113
173
|
end
|
|
114
174
|
end
|
|
115
175
|
|
|
116
|
-
|
|
176
|
+
# A module function's singleton copy has its own return reading (`singleton_body_return`).
|
|
177
|
+
def inferred_return(single, scope)
|
|
117
178
|
return Slot.unavailable("contested") unless single
|
|
118
179
|
|
|
119
|
-
|
|
180
|
+
return Slot.unavailable("not_computed") if single.data["alias_of"] && resolved_alias(single).nil?
|
|
181
|
+
|
|
182
|
+
copy = single.data["module_function"] && scope == "singleton"
|
|
183
|
+
body = copy ? single.data["singleton_body_return"] : single.data["body_return"]
|
|
120
184
|
return Slot.unavailable("not_computed") if body.nil?
|
|
121
185
|
return body if Slot.unavailable?(body)
|
|
122
186
|
|
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)
|
|
@@ -78,6 +78,7 @@ module Rigor
|
|
|
78
78
|
def reset_captures
|
|
79
79
|
@captured = {}
|
|
80
80
|
@contracts = {}
|
|
81
|
+
@defaulted_blocks = {}
|
|
81
82
|
@site_paths = {}
|
|
82
83
|
end
|
|
83
84
|
|
|
@@ -127,16 +128,84 @@ module Rigor
|
|
|
127
128
|
spell: speller(scope.environment)).read
|
|
128
129
|
reader.sites.each do |site|
|
|
129
130
|
@site_paths[site.data["key"]] = absolute
|
|
130
|
-
site
|
|
131
|
+
read_constant_value(site, scope) if site.data["kind"] == "constant"
|
|
132
|
+
Members.scopes_of(site.data).product(site.data["members"]).each do |member_scope, name|
|
|
133
|
+
read_contract(site.data["owner"], member_scope, name, scope)
|
|
134
|
+
end
|
|
131
135
|
end
|
|
132
136
|
@captured[absolute] = reader
|
|
133
137
|
end
|
|
134
138
|
|
|
139
|
+
# The constant's type as `check` reads it ({Reflection.constant_type_for}): the value the project's source
|
|
140
|
+
# publishes for the name (every write joined, a mutated one widened) over any declaration, else its RBS
|
|
141
|
+
# declaration, else unknown.
|
|
142
|
+
def read_constant_value(site, scope)
|
|
143
|
+
name = site.data["name"]
|
|
144
|
+
published = scope.in_source_constants[name]
|
|
145
|
+
site.data["value"] =
|
|
146
|
+
if published
|
|
147
|
+
published_slot(published, scope.environment)
|
|
148
|
+
else
|
|
149
|
+
declared_slot(site, contract_reader(scope.environment).read_constant(name, scope.environment), scope)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The rbs-inline plugin declares every constant of a file it reads, typing an unannotated one from its
|
|
154
|
+
# literal (`[]` as `Array[untyped]`), and reads only a `#:` trailing the assignment as its type: an inline
|
|
155
|
+
# declaration is the constant's own only when that is the site's one contract comment. Otherwise it is the
|
|
156
|
+
# plugin's default, which `check` reads all the same: that type, `inferred`. A `Class.new` / `Module.new`
|
|
157
|
+
# value is the exception, which `check` types through its own write reading, not the default `untyped`.
|
|
158
|
+
# An inline declaration from another file says nothing about this site: when the name is written in two
|
|
159
|
+
# files, the one the environment kept decides, and this site's own comment may say otherwise.
|
|
160
|
+
def declared_slot(site, declared, scope)
|
|
161
|
+
return Slot.unavailable("not_computed") if declared.nil?
|
|
162
|
+
return declared unless declared["provenance"] == "inline"
|
|
163
|
+
return Slot.unavailable("not_computed") unless own_inline_declaration?(site, scope)
|
|
164
|
+
return declared if trailing_type?(site)
|
|
165
|
+
return Slot.unavailable("not_computed") if anonymous_namespace?(site.node.value)
|
|
166
|
+
|
|
167
|
+
published_slot(scope.environment.constant_for_name(site.data["name"]), scope.environment)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def own_inline_declaration?(site, scope)
|
|
171
|
+
buffer = scope.environment.rbs_loader&.constant_declaration(site.data["name"])&.last
|
|
172
|
+
path = @site_paths.fetch(site.data["key"])
|
|
173
|
+
buffer&.end_with?(":#{path}") || false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def anonymous_namespace?(node)
|
|
177
|
+
return false unless node.is_a?(Prism::Node)
|
|
178
|
+
return true if node.is_a?(Prism::CallNode) && node.name == :new && node.receiver &&
|
|
179
|
+
%w[Class Module].include?(node.receiver.slice.delete_prefix("::"))
|
|
180
|
+
|
|
181
|
+
node.compact_child_nodes.any? { |child| anonymous_namespace?(child) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def trailing_type?(site)
|
|
185
|
+
comments = site.contract_comments
|
|
186
|
+
comments.size == 1 && comments.first.location.start_line == site.node.location.end_line &&
|
|
187
|
+
comments.first.location.slice.start_with?("#:")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def published_slot(type, environment)
|
|
191
|
+
return Slot.unavailable("not_computed") if type.nil?
|
|
192
|
+
return Slot.dynamic("inferred", nil) if type.is_a?(Type::Dynamic)
|
|
193
|
+
|
|
194
|
+
spelled = speller(environment).call(type, nil)
|
|
195
|
+
spelled == "untyped" ? Slot.dynamic("inferred", nil) : Slot.typed(spelled, "inferred")
|
|
196
|
+
rescue StandardError
|
|
197
|
+
Slot.unavailable("not_computed")
|
|
198
|
+
end
|
|
199
|
+
|
|
135
200
|
def read_contract(owner, member_scope, name, scope)
|
|
136
201
|
key = [owner, member_scope, name]
|
|
137
202
|
return if @contracts.key?(key)
|
|
138
203
|
|
|
139
|
-
|
|
204
|
+
reader = contract_reader(scope.environment)
|
|
205
|
+
@contracts[key] = reader.read(owner, member_scope.to_sym, name, scope)
|
|
206
|
+
return if @contracts[key]
|
|
207
|
+
|
|
208
|
+
@defaulted_blocks[key] = reader.read_defaulted_block(owner, member_scope.to_sym, name, scope)
|
|
140
209
|
end
|
|
141
210
|
|
|
142
211
|
def target(entry, diagnostics, session)
|
|
@@ -187,7 +256,7 @@ module Rigor
|
|
|
187
256
|
sites = readers.values.flat_map(&:sites)
|
|
188
257
|
omissions = readers.values.flat_map(&:omissions)
|
|
189
258
|
requested = readers.keys.to_set
|
|
190
|
-
Members.new(sites: sites, omissions: omissions, contracts: @contracts,
|
|
259
|
+
Members.new(sites: sites, omissions: omissions, contracts: @contracts, defaulted_blocks: @defaulted_blocks,
|
|
191
260
|
elsewhere: elsewhere(session, requested), site_paths: @site_paths).build
|
|
192
261
|
end
|
|
193
262
|
|
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 " \
|
|
@@ -109,8 +128,8 @@ module Rigor
|
|
|
109
128
|
properties: {
|
|
110
129
|
rule: {
|
|
111
130
|
type: "string",
|
|
112
|
-
description: "Rule ID, legacy alias, or family prefix (call, flow, assert, dump,
|
|
113
|
-
"Omit to list every rule."
|
|
131
|
+
description: "Rule ID, legacy alias, or family prefix (call, flow, def, assert, dump, global, " \
|
|
132
|
+
"suppression, static, effect). Omit to list every rule."
|
|
114
133
|
}
|
|
115
134
|
}
|
|
116
135
|
}
|
|
@@ -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
|
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "../cache/engine_source"
|
|
|
13
13
|
# plugin class body can be evaluated before anything else pulled the cache layer in.
|
|
14
14
|
require_relative "../cache/store"
|
|
15
15
|
require_relative "../source/node_walker"
|
|
16
|
+
require_relative "../rooted_glob"
|
|
16
17
|
|
|
17
18
|
module Rigor
|
|
18
19
|
module Plugin
|
|
@@ -1003,7 +1004,7 @@ module Rigor
|
|
|
1003
1004
|
# plugin code calls `watch:`.
|
|
1004
1005
|
#
|
|
1005
1006
|
# @param roots — search roots (relative to the project root, or absolute paths)
|
|
1006
|
-
# @param patterns — glob suffixes
|
|
1007
|
+
# @param patterns — glob suffixes matched under each root, the root read literally (#1956).
|
|
1007
1008
|
# Multiple patterns union into one descriptor (`"**/*.erb", "**/*.html"` etc.).
|
|
1008
1009
|
def glob_descriptor(roots, *patterns)
|
|
1009
1010
|
files = collect_glob_files(Array(roots), patterns)
|
|
@@ -1163,7 +1164,7 @@ module Rigor
|
|
|
1163
1164
|
absolute = File.absolute_path(root.to_s)
|
|
1164
1165
|
next [] unless File.directory?(absolute)
|
|
1165
1166
|
|
|
1166
|
-
patterns.flat_map { |pattern|
|
|
1167
|
+
patterns.flat_map { |pattern| RootedGlob.glob(absolute, pattern) }
|
|
1167
1168
|
end
|
|
1168
1169
|
matched.uniq.sort.select { |path| File.file?(path) }
|
|
1169
1170
|
end
|
|
@@ -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
|