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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +16 -0
  5. data/docs/manual/02-cli-reference.md +4 -3
  6. data/docs/manual/03-configuration.md +16 -5
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/07-plugins.md +1 -1
  9. data/docs/manual/10-mcp-server.md +32 -3
  10. data/docs/manual/18-removing-dead-code.md +11 -1
  11. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  12. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  13. data/exe/rigor +12 -17
  14. data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
  15. data/lib/rigor/analysis/check_rules.rb +46 -36
  16. data/lib/rigor/analysis/incremental.rb +12 -0
  17. data/lib/rigor/analysis/incremental_session.rb +121 -21
  18. data/lib/rigor/analysis/reachability/graph.rb +23 -3
  19. data/lib/rigor/analysis/reachability/scan.rb +51 -9
  20. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  21. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  22. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  26. data/lib/rigor/analysis/runner.rb +35 -9
  27. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  28. data/lib/rigor/analysis/worker_session.rb +57 -1
  29. data/lib/rigor/cache/descriptor.rb +40 -6
  30. data/lib/rigor/cache/incremental_snapshot.rb +20 -4
  31. data/lib/rigor/cli/mcp_command.rb +1 -1
  32. data/lib/rigor/config_audit.rb +111 -15
  33. data/lib/rigor/configuration/dependencies.rb +19 -11
  34. data/lib/rigor/environment/rbs_loader.rb +46 -10
  35. data/lib/rigor/inference/block_call_timing.rb +5 -0
  36. data/lib/rigor/inference/captured_locals.rb +26 -3
  37. data/lib/rigor/inference/definer_resolution.rb +16 -7
  38. data/lib/rigor/inference/expression_typer.rb +59 -4
  39. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  40. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  41. data/lib/rigor/inference/module_function_state.rb +80 -13
  42. data/lib/rigor/inference/narrowing.rb +184 -68
  43. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  44. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  45. data/lib/rigor/inference/scope_indexer.rb +439 -70
  46. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  47. data/lib/rigor/inference/statement_evaluator.rb +84 -29
  48. data/lib/rigor/lens/anchor.rb +3 -3
  49. data/lib/rigor/lens/contract_reader.rb +15 -0
  50. data/lib/rigor/lens/document.rb +2 -2
  51. data/lib/rigor/lens/file_reader.rb +442 -73
  52. data/lib/rigor/lens/members.rb +65 -15
  53. data/lib/rigor/lens/run.rb +44 -2
  54. data/lib/rigor/lens.rb +2 -1
  55. data/lib/rigor/mcp/server.rb +61 -20
  56. data/lib/rigor/plugin/box_probe.rb +154 -7
  57. data/lib/rigor/project_environment.rb +6 -3
  58. data/lib/rigor/scope.rb +11 -6
  59. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  60. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  61. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  62. data/lib/rigor/sig_gen/writer.rb +47 -34
  63. data/lib/rigor/version.rb +1 -1
  64. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  65. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  66. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  67. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  68. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  69. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  70. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  71. metadata +4 -1
@@ -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["members"].each { |name| groups[[site.data["owner"], site.data["scope"], name]] << 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
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 { |omission| [omission["owner"], omission["scope"], omission["name"]] == key }
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
- def common_visibility(sites)
62
- values = sites.map { |site| site.data["visibility"] }.uniq
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
- params, block, head = Parameters.read(site.node.parameters, yields: Parameters.yields?(site.node.body))
99
- slot = inferred_return(single)
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
- def inferred_return(single)
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
- body = single.data["body_return"]
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
 
@@ -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.data["members"].each { |name| read_contract(site.data["owner"], site.data["scope"], name, scope) }
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 body_return diagnostics inputs].freeze
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
 
@@ -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, { content: [{ type: "text", text: text }], isError: is_error })
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 += Array(args["paths"])
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
- argv << "#{args['file']}:#{args['line']}:#{args['col']}"
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 += Array(args["paths"])
222
- argv
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 << args["file"]
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 += Array(args["paths"])
237
- argv
280
+ argv + ["--", *Array(args["paths"])]
238
281
 
239
282
  when "rigor_explain"
240
283
  argv = ["explain", "--format=json"]
241
- argv << args["rule"] if args["rule"]
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 += Array(args["paths"])
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
- "Ruby #{RUBY_VERSION} crashes running Ractor-shareable procs inside Ruby::Box " \
52
- "(Ruby Bug #22260; a Ruby carrying its fix is needed)"
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 (see {.project_patched_methods}); nil keeps
60
- # the unpatched universe.
61
- def build(configuration:, source_files:, feature_sources: {}, project_patched_methods: nil)
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 == :global
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 the surrounding
1925
- # `private` / `protected` / `public` modifier state plus any post-hoc `private :name, ...` named-argument
1926
- # calls. Consumed by the `def.method-visibility-mismatch` rule so explicit-non-self calls to a private method
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