rigortype 0.2.8 → 0.2.9

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/08-understanding-errors.md +3 -3
  8. data/docs/manual/01-installation.md +1 -0
  9. data/docs/manual/02-cli-reference.md +16 -7
  10. data/docs/manual/07-plugins.md +1 -1
  11. data/docs/manual/14-rails-quickstart.md +4 -2
  12. data/docs/manual/15-type-protection-coverage.md +21 -0
  13. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  14. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  15. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  16. data/lib/rigor/cache/store.rb +174 -57
  17. data/lib/rigor/cli/coverage_command.rb +50 -29
  18. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  19. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  20. data/lib/rigor/cli/protection_report.rb +7 -1
  21. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  22. data/lib/rigor/environment.rb +54 -18
  23. data/lib/rigor/inference/expression_typer.rb +20 -2
  24. data/lib/rigor/inference/fork_map.rb +87 -0
  25. data/lib/rigor/inference/narrowing.rb +118 -0
  26. data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
  27. data/lib/rigor/inference/scope_indexer.rb +9 -13
  28. data/lib/rigor/inference/statement_evaluator.rb +48 -3
  29. data/lib/rigor/version.rb +1 -1
  30. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
  34. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  35. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  36. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  37. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  42. data/sig/rigor/environment.rbs +1 -0
  43. metadata +8 -1
@@ -7,6 +7,7 @@ require "monitor"
7
7
  require "securerandom"
8
8
  require "zlib"
9
9
 
10
+ require_relative "../version"
10
11
  require_relative "descriptor"
11
12
 
12
13
  module Rigor
@@ -30,6 +31,27 @@ module Rigor
30
31
  # bytes.
31
32
  FORMAT_VERSION = 2
32
33
 
34
+ # Payload ABI version. Store values are mostly Marshal blobs of Rigor/RBS objects, so a Rigor release
35
+ # upgrade is an ABI boundary even when the byte layout and descriptor schema are unchanged. Folding the
36
+ # gem version into the root marker makes installed-version upgrades rebuild rather than silently reuse a
37
+ # blob whose class layout still happens to unmarshal.
38
+ PAYLOAD_ABI_VERSION = Rigor::VERSION
39
+
40
+ # Whole-project producers are content-keyed, so dependency / signature churn writes a new entry and leaves
41
+ # the old generation unreachable. The global 256 MB cap is intentionally generous and often never fires on
42
+ # one project, so these producers get a small generation cap as a second compaction axis. Per-file / plugin
43
+ # producers are deliberately absent from this table: many current entries under one producer id can be live.
44
+ GENERATION_CAP_BY_PRODUCER = {
45
+ "analysis.run-diagnostics" => 16,
46
+ "rbs.class_ancestor_table" => 2,
47
+ "rbs.class_type_param_names" => 2,
48
+ "rbs.constant_type_table" => 2,
49
+ "rbs.environment" => 2,
50
+ "rbs.known_class_names" => 2
51
+ }.freeze
52
+
53
+ STALE_TEMP_FILE_AGE_SECONDS = 60 * 60
54
+
33
55
  # Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte format version.
34
56
  HEADER = "RIGOR\x00#{FORMAT_VERSION.chr}".b.freeze
35
57
 
@@ -37,17 +59,19 @@ module Rigor
37
59
 
38
60
  # @param root [String] cache root directory.
39
61
  # @param read_only [Boolean] when true, every disk-side side-effect is suppressed: `fetch_or_compute`
