rigortype 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Lens
5
+ # Tagged type slots and RBS-buffer provenance (docs/internal-spec/lens.md § Slots).
6
+ module Slot
7
+ module_function
8
+
9
+ def typed(type, provenance, path: nil, from_site: nil)
10
+ { "state" => "typed", "type" => type, "provenance" => provenance, "path" => path, "from_site" => from_site }
11
+ end
12
+
13
+ def dynamic(provenance, cause, path: nil, from_site: nil)
14
+ { "state" => "dynamic", "type" => "untyped", "provenance" => provenance, "path" => path,
15
+ "from_site" => from_site, "cause" => cause&.to_s }
16
+ end
17
+
18
+ def unavailable(reason)
19
+ { "state" => "unavailable", "reason" => reason }
20
+ end
21
+
22
+ def unavailable?(slot)
23
+ slot.is_a?(Hash) && slot["state"] == "unavailable"
24
+ end
25
+
26
+ # A slot for an RBS type string read from a contract. `untyped` is a dynamic slot whose cause is the
27
+ # authored contract.
28
+ def from_rbs(type, provenance, path)
29
+ return dynamic(provenance, :explicit_untyped, path: path) if type == "untyped"
30
+
31
+ typed(type, provenance, path: path)
32
+ end
33
+
34
+ # The RBS type a slot puts in an overload's `rbs` string: an unavailable slot reads `untyped`.
35
+ def rbs_spelling(slot)
36
+ slot["state"] == "typed" ? slot["type"] : "untyped"
37
+ end
38
+ end
39
+
40
+ # Maps an RBS member's buffer name to a provenance (§ Slots).
41
+ class Provenance
42
+ CACHED_BUFFER = "<cached>"
43
+
44
+ # @param root — the project root (absolute)
45
+ # @param signature_roots — the project signature roots (absolute)
46
+ # @param plugin_roots — `{absolute signature directory => plugin id}` of the loaded plugins
47
+ def initialize(root:, signature_roots:, plugin_roots:)
48
+ @root = root
49
+ @signature_roots = signature_roots
50
+ @plugin_roots = plugin_roots
51
+ end
52
+
53
+ # [provenance, path] for a buffer name, or nil when the buffer cannot be classified.
54
+ def classify(buffer_name)
55
+ name = buffer_name.to_s
56
+ return ["inline", nil] if name.start_with?("virtual:rbs-inline:")
57
+ return ["plugin:#{name.split(':', 3)[1]}", nil] if name.start_with?("virtual:")
58
+ return nil if name.empty? || name == CACHED_BUFFER
59
+
60
+ absolute = File.expand_path(name, @root)
61
+ plugin = @plugin_roots.find { |root, _| under?(absolute, [root]) }
62
+ return ["plugin:#{plugin.last}", nil] if plugin
63
+ return ["sig", relative(absolute)] if under?(absolute, @signature_roots)
64
+
65
+ ["library", nil]
66
+ end
67
+
68
+ def relative(absolute)
69
+ Pathname(absolute).relative_path_from(Pathname(@root)).to_s
70
+ end
71
+
72
+ private
73
+
74
+ def under?(absolute, roots)
75
+ roots.any? { |root| absolute == root || absolute.start_with?("#{root}/") }
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/rigor/lens.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hashing/xxh3"
4
+
5
+ module Rigor
6
+ # `rigor lens`: a declaration map of the requested files with type provenance, computed as `rigor check` computes
7
+ # them (ADR-113; the output contract is docs/internal-spec/lens.md).
8
+ module Lens
9
+ SCHEMA = "lens/v0"
10
+ # Bumped on every breaking change to the `lens/v0` document (docs/internal-spec/lens.md § Status).
11
+ REVISION = 1
12
+ CAPABILITIES = %w[class module method attribute constant module_function builder alias body_return
13
+ diagnostics inputs].freeze
14
+
15
+ module_function
16
+
17
+ # A Prism location as the document's span object: 1-based lines, 0-based byte columns and offsets.
18
+ def span(location)
19
+ { "start_line" => location.start_line, "start_column" => location.start_column,
20
+ "end_line" => location.end_line, "end_column" => location.end_column,
21
+ "start_offset" => location.start_offset, "end_offset" => location.end_offset }
22
+ end
23
+
24
+ # The span from the first to the last of a site's contract comments, or nil when it has none.
25
+ def contract_span(comments)
26
+ return nil if comments.empty?
27
+
28
+ first = comments.first.location
29
+ last = comments.last.location
30
+ { "start_line" => first.start_line, "start_column" => first.start_column, "end_line" => last.end_line,
31
+ "end_column" => last.end_column, "start_offset" => first.start_offset, "end_offset" => last.end_offset }
32
+ end
33
+
34
+ def digest(bytes)
35
+ "xxh3-64:#{Hashing::XXH3.file_hash(bytes)}"
36
+ end
37
+ end
38
+ end
39
+
40
+ require_relative "lens/run"
41
+ require_relative "lens/document"
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "environment"
4
+ require_relative "inference/project_patched_scanner"
4
5
  require_relative "plugin"
