rigortype 0.1.4 → 0.1.6

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. metadata +92 -1
data/lib/rigor/cli.rb CHANGED
@@ -25,7 +25,8 @@ module Rigor
25
25
  "type-scan" => :run_type_scan,
26
26
  "explain" => :run_explain,
27
27
  "diff" => :run_diff,
28
- "sig-gen" => :run_sig_gen
28
+ "sig-gen" => :run_sig_gen,
29
+ "lsp" => :run_lsp
29
30
  }.freeze
30
31
 
31
32
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -69,29 +70,91 @@ module Rigor
69
70
 
70
71
  def run_check
71
72
  require_relative "analysis/runner"
73
+ require_relative "analysis/buffer_binding"
72
74
  require_relative "cache/store"
73
75
 
74
76
  options = parse_check_options
77
+ buffer = resolve_buffer_binding(options)
78
+ return EXIT_USAGE if buffer == :usage_error
75
79
 
76
80
  configuration = Configuration.load(options.fetch(:config))
77
81
  cache_root = configuration.cache_path
78
82
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
79
- cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
80
83
 
81
- paths = @argv.empty? ? configuration.paths : @argv
82
- runner = Analysis::Runner.new(
83
- configuration: configuration,
84
- explain: options.fetch(:explain),
85
- cache_store: cache_store
84
+ runner = build_check_runner(
85
+ configuration: configuration, options: options,
86
+ buffer: buffer, cache_root: cache_root
86
87
  )
87
- result = runner.run(paths)
88
+ result = runner.run(@argv.empty? ? configuration.paths : @argv)
88
89
 
89
90
  write_result(result, options.fetch(:format))
91
+ write_run_stats(result.stats) if result.stats
90
92
  write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
91
93
  result.success? ? 0 : 1
92
94
  end
93
95
 
94
- def parse_check_options
96
+ def build_check_runner(configuration:, options:, buffer:, cache_root:)
97
+ cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
98
+ Analysis::Runner.new(
99
+ configuration: configuration,
100
+ explain: options.fetch(:explain),
101
+ cache_store: cache_store,
102
+ collect_stats: options.fetch(:stats),
103
+ workers: resolve_workers(options, configuration),
104
+ buffer: buffer
105
+ )
106
+ end
107
+
108
+ # Editor-mode CLI envelope. The `--tmp-file=PATH` /
109
+ # `--instead-of=PATH` pair binds an in-flight buffer file to
110
+ # the logical project path it represents (see
111
+ # `docs/design/20260516-editor-mode.md`). Both flags must
112
+ # appear together; either alone is a usage error. The
113
+ # physical file must be readable; missing-file is a usage
114
+ # error too so editors get one consistent failure shape.
115
+ #
116
+ # Returns:
117
+ # - `nil` when neither flag was supplied (legacy path).
118
+ # - `Rigor::Analysis::BufferBinding` when the pair is valid.
119
+ # - `:usage_error` after writing one diagnostic to stderr;
120
+ # the caller MUST translate this to `EXIT_USAGE`.
121
+ def resolve_buffer_binding(options)
122
+ tmp = options[:tmp_file]
123
+ instead = options[:instead_of]
124
+ return nil if tmp.nil? && instead.nil?
125
+
126
+ if tmp.nil? || instead.nil?
127
+ @err.puts("--tmp-file and --instead-of must appear together")
128
+ return :usage_error
129
+ end
130
+
131
+ unless File.file?(tmp)
132
+ @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
133
+ return :usage_error
134
+ end
135
+
136
+ Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
137
+ end
138
+
139
+ # ADR-15 Phase 4c — resolves the worker count by
140
+ # precedence: CLI `--workers=N` (most explicit) > env
141
+ # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
142
+ # `parallel.workers:` > 0 (sequential default). Returns
143
+ # an Integer; non-numeric values raise so typos fail
144
+ # loudly. CLI / env may pass a negative value — clamped
145
+ # to 0 (sequential) so a stray `-1` doesn't crash the
146
+ # pool spawn loop.
147
+ def resolve_workers(options, configuration)
148
+ cli_value = options[:workers]
149
+ return [Integer(cli_value), 0].max if cli_value
150
+
151
+ env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
152
+ return [Integer(env_value), 0].max if env_value && !env_value.empty?
153
+
154
+ configuration.parallel_workers
155
+ end
156
+
157
+ def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
95
158
  options = {
96
159
  # `nil` triggers `Configuration.discover` (`.rigor.yml` then
97
160
  # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
@@ -100,7 +163,24 @@ module Rigor
100
163
  explain: false,
101
164
  cache_stats: false,
102
165
  clear_cache: false,
103
- no_cache: false
166
+ no_cache: false,
167
+ # Run-stats summary (target files, RBS class universe
168
+ # breakdown, wall time, peak RSS) is on by default
169
+ # because collection is ~free (single syscall for RSS,
170
+ # one walk of `class_decl_paths` for the breakdown).
171
+ # `--no-stats` suppresses it for callers that want a
172
+ # diagnostic-only output stream.
173
+ stats: true,
174
+ # ADR-15 Phase 4c — when nil, falls back to
175
+ # `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
176
+ # `parallel.workers:` then 0 (sequential). See
177
+ # `resolve_workers` for the precedence chain.
178
+ workers: nil,
179
+ # Editor mode (`docs/design/20260516-editor-mode.md`).
180
+ # Both must appear together; the runner uses the pair
181
+ # to bind an in-flight buffer file to its logical path.
182
+ tmp_file: nil,
183
+ instead_of: nil
104
184
  }
105
185
  parser = OptionParser.new do |opts|
106
186
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -110,6 +190,22 @@ module Rigor
110
190
  opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
111
191
  opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
112
192
  opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
193
+ opts.on("--[no-]stats",
194
+ "Print run summary (files, classes, memory, wall time) to stderr (default: on)") do |value|
195
+ options[:stats] = value
196
+ end
197
+ opts.on("--workers=N", Integer,
198
+ "Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
199
+ options[:workers] = value
200
+ end
201
+ opts.on("--tmp-file=PATH",
202
+ "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
203
+ options[:tmp_file] = value
204
+ end
205
+ opts.on("--instead-of=PATH",
206
+ "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
207
+ options[:instead_of] = value
208
+ end
113
209
  end
114
210
  parser.parse!(@argv)
115
211
  options
@@ -124,6 +220,15 @@ module Rigor
124
220
  end
125
221
  end
126
222
 
223
+ # Emits the {Analysis::RunStats} summary to STDERR so it
224
+ # doesn't interleave with the diagnostic stream (text or
225
+ # JSON) on STDOUT. JSON consumers can pipe stdout cleanly;
226
+ # interactive users still see the summary on their tty.
227
+ def write_run_stats(stats)
228
+ @err.puts("")
229
+ stats.format(@err)
230
+ end
231
+
127
232
  def write_cache_stats(cache_root, runtime_store)
128
233
  inv = Cache::Store.disk_inventory(root: cache_root)
129
234
 
@@ -286,6 +391,12 @@ module Rigor
286
391
  SigGenCommand.new(argv: @argv, out: @out, err: @err).run
287
392
  end
288
393
 
394
+ def run_lsp
395
+ require_relative "cli/lsp_command"
396
+
397
+ LspCommand.new(argv: @argv, out: @out, err: @err).run
398
+ end
399
+
289
400
  def write_result(result, format)
290
401
  case format
291
402
  when "json"
@@ -326,6 +437,7 @@ module Rigor
326
437
  explain Print the description of one or all CheckRules
327
438
  diff Compare current diagnostics to a saved baseline JSON
328
439
  sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
440
+ lsp Run the Rigor Language Server (LSP) over stdio
329
441
  version Print the Rigor version
330
442
  help Print this help
331
443
  HELP
@@ -49,6 +49,16 @@ module Rigor
49
49
  "disable" => [],
50
50
  "libraries" => [],
51
51
  "signature_paths" => nil,
52
+ # ADR-17 — project-side monkey-patch pre-evaluation.
53
+ # Empty by default; users opt in by listing explicit files
54
+ # that the analyzer walks before per-file inference so
55
+ # patched-method declarations are visible across the
56
+ # project (e.g. `lib/core_ext/string_extensions.rb`). Slice 1
57
+ # plumbing only — listed files are validated at config-load
58
+ # time (`pre-eval.file-not-found` on a missing path), but
59
+ # the dispatcher tier consuming the registry lands in
60
+ # slice 2.
61
+ "pre_eval" => [],
52
62
  "fold_platform_specific_paths" => false,
53
63
  "cache" => {
54
64
  "path" => ".rigor/cache"
@@ -63,6 +73,81 @@ module Rigor
63
73
  "dependencies" => {
64
74
  "source_inference" => [],
65
75
  "budget_per_gem" => Configuration::Dependencies::DEFAULT_BUDGET_PER_GEM
76
+ },
77
+ "parallel" => {
78
+ # ADR-15 Phase 4c — when greater than zero, `rigor check`
79
+ # dispatches per-file analysis across N Ractor workers
80
+ # built around {Rigor::Analysis::WorkerSession}.
81
+ # `0` (default) keeps the sequential coordinator path
82
+ # bit-for-bit unchanged. The CLI's `--workers=N` flag
83
+ # and the `RIGOR_RACTOR_WORKERS` env var both override
84
+ # this setting; precedence is CLI > env > config > 0.
85
+ "workers" => 0
86
+ },
87
+ "bundler" => {
88
+ # Open item O4 — target-project Bundler awareness.
89
+ # When `bundle_path:` is set (or auto-detected), Rigor
90
+ # walks `<bundle_path>/ruby/*/gems/*/sig/` and adds each
91
+ # gem-shipped sig directory to `signature_paths:`. With
92
+ # O7's failure-memo in place, conflicts (a vendored sig
93
+ # already declares the same constant) degrade gracefully
94
+ # to "no RBS env" with a single-line warning naming the
95
+ # offending file, rather than hanging.
96
+ #
97
+ # `bundle_path:` (String, optional): explicit path to the
98
+ # bundler install root (e.g., "vendor/bundle" or an
99
+ # absolute path). Resolved relative to the project root
100
+ # (`paths:`'s base) when relative.
101
+ #
102
+ # `auto_detect:` (Boolean, default true): when no
103
+ # explicit `bundle_path:` is set, try `.bundle/config`'s
104
+ # `BUNDLE_PATH:` first; fall back to `vendor/bundle/`
105
+ # under the project root if it exists. When neither is
106
+ # found, no extra sigs are added — the analyzer sees
107
+ # only rigor's vendored RBS and the user's
108
+ # `signature_paths:`.
109
+ #
110
+ # O4 Layer 3 keys:
111
+ #
112
+ # `lockfile:` (String, optional): explicit path to a
113
+ # `Gemfile.lock`. Resolved relative to the project root
114
+ # when relative. When set (or auto-detected via the
115
+ # `auto_detect:` flag below) Rigor parses the lockfile
116
+ # and uses it to FILTER the bundle-discovered `sig/`
117
+ # directories: only gems whose `(name, version,
118
+ # platform)` matches a lockfile entry are admitted to
119
+ # `signature_paths:`. Stale or out-of-band gems sitting
120
+ # in the bundle install tree are silently dropped.
121
+ #
122
+ # `auto_detect:` (Boolean, also gates the lockfile
123
+ # search): when true and `lockfile:` is nil, look for
124
+ # `<project_root>/Gemfile.lock`.
125
+ "bundle_path" => nil,
126
+ "auto_detect" => true,
127
+ "lockfile" => nil
128
+ },
129
+ "rbs_collection" => {
130
+ # Open item O4 Layer 3 slice 2 — `rbs collection
131
+ # install` awareness. When the target project has been
132
+ # set up with `rbs collection install`, the resulting
133
+ # `rbs_collection.lock.yaml` carries the resolved (gem,
134
+ # version, source) triples and `.gem_rbs_collection/`
135
+ # holds the downloaded `.rbs` files. Rigor parses the
136
+ # lockfile and auto-feeds each gem's
137
+ # `<collection_root>/<name>/<version>/` directory into
138
+ # `RbsLoader`'s `signature_paths:`. Sources of type
139
+ # `stdlib` are skipped because rigor's bundled
140
+ # `DEFAULT_LIBRARIES` already covers that surface.
141
+ #
142
+ # `lockfile:` (String, optional): explicit path to
143
+ # `rbs_collection.lock.yaml`. Resolved relative to the
144
+ # project root when relative.
145
+ #
146
+ # `auto_detect:` (Boolean, default true): when no
147
+ # explicit `lockfile:` is set, look for
148
+ # `<project_root>/rbs_collection.lock.yaml`.
149
+ "lockfile" => nil,
150
+ "auto_detect" => true
66
151
  }
67
152
  }.freeze
68
153
 
@@ -70,7 +155,7 @@ module Rigor
70
155
  # MUST be resolved relative to the config file's directory.
71
156
  # `exclude:` is intentionally NOT in this list — its entries
72
157
  # are glob patterns (`**/vendor/**`), not paths.
73
- PATH_KEYS = %w[paths signature_paths].freeze
158
+ PATH_KEYS = %w[paths signature_paths pre_eval].freeze
74
159
  private_constant :PATH_KEYS
75
160
 
76
161
  attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
@@ -78,7 +163,10 @@ module Rigor
78
163
  :plugins_io_network, :plugins_io_allowed_paths,
79
164
  :plugins_io_allowed_url_hosts,
80
165
  :severity_profile, :severity_overrides,
81
- :dependencies
166
+ :dependencies, :parallel_workers,
167
+ :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
168
+ :rbs_collection_lockfile, :rbs_collection_auto_detect,
169
+ :pre_eval
82
170
 
83
171
  # Loads a configuration file.
84
172
  #
@@ -214,13 +302,13 @@ module Rigor
214
302
  private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge,
215
303
  :merge_value, :merge_dependencies_hash
216
304
 
217
- # rubocop:disable Metrics/AbcSize
305
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
218
306
  def initialize(data = DEFAULTS)
219
307
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
220
308
  plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
221
309
 
222
310
  @target_ruby = coerce_target_ruby(data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")))
223
- @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
311
+ @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s).freeze
224
312
  user_excludes = Array(data.fetch("exclude", DEFAULTS.fetch("exclude"))).map(&:to_s)
225
313
  @exclude_patterns = (BUILTIN_EXCLUDES + user_excludes).uniq.freeze
226
314
  @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
@@ -230,6 +318,9 @@ module Rigor
230
318
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
231
319
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
232
320
  @signature_paths = sig_paths.nil? ? nil : Array(sig_paths).map(&:to_s).freeze
321
+ @pre_eval = expand_pre_eval_entries(
322
+ Array(data.fetch("pre_eval", DEFAULTS.fetch("pre_eval"))).map(&:to_s)
323
+ )
233
324
  @fold_platform_specific_paths = data.fetch(
234
325
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
235
326
  ) == true
@@ -246,10 +337,32 @@ module Rigor
246
337
  @dependencies = Dependencies.from_h(
247
338
  data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
248
339
  )
340
+ parallel = DEFAULTS.fetch("parallel").merge(data.fetch("parallel", {}))
341
+ @parallel_workers = coerce_parallel_workers(parallel.fetch("workers"))
342
+ bundler = DEFAULTS.fetch("bundler").merge(data.fetch("bundler", {}))
343
+ bp = bundler.fetch("bundle_path")
344
+ @bundler_bundle_path = bp.nil? ? nil : bp.to_s.dup.freeze
345
+ @bundler_auto_detect = bundler.fetch("auto_detect") == true
346
+ lf = bundler.fetch("lockfile")
347
+ @bundler_lockfile = lf.nil? ? nil : lf.to_s.dup.freeze
348
+ rbs_collection = DEFAULTS.fetch("rbs_collection").merge(data.fetch("rbs_collection", {}))
349
+ rclf = rbs_collection.fetch("lockfile")
350
+ @rbs_collection_lockfile = rclf.nil? ? nil : rclf.to_s.dup.freeze
351
+ @rbs_collection_auto_detect = rbs_collection.fetch("auto_detect") == true
352
+ # Ractor migration Phase 2a: deep-freeze the
353
+ # Configuration so it is `Ractor.shareable?`. Every
354
+ # ivar above is now either a frozen value (Symbol /
355
+ # nil / Boolean) or an explicitly frozen
356
+ # collection / value object; freezing `self` makes the
357
+ # whole carrier safe to send across Ractor boundaries
358
+ # (and catches accidental post-init mutation in any
359
+ # caller). See
360
+ # `docs/design/20260514-ractor-migration.md`.
361
+ freeze
249
362
  end
250
- # rubocop:enable Metrics/AbcSize
363
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
251
364
 
252
- def to_h
365
+ def to_h # rubocop:disable Metrics/MethodLength
253
366
  {
254
367
  "target_ruby" => target_ruby,
255
368
  "paths" => paths,
@@ -258,6 +371,7 @@ module Rigor
258
371
  "disable" => disabled_rules,
259
372
  "libraries" => libraries,
260
373
  "signature_paths" => signature_paths,
374
+ "pre_eval" => pre_eval,
261
375
  "fold_platform_specific_paths" => fold_platform_specific_paths,
262
376
  "cache" => {
263
377
  "path" => cache_path
@@ -269,12 +383,45 @@ module Rigor
269
383
  },
270
384
  "severity_profile" => severity_profile.to_s,
271
385
  "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] },
272
- "dependencies" => dependencies.to_h
386
+ "dependencies" => dependencies.to_h,
387
+ "parallel" => {
388
+ "workers" => parallel_workers
389
+ },
390
+ "bundler" => {
391
+ "bundle_path" => bundler_bundle_path,
392
+ "auto_detect" => bundler_auto_detect,
393
+ "lockfile" => bundler_lockfile
394
+ },
395
+ "rbs_collection" => {
396
+ "lockfile" => rbs_collection_lockfile,
397
+ "auto_detect" => rbs_collection_auto_detect
398
+ }
273
399
  }
274
400
  end
275
401
 
276
402
  private
277
403
 
404
+ # ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is
405
+ # accepted as either a literal path (slice 1 contract) OR a
406
+ # `File.fnmatch?`-shaped glob pattern (`lib/core_ext/**/*.rb`).
407
+ # Glob meta characters (`*`, `?`, `[`) trigger `Dir.glob`
408
+ # expansion; the resulting file list is folded into the
409
+ # `pre_eval:` set with `uniq`. Literal entries that don't
410
+ # exist on disk continue to surface as `pre-eval.file-not-found`
411
+ # `:error` (slice 1 behaviour); glob entries that match
412
+ # nothing degrade silently to "no contribution from this
413
+ # entry" so a templated `**` pattern in a fresh project
414
+ # doesn't generate an error per match-less pattern.
415
+ def expand_pre_eval_entries(entries)
416
+ entries.flat_map do |entry|
417
+ glob_pattern?(entry) ? Dir.glob(entry, sort: true) : [entry]
418
+ end.uniq.freeze
419
+ end
420
+
421
+ def glob_pattern?(path)
422
+ path.include?("*") || path.include?("?") || path.include?("[")
423
+ end
424
+
278
425
  # Accepts either `"rigor-foo"` (gem-name shorthand) or
279
426
  # `{ "gem" => "rigor-foo", "id" => "foo", "config" => {...} }`
280
427
  # (full form). Returns the canonical hash form so the loader
@@ -327,6 +474,20 @@ module Rigor
327
474
  VALID_NETWORK_POLICIES = %i[disabled allowlist].freeze
328
475
  private_constant :VALID_NETWORK_POLICIES
329
476
 
477
+ # ADR-15 Phase 4c — accepts a non-negative Integer (or a
478
+ # string-shaped one from YAML files that miss type
479
+ # annotations). Negative / non-integer values raise so
480
+ # typos / bad YAML fail loudly rather than silently
481
+ # disabling parallelism.
482
+ def coerce_parallel_workers(value)
483
+ integer = Integer(value)
484
+ raise ArgumentError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
485
+
486
+ integer
487
+ rescue TypeError, ArgumentError => e
488
+ raise ArgumentError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
489
+ end
490
+
330
491
  def coerce_network_policy(value)
331
492
  sym = value.to_sym
332
493
  unless VALID_NETWORK_POLICIES.include?(sym)
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rigor
6
+ class Environment
7
+ # Open item O4 — target-project Bundler awareness.
8
+ #
9
+ # Walks a Bundler-installed gem tree (e.g., the project's
10
+ # `vendor/bundle` or a Docker-mounted bundle root) and
11
+ # returns the per-gem `sig/` directories to feed into
12
+ # `RbsLoader`'s `signature_paths:`. Of the ~3% of gems that
13
+ # ship `sig/` in their gem package today (per the four-project
14
+ # Mastodon Docker bundle-install measurement on 2026-05-15:
15
+ # 10 of 343 gems shipped sig — `prism`, `aws-sdk-s3`,
16
+ # `aws-sdk-kms`, `aws-sdk-core`, `playwright-ruby-client`,
17
+ # `mutex_m`, `webrick`, `base64`, `stoplight`, `ffi`), this
18
+ # discovery surfaces the typed contract the gem author
19
+ # explicitly published.
20
+ #
21
+ # Conflicts with rigor's bundled stdlib RBS (the prism case
22
+ # was the motivating example) degrade gracefully via O7's
23
+ # failure-memo in `RbsLoader#env`: a single warning naming
24
+ # the offending file is emitted and analysis continues with
25
+ # `Dynamic[top]` everywhere rather than hanging.
26
+ #
27
+ # The discovery is intentionally a pure file-system walk —
28
+ # no `Bundler` API call, no `Gemfile.lock` parse — so rigor
29
+ # doesn't need the target project's Bundler context.
30
+ module BundleSigDiscovery
31
+ # Gems already covered by rigor's `DEFAULT_LIBRARIES`
32
+ # (stdlib RBS) plus the `data/vendored_gem_sigs/` bundle.
33
+ # Skipping these from bundle discovery prevents
34
+ # `RBS::DuplicatedDeclarationError` (the prism case was the
35
+ # motivating example — Ruby 4.0 ships prism's RBS in
36
+ # stdlib, and the gem also ships its own `sig/`, so loading
37
+ # both raises on `Prism::BACKEND` etc.).
38
+ #
39
+ # The list is hard-coded for the MVP because it tracks
40
+ # rigor's bundled coverage 1:1. When a new gem is vendored
41
+ # under `data/vendored_gem_sigs/` or added to
42
+ # `DEFAULT_LIBRARIES`, add its name here.
43
+ SKIPPED_GEMS_BY_DEFAULT = Set[
44
+ # DEFAULT_LIBRARIES (lib/rigor/environment.rb)
45
+ "pathname", "optparse", "json", "yaml", "fileutils",
46
+ "tempfile", "tmpdir", "stringio", "forwardable",
47
+ "digest", "securerandom", "uri", "logger", "date",
48
+ "pp", "delegate", "singleton", "observable", "abbrev",
49
+ "find", "tsort", "shellwords", "benchmark", "base64",
50
+ "did_you_mean", "monitor", "mutex_m", "timeout",
51
+ "open3", "erb", "etc", "ipaddr", "bigdecimal",
52
+ "bigdecimal-math", "prettyprint",
53
+ "random-formatter", "time", "open-uri", "resolv",
54
+ "csv", "pstore", "objspace", "io-console", "cgi", "cgi-escape",
55
+ "strscan",
56
+ "prism", "rbs",
57
+ # data/vendored_gem_sigs/
58
+ "pg", "mysql2", "nokogiri", "bcrypt", "redis", "idn-ruby"
59
+ ].freeze
60
+
61
+ # @param bundle_path [String, Pathname, nil] explicit path
62
+ # to the bundler install root. When `nil`, falls back to
63
+ # `auto_detect` if `auto_detect:` is true.
64
+ # @param project_root [String] resolution base for relative
65
+ # `bundle_path:` and the auto-detect search.
66
+ # @param auto_detect [Boolean] when true and `bundle_path:`
67
+ # is nil, try `.bundle/config`'s `BUNDLE_PATH:` and
68
+ # `vendor/bundle/` under `project_root`.
69
+ # @param skip_gems [Set<String>] gem names to exclude from
70
+ # discovery. Defaults to {SKIPPED_GEMS_BY_DEFAULT}.
71
+ # @param locked_gems [Hash{String => LockfileResolver::LockedGem}, nil]
72
+ # Optional O4-Layer-3 filter. When non-nil and non-empty,
73
+ # only `sig/` directories whose gem `(name, version,
74
+ # platform)` tuple matches a lockfile entry are returned.
75
+ # Bundle entries absent from the lockfile (or at a drifted
76
+ # version) are silently dropped — the lockfile is treated
77
+ # as the source of truth for "what gems this project
78
+ # actually declares". Pass `nil` (the default) to keep
79
+ # the pre-Layer-3 behaviour of returning every non-skipped
80
+ # `sig/` under the bundle.
81
+ # @return [Array<Pathname>] every `<gem-dir>/sig` directory
82
+ # under the resolved bundle path, minus any whose gem
83
+ # name is in `skip_gems` and (when `locked_gems` is
84
+ # supplied) minus any whose `(name, version, platform)`
85
+ # does not match a lockfile entry.
86
+ def self.discover(bundle_path:, project_root: Dir.pwd, auto_detect: true,
87
+ skip_gems: SKIPPED_GEMS_BY_DEFAULT, locked_gems: nil)
88
+ resolved = resolve_bundle_path(
89
+ bundle_path: bundle_path,
90
+ project_root: project_root,
91
+ auto_detect: auto_detect
92
+ )
93
+ return [] if resolved.nil?
94
+
95
+ # `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig/` is the
96
+ # canonical bundler layout. `*` on the ruby version dir
97
+ # picks up whichever Ruby the bundle was installed for.
98
+ all = Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")).map { |d| Pathname.new(d) }
99
+ filtered = all.reject { |sig_dir| skip_gems.include?(gem_name_from_sig_path(sig_dir)) }
100
+ return filtered if locked_gems.nil? || locked_gems.empty?
101
+
102
+ expected_dirs = expected_gem_dirs(locked_gems)
103
+ filtered.select { |sig_dir| expected_dirs.include?(sig_dir.parent.basename.to_s) }
104
+ end
105
+
106
+ # `{name => LockedGem}` → set of canonical bundler gem
107
+ # directory basenames. Pure-Ruby gems install as
108
+ # `<name>-<version>`; platform-specific gems install as
109
+ # `<name>-<version>-<platform>` (e.g. `ffi-1.17.4-aarch64-linux-gnu`).
110
+ # Lockfile platform `"ruby"` is the pure-Ruby case; any
111
+ # other value is treated as a platform tag.
112
+ def self.expected_gem_dirs(locked_gems)
113
+ locked_gems.each_value.with_object(Set.new) do |locked, set|
114
+ base = "#{locked.name}-#{locked.version}"
115
+ set << if locked.platform == "ruby" || locked.platform.empty?
116
+ base
117
+ else
118
+ "#{base}-#{locked.platform}"
119
+ end
120
+ end
121
+ end
122
+ private_class_method :expected_gem_dirs
123
+
124
+ # `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name>`.
125
+ # The gem directory follows the canonical
126
+ # `<name>-<version>` pattern; we strip everything from the
127
+ # last hyphen onwards to recover the name. (Platform-tagged
128
+ # variants like `ffi-1.17.4-aarch64-linux-gnu/` keep their
129
+ # platform suffix in the version part, so the first hyphen
130
+ # from the right is still the name boundary.)
131
+ #
132
+ # Public so the O4 Layer 3 slice-3 coverage report
133
+ # (`RbsCoverageReport`) can classify discovered bundle sigs
134
+ # against locked gem names without re-running discovery.
135
+ def self.gem_name_from_sig_path(sig_dir)
136
+ gem_dir = sig_dir.parent.basename.to_s
137
+ # Strip `-<version>` and any platform suffix. The version
138
+ # always starts with a digit, so split at the first
139
+ # `-` followed by a digit.
140
+ gem_dir.sub(/-\d.*\z/, "")
141
+ end
142
+
143
+ # Returns `Pathname` resolved bundle path, or `nil` when
144
+ # neither explicit nor auto-detected. Public for the stats
145
+ # banner so end users can see what rigor picked up.
146
+ def self.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true)
147
+ if bundle_path
148
+ path = Pathname.new(File.expand_path(bundle_path.to_s, project_root))
149
+ return path if path.directory?
150
+
151
+ return nil
152
+ end
153
+
154
+ return nil unless auto_detect
155
+
156
+ detected = auto_detect(project_root: project_root)
157
+ Pathname.new(detected) if detected
158
+ end
159
+
160
+ # Auto-detection order:
161
+ # 1. `<project_root>/.bundle/config` carries `BUNDLE_PATH:`
162
+ # set by `bundle config set --local path <dir>`.
163
+ # 2. `<project_root>/vendor/bundle/` — the conventional
164
+ # in-tree install location when a developer ran
165
+ # `bundle install --path vendor/bundle`.
166
+ # 3. `nil` — let the caller proceed without bundle sig
167
+ # discovery (rigor's vendored RBS still loads).
168
+ def self.auto_detect(project_root:)
169
+ from_config = read_bundle_config_path(project_root)
170
+ return File.expand_path(from_config, project_root) if from_config
171
+
172
+ vendor = File.join(project_root, "vendor", "bundle")
173
+ return vendor if File.directory?(vendor)
174
+
175
+ nil
176
+ end
177
+
178
+ def self.read_bundle_config_path(project_root)
179
+ config_path = File.join(project_root, ".bundle", "config")
180
+ return nil unless File.exist?(config_path)
181
+
182
+ # `.bundle/config` is YAML with all-caps env-style keys.
183
+ # `BUNDLE_PATH:` is the canonical key (Bundler 2.x); the
184
+ # `--path` flag sets it.
185
+ data = YAML.safe_load_file(config_path)
186
+ return nil unless data.is_a?(Hash)
187
+
188
+ data["BUNDLE_PATH"]
189
+ rescue StandardError
190
+ # Malformed `.bundle/config` should not break analysis;
191
+ # silently skip auto-detection.
192
+ nil
193
+ end
194
+
195
+ private_class_method :read_bundle_config_path
196
+ end
197
+ end
198
+ end
@@ -67,10 +67,19 @@ module Rigor
67
67
 
68
68
  private
69
69
 
70
+ # ADR-15 Phase 4b — the default registry MUST be
71
+ # `Ractor.shareable?` so worker Ractors that consult
72
+ # `Environment.for_project`'s default `class_registry:`
73
+ # don't trip `Ractor::IsolationError`. The internal
74
+ # `@nominals` / `@class_objects` Hashes are populated
75
+ # via `register`, then `Ractor.make_shareable`
76
+ # recursively freezes the registry, the two Hashes,
77
+ # and confirms every entry (Type::Nominal carriers +
78
+ # core Ruby classes) is itself shareable.
70
79
  def build_default
71
- new.tap do |registry|
72
- CORE_BUILT_INS.each { |klass| registry.register(klass) }
73
- end.freeze
80
+ registry = new
81
+ CORE_BUILT_INS.each { |klass| registry.register(klass) }
82
+ Ractor.make_shareable(registry)
74
83
  end
75
84
  end
76
85