40
- # still reads existing entries (hits) and still runs the producer block on miss, but it does NOT
41
- # write the produced value to disk, does NOT update the `schema_version.txt` marker, and does NOT
42
- # touch the on-disk root directory. The in-process memo is still populated so repeated lookups within
43
- # the same run stay cheap. Used by editor mode so multiple buffer-mode invocations can read from the
44
- # same cache concurrently without churning it. See `docs/design/20260516-editor-mode.md` § "Cache
45
- # behaviour".
62
+ # still reads existing entries (hits, gated on a current `schema_version.txt` marker see
63
+ # {#ensure_schema_version!}) and still runs the producer block on miss, but it does NOT write the
64
+ # produced value to disk, does NOT update the marker, and does NOT touch the on-disk root directory.
65
+ # The in-process memo is still populated so repeated lookups within the same run stay cheap. Used by
66
+ # editor mode so multiple buffer-mode invocations can read from the same cache concurrently without
67
+ # churning it. See `docs/design/20260516-editor-mode.md` § "Cache behaviour".
46
68
  def initialize(root:, read_only: false, max_bytes: nil)
47
69
  @root = root.to_s.dup.freeze
48
70
  @read_only = read_only
49
71
  @max_bytes = max_bytes&.then { |n| Integer(n) }
50
- @schema_version_ensured = false
72
+ # Tri-state: nil = not yet checked, true/false = the disk tier's availability for this Store's
73
+ # lifetime (see {#ensure_schema_version!}).
74
+ @disk_available = nil
51
75
  @hits = 0
52
76
  @misses = 0
53
77
  @writes = 0
@@ -100,7 +124,7 @@ module Rigor
100
124
  # the eviction cap forever. Folding the format version into the marker routes the bump through the
101
125
  # established clear-the-root path instead.
102
126
  def self.schema_marker_value
103
- "#{Descriptor::SCHEMA_VERSION}.#{FORMAT_VERSION}"
127
+ "#{PAYLOAD_ABI_VERSION}.#{Descriptor::SCHEMA_VERSION}.#{FORMAT_VERSION}"
104
128
  end
105
129
 
106
130
  def self.disk_inventory(root:)
@@ -154,7 +178,7 @@ module Rigor
154
178
  def fetch_or_compute(producer_id:, params:, descriptor:,
155
179
  serialize: nil, deserialize: nil, &block)
156
180
  validate_producer_id!(producer_id)
157
- ensure_schema_version!
181
+ disk = ensure_schema_version!
158
182
 
159
183
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
160
184
  memo_key = [producer_id, key].freeze
@@ -164,8 +188,8 @@ module Rigor
164
188
  return memoed
165
189
  end
166
190
 
167
- path = entry_path(producer_id, key)
168
- cached = read_entry(path, deserialize: deserialize)
191
+ path = disk ? entry_path(producer_id, key) : nil
192
+ cached = path && read_entry(path, deserialize: deserialize)
169
193
  unless cached.nil?
170
194
  @monitor.synchronize do
171
195
  record(:hits, producer_id)
@@ -175,10 +199,10 @@ module Rigor
175
199
  end
176
200
 
177
201
  value = block.call
178
- write_entry(path, descriptor, value, serialize: serialize) unless @read_only
202
+ wrote = path && try_write_entry(path, descriptor, value, serialize: serialize)
179
203
  @monitor.synchronize do
180
204
  record(:misses, producer_id)
181
- record(:writes, producer_id) unless @read_only
205
+ record(:writes, producer_id) if wrote
182
206
  @memo[memo_key] = value
183
207
  end
184
208
  value
@@ -195,11 +219,11 @@ module Rigor
195
219
  # validation always re-checks the filesystem — but a single run only looks up once.
196
220
  def fetch_or_validate(producer_id:, key_descriptor:, params: {}, serialize: nil, deserialize: nil)
197
221
  validate_producer_id!(producer_id)
198
- ensure_schema_version!
222
+ disk = ensure_schema_version!
199
223
 
200
224
  key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
201
- path = entry_path(producer_id, key)
202
- cached = read_entry(path, deserialize: deserialize)
225
+ path = disk ? entry_path(producer_id, key) : nil
226
+ cached = path && read_entry(path, deserialize: deserialize)
203
227
  if cached && (pair = cached.value).is_a?(Array) && pair.size == 2 &&
204
228
  pair[1].is_a?(Descriptor) && pair[1].fresh?
205
229
  @monitor.synchronize { record(:hits, producer_id) }
