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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/core_overlay/csv.rbs +28 -0
- data/data/core_overlay/psych.rbs +22 -0
- data/docs/handbook/01-getting-started.md +9 -1
- data/docs/handbook/02-everyday-types.md +4 -1
- data/docs/handbook/08-understanding-errors.md +3 -3
- data/docs/manual/01-installation.md +1 -0
- data/docs/manual/02-cli-reference.md +16 -7
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/14-rails-quickstart.md +4 -2
- data/docs/manual/15-type-protection-coverage.md +21 -0
- data/docs/manual/plugins/rigor-actionpack.md +1 -1
- data/docs/manual/plugins/rigor-activerecord.md +12 -5
- data/docs/manual/plugins/rigor-rails-routes.md +11 -0
- data/lib/rigor/cache/store.rb +174 -57
- data/lib/rigor/cli/coverage_command.rb +50 -29
- data/lib/rigor/cli/diagnostic_formats.rb +4 -1
- data/lib/rigor/cli/protection_fork_scan.rb +55 -0
- data/lib/rigor/cli/protection_report.rb +7 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
- data/lib/rigor/environment.rb +54 -18
- data/lib/rigor/inference/expression_typer.rb +20 -2
- data/lib/rigor/inference/fork_map.rb +87 -0
- data/lib/rigor/inference/narrowing.rb +118 -0
- data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
- data/lib/rigor/inference/scope_indexer.rb +9 -13
- data/lib/rigor/inference/statement_evaluator.rb +48 -3
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
- data/sig/rigor/environment.rbs +1 -0
- metadata +8 -1
data/lib/rigor/cache/store.rb
CHANGED
|
@@ -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
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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" —
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
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 @
|
|
252
|
+
return if @read_only
|
|
236
253
|
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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.
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
204
|
-
CoverageScan.precision_report(files: paths, configuration:
|
|
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
|
|
216
|
-
# registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as
|
|
217
|
-
# a systematic undercount of what Rigor actually types on a plugin-using project.
|
|
218
|
-
# plugin-aware environment (registry materialised + the per-run prepare pass that
|
|
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
|
|
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
|
-
|
|
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
|