5
6
  require_relative "plugin/loader"
6
7
  require_relative "plugin/services"
@@ -55,9 +56,15 @@ module Rigor
55
56
  # `source_rbs_synthesizer` runs over them at env-build time; an empty list contributes no synthesized RBS.
56
57
  # @param feature_sources — path => source text the required-feature scan reads in place of that file (an
57
58
  # editor buffer bound with `--tmp-file` / `--instead-of`).
58
- def build(configuration:, source_files:, feature_sources: {})
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))
59
65
  features = required_features(configuration, source_files, sources: feature_sources)
60
66
  Environment.for_project(
67
+ project_patched_methods: project_patched_methods,
61
68
  libraries: configuration.libraries,
62
69
  signature_paths: configuration.signature_paths,
63
70
  plugin_registry: load_plugin_registry(configuration),
@@ -69,6 +76,21 @@ module Rigor
69
76
  bare(configuration, features)
70
77
  end
71
78
 
79
+ # The patched-method registry `rigor check` types against for the project's `pre_eval:` files. It depends
80
+ # on those files alone, so it is built by the same {Inference::ProjectPatchedScanner} the Runner's project
81
+ # pre-pass calls, over the same resolved `configuration.pre_eval` entries (glob-expanded at config load,
82
+ # missing files skipped), without the pre-pass's plugin load / `#prepare` / dependency-source work. An
83
+ # editor `buffer` is honoured, so a `--tmp-file` edit of a pre_eval file is read in flight. Nil when the
84
+ # project lists no `pre_eval:` entry, so a project without one stays byte-identical. Fail-soft.
85
+ def project_patched_methods(configuration, buffer: nil)
86
+ return nil if configuration.pre_eval.empty?
87
+
88
+ existing = configuration.pre_eval.select { |path| File.file?(path) }
89
+ Inference::ProjectPatchedScanner.scan(existing, buffer: buffer).registry
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
72
94
  # Issue #1700 — the {Environment::RequiredFeatures} the environment gates on: the configured paths plus
73
95
  # `extra_files` (the file a probe inspects, which may lie outside them), the same set `rigor check` scans
74
96
  # whichever files it is given, so a position types as `rigor check` types it.
@@ -84,7 +84,8 @@ module Rigor
84
84
 
85
85
  # Human-readable reasons the caller prints. Each names something the operator can act on.
86
86
  NO_SNAPSHOT = "no reusable `rigor check --incremental` snapshot"
87
- OPAQUE_PLUGIN = "a plugin contributes types with no incremental fingerprint surface"
87
+ OPAQUE_PLUGIN = "a plugin is opaque to the incremental fingerprint " \
88
+ "(no fingerprint surface, or a value it cannot digest)"
88
89
  UNDIGESTIBLE = "the project-scan tables could not be digested"
89
90
  UNIDENTIFIED_ENGINE = "the engine's own source tree could not be digested"
90
91
 
data/lib/rigor/scope.rb CHANGED
@@ -251,11 +251,11 @@ module Rigor
251
251
  # class in a project that publishes nothing) allocates none.
252
252
  EMPTY_PUBLISHED_CONSTANT_IVARS = Set.new.freeze
253
253
  # The index `||=` sites of a repeating block body whose slot an earlier run of the body may have filled,
254
- # keyed by node identity and laid by a block-return pass at the body's entry
255
- # ({#with_repeated_or_writes}; `Inference::RepeatedOrWrites` decides which). The memoizing `||=` reading
256
- # (`StatementEvaluator#index_compound_write_value`) is withheld at such a site. The mark belongs to the
257
- # site, so no rebind or narrowing of a variable drops it, and a join keeps a site either arm holds: the
258
- # mark only ever withholds that reading, whose answer is the narrower one.
254
+ # keyed by node identity and laid at the body's entry by a block-return pass or by `StatementEvaluator`'s
255
+ # statement and write-back passes ({#with_repeated_or_writes}; `Inference::RepeatedOrWrites` decides which).
256
+ # The memoizing `||=` reading (`StatementEvaluator#index_compound_write_value`) is withheld at such a site.
257
+ # The mark belongs to the site, so no rebind or narrowing of a variable drops it, and a join keeps a site
258
+ # either arm holds: the mark only ever withholds that reading, whose answer is the narrower one.
259
259
  EMPTY_REPEATED_OR_WRITES = {}.compare_by_identity.freeze
260
260
  # Issue #1429 — the narrowing a guard leaves on a constant reference (`STDOUT.is_a?(StringIO)`, `if CONFIG`),
261
261
  # keyed by the reference's spelling ({Inference::Narrowing.constant_key}: `"STDOUT"`, `"::Foo::BAR"`). A constant
@@ -457,6 +457,13 @@ module Rigor
457
457
  @locals[name.to_sym]
458
458
  end
459
459
 
460
+ # Issue #1840 — replaces the joined bindings of locals a branch merge only narrowed with their pre-branch
461
+ # binding ({Inference::UntypedRejoin}). Unlike {#with_local} this is not a write: the value is the one the
462
+ # branches started from, so every fact, narrowing and mark the join kept about it still holds.
463
+ def with_rejoined_locals(bindings)
464
+ rebuild(locals: @locals.merge(bindings).freeze)
465
+ end
466
+
460
467
  def with_local(name, type)
461
468
  bind_local(name, type, keep_marks: false)
462
469
  end
@@ -869,13 +876,18 @@ module Rigor
869
876
  STDOUT_ALIASES = { :$stdout => :$>, :$> => :$stdout }.freeze
870
877
  private_constant :STDOUT_ALIASES
871
878
 
872
- # 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.
873
881
  def forget_guard_narrowing_of(kind, name)
874
882
  key = [kind, name]
875
883
  pre_guard = @guard_records[key]
876
884
  return self if pre_guard.nil?
877
885
 
878
- 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
879
891
  current = @globals[name]
880
892
  globals = current ? @globals.merge(name => Type::Combinator.union(pre_guard, current)).freeze : @globals
881
893
  rebuild(globals: globals, guard_records: @guard_records.except(key).freeze)
@@ -1055,20 +1067,22 @@ module Rigor
1055
1067
  @repeated_or_writes.key?(node)
1056
1068
  end
1057
1069
 
1058
- # Regex match-data globals (`$~`, `$&`, `$1..$9`, the pre/post-match and last-paren back-references). Narrowed
1070
+ # Regex match-data globals (`$~`, `$&`, `$1..$N`, the pre/post-match and last-paren back-references). Narrowed
1059
1071
  # on a successful-`=~` / `case`-`when` match edge (see `Narrowing#regex_match_predicate_scopes`). They live in
1060
1072
  # the method frame's special-variable slot, which the method's blocks and closures share (issue #1358), so a
1061
1073
  # later call that may run a match in this frame — a match-capable call, a block that may match, a call once
1062
1074
  # the frame has made a closure that may — rebinds every one of them, and `eval_call` forgets the narrowed
1063
1075
  # facts here. Always safe — only drops facts, so a subsequent read falls back to the default `String | nil`.
1064
1076
  # Program-level `$GLOBAL = ...` seeds use other names and are untouched.
1065
- MATCH_DATA_GLOBALS = %i[$~ $& $` $' $+ $1 $2 $3 $4 $5 $6 $7 $8 $9].freeze
1066
- private_constant :MATCH_DATA_GLOBALS
1077
+ # Issue #1384 — the numbered groups are matched by name, not listed: a regex may carry more than nine captures, and
1078
+ # `$10` and above are the same slot as `$1`..`$9`, so forgetting must clear them too.
1079
+ MATCH_DATA_GLOBAL_NAME = /\A\$(?:~|&|`|'|\+|[1-9]\d*)\z/
1080
+ private_constant :MATCH_DATA_GLOBAL_NAME
1067
1081
 
1068
1082
  def forget_match_globals
1069
1083
  return self unless match_globals_bound?
1070
1084
 
1071
- rebuild(globals: @globals.except(*MATCH_DATA_GLOBALS).freeze)
1085
+ rebuild(globals: @globals.reject { |name, _| MATCH_DATA_GLOBAL_NAME.match?(name.name) }.freeze)
1072
1086
  end
1073
1087
 
1074
1088
  # Issue #1361 — this scope with every bound match-data global rebound to `Dynamic[top]`, and an unbound one left
@@ -1079,14 +1093,16 @@ module Rigor
1079
1093
  return self unless match_globals_bound?
1080
1094
 
1081
1095
  untyped = Type::Combinator.untyped
1082
- rebound = MATCH_DATA_GLOBALS.each_with_object({}) { |name, acc| acc[name] = untyped if @globals.key?(name) }
1096
+ rebound = @globals.each_with_object({}) do |(name, _), acc|
1097
+ acc[name] = untyped if MATCH_DATA_GLOBAL_NAME.match?(name.name)
1098
+ end
1083
1099
  rebuild(globals: @globals.merge(rebound).freeze)
1084
1100
  end
1085
1101
 
1086
1102
  # True when any match-data global holds a binding, which a match edge or {#untyped_match_globals} makes: the only
1087
1103
  # state {#forget_match_globals} can drop, and so the gate on every scan that decides whether to.
1088
1104
  def match_globals_bound?
1089
- !@globals.empty? && MATCH_DATA_GLOBALS.any? { |name| @globals.key?(name) }
1105
+ !@globals.empty? && @globals.any? { |name, _| MATCH_DATA_GLOBAL_NAME.match?(name.name) }
1090
1106
  end
1091
1107
 
1092
1108
  # Issue #1359 — the last line read, `$_`, lives in the same frame slot as the match globals, and so on the same
@@ -1910,10 +1926,10 @@ module Rigor
1910
1926
  private_constant :EMPTY_PARAMETER_ENVELOPES
1911
1927
 
1912
1928
  # v0.1.2 — per-class table mapping `method_name (Symbol) → :public | :private | :protected`. Populated by
1913
- # `ScopeIndexer` for every `def` it sees inside a class body, with the visibility taken from the surrounding
1914
- # `private` / `protected` / `public` modifier state plus any post-hoc `private :name, ...` named-argument
1915
- # calls. Consumed by the `def.method-visibility-mismatch` rule so explicit-non-self calls to a private method
1916
- # 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.
1917
1933
  def discovered_method_visibility(class_name, method_name)
1918
1934
  table = @discovery.discovered_method_visibilities[class_name.to_s]
1919
1935
  return nil unless table