@@ -207,17 +231,7 @@ module Rigor
207
231
  end
208
232
 
209
233
  value, dependency_descriptor = block_given? ? yield : [nil, Descriptor.new]
210
- wrote = false
211
- unless @read_only
212
- # A cache write must never break the run. If the value is not Marshal-clean (or any disk error
213
- # occurs) skip caching and return the freshly-computed value — the next run recomputes.
214
- begin
215
- write_entry(path, key_descriptor, [value, dependency_descriptor], serialize: serialize)
216
- wrote = true
217
- rescue StandardError
218
- wrote = false
219
- end
220
- end
234
+ wrote = path && try_write_entry(path, key_descriptor, [value, dependency_descriptor], serialize: serialize)
221
235
  @monitor.synchronize do
222
236
  record(:misses, producer_id)
223
237
  record(:writes, producer_id) if wrote
@@ -225,27 +239,30 @@ module Rigor
225
239
  value
226
240
  end
227
241
 
228
- # ADR-6 § "Eviction" — LRU pass over the on-disk cache. No-op when `max_bytes:` was not configured or
229
- # the store is read-only. Walks all `.entry` files, sorts by mtime ascending (oldest = least recently
230
- # used), and unlinks from the oldest until the total is at or below the cap. Touch-on-disk-read
231
- # ({read_entry}) is the cross-process LRU signal: every disk hit (not in-process-memo hit) updates the
232
- # mtime so recently-read entries survive the eviction pass. Any FS error is swallowed eviction must
233
- # never break a run.
242
+ # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only. Stale
243
+ # temp file cleanup and the whole-project generation cap run regardless of `max_bytes:` they reclaim
244
+ # provably-dead bytes (leaked temp files, unreachable content-keyed generations) rather than enforcing a
245
+ # size budget, so an explicitly unbounded store (`max_bytes: nil`) still benefits from them. The
246
+ # size-based LRU pass below stays gated on `max_bytes:` being configured: it walks all remaining
247
+ # `.entry` files, sorts by mtime ascending (oldest = least recently used), and unlinks from the oldest
248
+ # until the total is at or below the cap. Touch-on-disk-read ({read_entry}) is the cross-process LRU
249
+ # signal: every disk hit (not in-process-memo hit) updates the mtime so recently-read entries survive
250
+ # the eviction pass. Any FS error is swallowed — eviction must never break a run.
234
251
  def evict!
235
- return if @max_bytes.nil? || @read_only
252
+ return if @read_only
236
253
 
237
- entries = collect_entry_stats
238
- total = entries.sum { |e| e[:bytes] }
254
+ cleanup_stale_temp_files
255
+ entries = evict_excess_generations(collect_entry_stats)
256
+ return if @max_bytes.nil?
257
+
258
+ total = entries.sum { |e| e[:bytes] }
239
259
  return if total <= @max_bytes
240
260
 
241
261
  entries.sort_by! { |e| e[:mtime] }
242
262
  entries.each do |entry|
243
263
  break if total <= @max_bytes
244
264
 
245
- File.unlink(entry[:path])
246
- total -= entry[:bytes]
247
- rescue StandardError
248
- next
265
+ total -= entry[:bytes] if unlink_entry(entry[:path])
249
266
  end
250
267
  nil
251
268
  rescue StandardError
@@ -372,38 +389,68 @@ module Rigor
372
389
  File.open(path, File::RDWR | File::CREAT, 0o644) do |lock_fd|
373
390
  lock_fd.flock(File::LOCK_EX)
374
391
  tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
375
- File.open(tmp, "wb") do |f|
376
- f.write(body)
377
- f.fsync
392
+ begin
393
+ File.open(tmp, "wb") do |f|
394
+ f.write(body)
395
+ f.fsync
396
+ end
397
+ File.rename(tmp, path)
398
+ ensure
399
+ # A failed write/rename must not leak its temp file — rely on the 1-hour `cleanup_stale_temp_files`
400
+ # sweep only as a backstop for crashes that skip this ensure entirely.
401
+ unlink_entry(tmp) if File.exist?(tmp)
378
402
  end
379
- File.rename(tmp, path)
403
+ fsync_directory(File.dirname(path))
380
404
  end
381
405
  end
382
406
 
407
+ # Checks (and, for a writable store, repairs) the `schema_version.txt` marker, and reports whether the
408
+ # disk tier is usable for the rest of this Store's lifetime. The result is memoized in `@disk_available`
409
+ # — one check per Store is enough; a benign double-check under a thread race would just repeat
410
+ # idempotent work.
411
+ #
412
+ # A writable store clears the cache root and rewrites the marker on a stale/missing marker, then reports
413
+ # available. A read-only store never touches the root — no mkdir, no marker write, no destructive clear
414
+ # — so it reports available ONLY when the on-disk marker already matches current exactly; a stale or
415
+ # missing marker (e.g. a Rigor upgrade with no writable run yet, as in LSP / editor mode) reports
416
+ # unavailable rather than risk unmarshalling a payload from a different ABI. Any filesystem failure
417
+ # (permission, disk full, deleted root) also reports unavailable, degrading this Store instance to
418
+ # in-memory-memo-only for the rest of its lifetime — the producer block still runs, its result still
419
+ # lands in `@memo`, and disk reads/writes are simply skipped.
420
+ #
421
+ # @return [Boolean]
383
422
  def ensure_schema_version!
384
- # Read-only stores never touch the cache root — no mkdir, no marker write, no destructive clear on
385
- # schema mismatch. A stale or wrong-schema marker simply yields nothing back (entries read through
386
- # the version check are content-keyed, so a write under the new schema never collides with a read
387
- # under the old). The next writable run will repair the cache.
388
- return if @read_only
389
- # The marker is process-stable; one check per Store is enough (a benign double-check under a thread
390
- # race just repeats idempotent work).
391
- return if @schema_version_ensured
423
+ return @disk_available unless @disk_available.nil?
424
+
425
+ @disk_available = @read_only ? read_only_marker_current? : repair_writable_marker!
426
+ end
427
+
428
+ def read_only_marker_current?
429
+ marker = File.join(@root, "schema_version.txt")
430
+ return false unless File.file?(marker)
431
+
432
+ File.read(marker).strip == self.class.schema_marker_value
433
+ rescue StandardError
434
+ false
435
+ end
392
436
 
393
- @schema_version_ensured = true
437
+ def repair_writable_marker!
394
438
  FileUtils.mkdir_p(@root)
395
439
  marker = File.join(@root, "schema_version.txt")
396
440
  current = self.class.schema_marker_value
397
441
 
398
442
  if File.file?(marker)
399
443
  on_disk = File.read(marker).strip
400
- return if on_disk == current
444
+ return true if on_disk == current
401
445
 
402
446
  clear_cache_root!
403
447
  end
404
448
 
405
449
  FileUtils.mkdir_p(@root)
406
450
  File.write(marker, "#{current}\n")
451
+ true
452
+ rescue StandardError
453
+ false
407
454
  end
408
455
 
409
456
  def clear_cache_root!
@@ -412,6 +459,27 @@ module Rigor
412
459
  end
413
460
  end
414
461
 
462
+ # Shared write path for both {#fetch_or_compute} and {#fetch_or_validate}. A cache write must never
463
+ # break the run: filesystem-side failures (permission, disk full, deleted root, read-only mount) are
464
+ # swallowed and read as "did not write". Programmer errors — a custom serializer that doesn't return a
465
+ # `String`, or any other producer contract violation — still raise so the bug is visible.
466
+ def try_write_entry(path, descriptor, value, serialize: nil)
467
+ return false if @read_only
468
+
469
+ write_entry(path, descriptor, value, serialize: serialize)
470
+ true
471
+ rescue SystemCallError, IOError
472
+ false
473
+ end
474
+
475
+ # Best-effort durability for the rename itself. Some platforms cannot fsync directories (or do not need to),
476
+ # so failures are ignored; the entry envelope still turns any lost/partial write into a miss.
477
+ def fsync_directory(dir)
478
+ File.open(dir, File::RDONLY, &:fsync)
479
+ rescue StandardError
480
+ nil
481
+ end
482
+
415
483
  # LEB128 unsigned varint encoder/decoder. Lengths fit easily in five bytes (cap at 2^35); the cache
416
484
  # layer never writes a value larger than that in practice.
417
485
  def write_varint(bytes, value)
@@ -437,17 +505,66 @@ module Rigor
437
505
  nil
438
506
  end
439
507
 
440
- # Returns an array of `{ path:, mtime:, bytes: }` hashes for every `.entry` file under the cache root,
441
- # skipping unreadable entries.
508
+ def cleanup_stale_temp_files
509
+ cutoff = Time.now - STALE_TEMP_FILE_AGE_SECONDS
510
+ Dir.glob(File.join(@root, "**", "*.tmp.*")).each do |path|
511
+ next unless File.file?(path)
512
+ next if File.mtime(path) > cutoff
513
+
514
+ unlink_entry(path)
515
+ rescue StandardError
516
+ next
517
+ end
518
+ rescue StandardError
519
+ nil
520
+ end
521
+
522
+ def evict_excess_generations(entries)
523
+ removed = {}
524
+ entries.group_by { |entry| entry[:producer] }.each do |producer, producer_entries|
525
+ cap = GENERATION_CAP_BY_PRODUCER[producer]
526
+ next if cap.nil? || producer_entries.size <= cap
527
+
528
+ producer_entries.sort_by { |entry| [entry[:mtime], entry[:path]] }
529
+ .first(producer_entries.size - cap)
530
+ .each do |entry|
531
+ removed[entry[:path]] = true if unlink_entry(entry[:path])
532
+ end
533
+ end
534
+ return entries if removed.empty?
535
+
536
+ entries.reject { |entry| removed[entry[:path]] }
537
+ end
538
+
539
+ def unlink_entry(path)
540
+ File.unlink(path)
541
+ true
542
+ rescue StandardError
543
+ false
544
+ end
545
+
546
+ # Returns an array of `{ path:, producer:, mtime:, bytes: }` hashes for every `.entry` file under the
547
+ # cache root, skipping unreadable entries.
442
548
  def collect_entry_stats
443
549
  Dir.glob(File.join(@root, "**", "*.entry")).filter_map do |path|
444
550
  stat = File.stat(path)
445
- { path: path, mtime: stat.mtime, bytes: stat.size }
551
+ producer = producer_id_for_entry(path)
552
+ next nil if producer.nil?
553
+
554
+ { path: path, producer: producer, mtime: stat.mtime, bytes: stat.size }
446
555
  rescue StandardError
447
556
  nil
448
557
  end
449
558
  end
450
559
 
560
+ def producer_id_for_entry(path)
561
+ root_prefix = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
562
+ return nil unless path.start_with?(root_prefix)
563
+
564
+ producer = path.delete_prefix(root_prefix).split(File::SEPARATOR, 2).first
565
+ producer.empty? ? nil : producer
566
+ end
567
+
451
568
  def read_varint(bytes, offset)
452
569
  result = 0
453
570
  shift = 0
@@ -25,6 +25,8 @@ require_relative "mutation_protection_renderer"
25
25
  require_relative "fused_protection_report"
26
26
  require_relative "fused_protection_renderer"
27
27
  require_relative "coverage_mutation"
28
+ require_relative "protection_fork_scan"
29
+ require_relative "check_runner_factory"
28
30
  require_relative "command"
29
31
 
30
32
  module Rigor
@@ -58,23 +60,32 @@ module Rigor
58
60
  return include_dynamic_misuse_error if options[:include_dynamic] && !options[:with_tests]
59
61
  return run_mutation_protection(options) if options[:mutation]
60
62
 
61
- paths = collect_paths(@argv, command_name: "coverage")
63
+ configuration = Configuration.load(options.fetch(:config))
64
+ paths = resolve_paths(configuration)
62
65
  return CLI::EXIT_USAGE if paths.nil?
63
66
  return usage_error if paths.empty?
64
67
 
65
- return run_protection(paths, options) if options[:protection]
68
+ return run_protection(paths, options, configuration) if options[:protection]
66
69
 
67
- report = scan_paths(paths, options)
70
+ report = scan_paths(paths, configuration)
68
71
  CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
69
72
  determine_exit(report, options)
70
73
  end
71
74
 
72
75
  private
73
76
 
77
+ # Like `rigor check`, fall back to the configured `paths:` when no path is given on the command line
78
+ # (`coverage` previously required an explicit path — operational friction the two commands should not
79
+ # differ on). The mutation mode keeps its own git-changed-files default and returns before this.
80
+ def resolve_paths(configuration)
81
+ args = @argv.empty? ? configuration.paths : @argv
82
+ collect_paths(args, command_name: "coverage")
83
+ end
84
+
74
85
  def parse_options
75
86
  options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false,
76
87
  with_tests: false, test_command: DEFAULT_TEST_COMMAND, include_dynamic: false,
77
- limit: nil, seed: 1 }
88
+ limit: nil, seed: 1, workers: nil }
78
89
  OptionParser.new { |opts| define_options(opts, options) }.parse!(@argv)
79
90
  options
80
91
  end
@@ -87,6 +98,10 @@ module Rigor
87
98
  options[:protection] = true
88
99
  end
89
100
  define_mutation_options(opts, options)
101
+ opts.on("--workers=N", Integer, "With --protection: fork N workers over the scanned files " \
102
+ "(default: config parallel.workers / RIGOR_RACTOR_WORKERS / 0)") do |v|
103
+ options[:workers] = v
104
+ end
90
105
  opts.on("--threshold=RATIO", Float, "Exit 1 when the precision (or, with --protection, " \
91
106
  "protection/effectiveness) ratio is below RATIO (0.0–1.0)") do |v|
92
107
  options[:threshold] = v
@@ -135,23 +150,39 @@ module Rigor
135
150
  CLI::EXIT_USAGE
136
151
  end
137
152
 
138
- def run_protection(paths, options)
139
- report = scan_protection(paths, options)
153
+ def run_protection(paths, options, configuration)
154
+ report = scan_protection(paths, options, configuration)
140
155
  ProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
141
156
  determine_protection_exit(report, options)
142
157
  end
143
158
 
144
- def scan_protection(paths, options)
145
- configuration = Configuration.load(options.fetch(:config))
159
+ # Builds the plugin-aware environment and the seeded scope ONCE on the parent, then fork-pools the
160
+ # per-file scan across the resolved worker count (P3-10). The scan is pure-read and each file is
161
+ # independent, so this is a fork-map + ordered reduce: {ProtectionForkScan} returns `{path => result}`
162
+ # and the parent absorbs in `paths` order so the report is byte-identical to a sequential run.
163
+ def scan_protection(paths, options, configuration)
146
164
  environment = plugin_aware_environment(configuration)
147
- scope = scope_with_inferred_params(paths, configuration, environment)
165
+ workers = CheckRunnerFactory.resolve_workers(options, configuration)
166
+ scope = scope_with_inferred_params(paths, configuration, environment, workers)
148
167
  scanner = Inference::ProtectionScanner.new(scope: scope)
149
168
  accumulator = ProtectionAccumulator.new
150
169
 
151
- paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
170
+ results = ProtectionForkScan.run(
171
+ paths: paths, scanner: scanner, environment: environment,
172
+ configuration: configuration, workers: workers
173
+ )
174
+ paths.each { |path| absorb_protection_result(accumulator, path, results.fetch(path)) }
152
175
  accumulator.to_report
153
176
  end
154
177
 
178
+ def absorb_protection_result(accumulator, path, result)
179
+ if result.is_a?(ProtectionForkScan::ParseError)
180
+ accumulator.record_parse_error_count(path, result.count)
181
+ else
182
+ accumulator.absorb(path, result)
183
+ end
184
+ end
185
+
155
186
  # Seed the protection scan's scope with the same cross-file facts `rigor check` resolves against, so a receiver
156
187
  # reads the type it actually has rather than a stripped-scope `Dynamic`:
157
188
  #
@@ -168,7 +199,7 @@ module Rigor
168
199
  #
169
200
  # Both span the scanned `paths` only (no whole-project pre-pass) — a site that gains neither is classified exactly
170
201
  # as before.
171
- def scope_with_inferred_params(paths, configuration, environment)
202
+ def scope_with_inferred_params(paths, configuration, environment, workers)
172
203
  base = Scope.empty(environment: environment)
173
204
  seed = {}
174
205
 
@@ -176,7 +207,7 @@ module Rigor
176
207
  seed[:discovered_classes] = discovered unless discovered.empty?
177
208
 
178
209
  table = Inference::ParameterInferenceCollector.collect(
179
- files: paths, environment: environment, target_ruby: configuration.target_ruby
210
+ files: paths, environment: environment, target_ruby: configuration.target_ruby, workers: workers
180
211
  )
181
212
  seed[:param_inferred_types] = table unless table.empty?
182
213
 
@@ -200,31 +231,21 @@ module Rigor
200
231
  CLI::EXIT_USAGE
201
232
  end
202
233
 
203
- def scan_paths(paths, options)
204
- CoverageScan.precision_report(files: paths, configuration: Configuration.load(options.fetch(:config)))
205
- end
206
-
207
- # Delegated to the shared scan module (see {CoverageScan}); the protection path below reuses both, and `rigor
208
- # check --coverage` reuses `precision_report` over the same machinery.
209
- def project_environment(configuration)
210
- CoverageScan.project_environment(configuration)
234
+ def scan_paths(paths, configuration)
235
+ CoverageScan.precision_report(files: paths, configuration: configuration)
211
236
  end
212
237
 
213
238
  # The protection scan must see the same receiver types `rigor check` does — including plugin-contributed
214
239
  # `dynamic_return` types (a controller's `params` → `ActionController::Parameters`, a `Model.where` →
215
- # `ActiveRecord::Relation[Model]`). The bare `project_environment` carries only the RBS environment (no plugin
216
- # registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as *unprotected* —
217
- # a systematic undercount of what Rigor actually types on a plugin-using project. `ProjectContext` builds the
218
- # plugin-aware environment (registry materialised + the per-run prepare pass that primes producers like the
219
- # controller / model index) exactly as the LSP and the runner do.
240
+ # `ActiveRecord::Relation[Model]`). The bare `CoverageScan.project_environment` carries only the RBS environment
241
+ # (no plugin registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as
242
+ # *unprotected* — a systematic undercount of what Rigor actually types on a plugin-using project.
243
+ # `ProjectContext` builds the plugin-aware environment (registry materialised + the per-run prepare pass that
244
+ # primes producers like the controller / model index) exactly as the LSP and the runner do.
220
245
  def plugin_aware_environment(configuration)
221
246
  LanguageServer::ProjectContext.new(configuration: configuration).environment
222
247
  end
223
248
 
224
- def scan_one(path, scanner, accumulator, configuration)
225
- CoverageScan.scan_into(path, scanner, accumulator, configuration)
226
- end
227
-
228
249
  def determine_exit(report, options)
229
250
  return 1 unless report.parse_errors.empty?
230
251
 
@@ -33,7 +33,9 @@ module Rigor
33
33
  FORMATS.include?(format)
34
34
  end
35
35
 
36
- # Renders `result` in the named CI format. Callers gate on {.supports?} first; an unrecognised format returns nil.
36
+ # Renders `result` in the named CI format. Callers gate on {.supports?} first, so an unrecognised format means
37
+ # {FORMATS} and the dispatch below have drifted apart — raise rather than return nil, which every caller would
38
+ # then have to guard.
37
39
  def render(result, format)
38
40
  case format
39
41
  when "sarif" then Sarif.new(result).render
@@ -42,6 +44,7 @@ module Rigor
42
44
  when "checkstyle" then Checkstyle.new(result).render
43
45
  when "junit" then Junit.new(result).render
44
46
  when "teamcity" then Teamcity.new(result).render
47
+ else raise ArgumentError, "unsupported format: #{format}"
45
48
  end
46
49
  end
47
50
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../inference/fork_map"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # Fork-pool for the `rigor coverage --protection` scan (P3-10). The plugin-aware environment and the
10
+ # seeded scope are built ONCE on the parent — the expensive part: RBS env, plugin registry, and the
11
+ # cross-file discovery + parameter-inference seed — and children copy-on-write inherit them (via
12
+ # {Inference::ForkMap}), each analysing a contiguous slice of paths and returning per-path results the
13
+ # parent merges in original path order.
14
+ #
15
+ # The scan is pure-read (no reporter drains, no plugin-emission merge, no prepare diagnostics), so a
16
+ # slice payload is just `{path => result}`. Determinism: the parent merges slice payloads in slice order
17
+ # and the accumulator absorbs in `paths` order, so the fused report is byte-identical to a sequential run
18
+ # regardless of which worker produced which file.
19
+ module ProtectionForkScan
20
+ module_function
21
+
22
+ # A parse failure for one file, carrying only the marshalable error count (Prism error objects are not
23
+ # reliably marshalable, and the accumulator only needs the count).
24
+ ParseError = Data.define(:count)
25
+
26
+ # @param paths [Array<String>] the files to scan, in caller order.
27
+ # @param scanner [Inference::ProtectionScanner] built on the parent; COW-inherited by workers.
28
+ # @param environment [Rigor::Environment] the scanner's environment, prewarmed here before forking.
29
+ # @param configuration [Rigor::Configuration] for the Prism `target_ruby` version.
30
+ # @param workers [Integer] resolved worker count (≤1 → sequential).
31
+ # @return [Hash{String => Inference::ProtectionScanner::FileResult, ParseError}] one entry per path.
32
+ def run(paths:, scanner:, environment:, configuration:, workers:)
33
+ # Force the full RBS load on the parent so children copy-on-write inherit a warm environment rather
34
+ # than each rebuilding it after the fork (mirrors the check fork pool's parent-side prewarm). A
35
+ # no-op on the sequential path but cheap.
36
+ environment.rbs_loader&.prewarm if Inference::ForkMap.parallel?([workers, paths.size].min)
37
+
38
+ payloads = Inference::ForkMap.call(items: paths, workers: workers) do |slice|
39
+ slice.to_h { |path| [path, scan_path(path, scanner, configuration)] }
40
+ end
41
+ payloads.each_with_object({}) { |slice_results, merged| merged.merge!(slice_results) }
42
+ end
43
+
44
+ # Parses one file and returns the scanner's {Inference::ProtectionScanner::FileResult}, or a
45
+ # {ParseError} when the source does not parse.
46
+ def scan_path(path, scanner, configuration)
47
+ source = File.read(path)
48
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
49
+ return ParseError.new(count: parse_result.errors.size) if parse_result.errors.any?
50
+
51
+ scanner.scan(parse_result.value)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -86,7 +86,13 @@ module Rigor
86
86
  end
87
87
 
88
88
  def record_parse_error(path, errors)
89
- @parse_errors << { "path" => path, "errors" => errors.size }
89
+ record_parse_error_count(path, errors.size)
90
+ end
91
+
92
+ # Count-based variant for the fork-pool path, where a worker carries only the marshalable error count
93
+ # (see {ProtectionForkScan::ParseError}), not the Prism error objects.
94
+ def record_parse_error_count(path, count)
95
+ @parse_errors << { "path" => path, "errors" => count }
90
96
  end
91
97
 
92
98
  def to_report