rigortype 0.1.4 → 0.1.5

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -13
  3. data/lib/rigor/analysis/fact_store.rb +15 -3
  4. data/lib/rigor/analysis/result.rb +11 -3
  5. data/lib/rigor/analysis/run_stats.rb +193 -0
  6. data/lib/rigor/analysis/runner.rb +387 -12
  7. data/lib/rigor/analysis/worker_session.rb +327 -0
  8. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  9. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  10. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  11. data/lib/rigor/cache/store.rb +40 -7
  12. data/lib/rigor/cli.rb +52 -2
  13. data/lib/rigor/configuration.rb +131 -6
  14. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  15. data/lib/rigor/environment/class_registry.rb +12 -3
  16. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  17. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  18. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  19. data/lib/rigor/environment/rbs_loader.rb +194 -6
  20. data/lib/rigor/environment/reflection.rb +152 -0
  21. data/lib/rigor/environment.rb +78 -6
  22. data/lib/rigor/inference/acceptance.rb +35 -1
  23. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  24. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  25. data/lib/rigor/inference/expression_typer.rb +12 -2
  26. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  29. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  32. data/lib/rigor/inference/method_dispatcher.rb +128 -3
  33. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  34. data/lib/rigor/inference/narrowing.rb +127 -8
  35. data/lib/rigor/inference/synthetic_method.rb +86 -0
  36. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  37. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  38. data/lib/rigor/plugin/blueprint.rb +60 -0
  39. data/lib/rigor/plugin/loader.rb +3 -1
  40. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  41. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  42. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  43. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  44. data/lib/rigor/plugin/macro.rb +31 -0
  45. data/lib/rigor/plugin/manifest.rb +78 -7
  46. data/lib/rigor/plugin/registry.rb +32 -2
  47. data/lib/rigor/plugin.rb +1 -0
  48. data/lib/rigor/trinary.rb +15 -11
  49. data/lib/rigor/type/bot.rb +6 -3
  50. data/lib/rigor/type/combinator.rb +12 -1
  51. data/lib/rigor/type/integer_range.rb +7 -7
  52. data/lib/rigor/type/refined.rb +18 -12
  53. data/lib/rigor/type/top.rb +4 -3
  54. data/lib/rigor/type_node/generic.rb +7 -1
  55. data/lib/rigor/type_node/identifier.rb +9 -1
  56. data/lib/rigor/type_node/string_literal.rb +4 -1
  57. data/lib/rigor/version.rb +1 -1
  58. data/sig/rigor/environment.rbs +5 -2
  59. data/sig/rigor/plugin/blueprint.rbs +7 -0
  60. data/sig/rigor/plugin/manifest.rbs +1 -1
  61. data/sig/rigor/plugin/registry.rbs +14 -1
  62. data/sig/rigor.rbs +35 -2
  63. metadata +39 -1
@@ -11,11 +11,14 @@ require_relative "../reflection"
11
11
  require_relative "../type/combinator"
12
12
  require_relative "../inference/coverage_scanner"
13
13
  require_relative "../inference/scope_indexer"
14
+ require_relative "../inference/synthetic_method_scanner"
14
15
  require_relative "../inference/method_dispatcher/file_folding"
15
16
  require_relative "check_rules"
16
17
  require_relative "dependency_source_inference"
17
18
  require_relative "diagnostic"
18
19
  require_relative "result"
20
+ require_relative "run_stats"
21
+ require_relative "worker_session"
19
22
 
20
23
  module Rigor
21
24
  module Analysis
@@ -35,13 +38,29 @@ module Rigor
35
38
  # run; the CLI's `--no-cache` flag wires `nil` through.
36
39
  # v0.0.9 group A slice 1 introduces the surface; later
37
40
  # slices route real producers through it.
41
+ # @param workers [Integer] ADR-15 Phase 4b — when greater
42
+ # than zero, per-file analysis dispatches across a pool of
43
+ # N Ractor workers built around {WorkerSession}. Default
44
+ # `0` keeps the sequential code path bit-for-bit
45
+ # unchanged. Phase 4c will wire the CLI / `.rigor.yml`
46
+ # surface that produces non-zero values; this slice
47
+ # leaves the parameter as a programmatic opt-in only.
48
+ # @param collect_stats [Boolean] when true (default), `#run`
49
+ # builds a {RunStats} summary exposed via `result.stats`
50
+ # — this forces the RBS env build at end-of-run so the
51
+ # `class_decl_paths` snapshot has real source attribution.
52
+ # Set to false to skip the stats summary entirely; the
53
+ # CLI's `--no-stats` threads `false` through to keep
54
+ # trivial-fixture runs from warming `.rigor/cache`.
38
55
  def initialize(configuration:, explain: false,
39
56
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
40
- plugin_requirer: nil)
57
+ plugin_requirer: nil, workers: 0, collect_stats: true)
41
58
  @configuration = configuration
42
59
  @explain = explain
43
60
  @cache_store = cache_store
44
61
  @plugin_requirer = plugin_requirer
62
+ @workers = workers
63
+ @collect_stats = collect_stats
45
64
  @plugin_registry = Plugin::Registry::EMPTY
46
65
  @dependency_source_index = DependencySourceInference::Index::EMPTY
47
66
  @rbs_extended_reporter = RbsExtended::Reporter.new
@@ -60,28 +79,69 @@ module Rigor
60
79
  Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
61
80
  @configuration.fold_platform_specific_paths
62
81
 
82
+ wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+
63
84
  target_ruby_error = validate_target_ruby
64
85
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
65
86
 
66
87
  @plugin_registry = load_plugins
67
88
  @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
68
- environment = Environment.for_project(
69
- libraries: @configuration.libraries,
70
- signature_paths: @configuration.signature_paths,
71
- cache_store: @cache_store,
89
+ expansion = expand_paths(paths)
90
+ @class_decl_paths_snapshot = {}.freeze
91
+ @signature_paths_snapshot = []
92
+ # ADR-16 slice 2b — Tier C pre-pass. Built once per run
93
+ # against the resolved file set + the loaded plugin
94
+ # registry's `heredoc_templates` so synthetic methods are
95
+ # visible cross-file when per-file inference dispatches.
96
+ @synthetic_method_index = Inference::SyntheticMethodScanner.scan(
72
97
  plugin_registry: @plugin_registry,
73
- dependency_source_index: @dependency_source_index,
74
- rbs_extended_reporter: @rbs_extended_reporter,
75
- boundary_cross_reporter: @boundary_cross_reporter
98
+ paths: expansion.fetch(:files),
99
+ environment: nil
76
100
  )
77
- expansion = expand_paths(paths)
78
101
 
79
102
  diagnostics = pre_file_diagnostics(expansion)
80
- diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
103
+ diagnostics += analyze_files(expansion.fetch(:files))
81
104
  diagnostics += rbs_extended_reporter_diagnostics
82
105
  diagnostics += boundary_cross_diagnostics
83
106
 
84
- Result.new(diagnostics: apply_severity_profile(diagnostics))
107
+ Result.new(
108
+ diagnostics: apply_severity_profile(diagnostics),
109
+ stats: @collect_stats ? build_run_stats(wall_started_at: wall_started_at, expansion: expansion) : nil
110
+ )
111
+ end
112
+
113
+ # ADR-15 Phase 4b — routes per-file analysis to either the
114
+ # sequential coordinator-side Environment (legacy path,
115
+ # default) or a Ractor worker pool built around
116
+ # {WorkerSession} (opt-in via `workers:`). The sequential
117
+ # path is bit-for-bit unchanged from v0.1.4 / earlier; the
118
+ # pool path is the substrate exercised by phase 4c when
119
+ # `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:`
120
+ # is wired.
121
+ #
122
+ # Sequential mode also snapshots `class_decl_paths` from the
123
+ # local environment after the per-file loop completes so
124
+ # `RunStats` can attribute the RBS class universe between
125
+ # project-sig and bundled sources. The env stays a LOCAL
126
+ # variable (not an ivar) so it goes GC-eligible when the
127
+ # method returns — holding it as long-lived state added
128
+ # memory pressure that surfaced as a Bus Error during the
129
+ # spec suite under Ruby 4.0 + rbs 4.0.2.
130
+ def analyze_files(files)
131
+ return [] if files.empty?
132
+
133
+ if pool_mode?
134
+ analyze_files_in_pool(files)
135
+ else
136
+ environment = build_runner_environment
137
+ result = files.flat_map { |path| analyze_file(path, environment) }
138
+ if @collect_stats
139
+ loader = environment.rbs_loader
140
+ @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
141
+ @signature_paths_snapshot = loader&.signature_paths || [].freeze
142
+ end
143
+ result
144
+ end
85
145
  end
86
146
 
87
147
  # Pre-file diagnostic streams that fire once per run rather
@@ -90,12 +150,25 @@ module Rigor
90
150
  # `expand_paths` errors for `paths:` entries that don't
91
151
  # exist or aren't `.rb`. Aggregated here so `#run` stays
92
152
  # under the ABC budget.
153
+ #
154
+ # ADR-15 Phase 4b — `plugin_prepare_diagnostics` runs on
155
+ # the coordinator's plugin registry under sequential mode;
156
+ # under pool mode each worker re-runs `prepare` against
157
+ # its own plugin instances, so the pool path drains the
158
+ # first worker's prepare-diagnostic snapshot into the
159
+ # aggregated diagnostic stream instead (see
160
+ # {#analyze_files_in_pool}). Skipping the coordinator
161
+ # prepare in pool mode avoids double-running `#prepare`
162
+ # against the coordinator-side plugin instances (which
163
+ # the pool path never consults for per-file analysis).
93
164
  def pre_file_diagnostics(expansion)
165
+ prepare = pool_mode? ? [] : plugin_prepare_diagnostics
94
166
  plugin_load_diagnostics +
95
- plugin_prepare_diagnostics +
167
+ prepare +
96
168
  dependency_source_diagnostics +
97
169
  dependency_source_budget_diagnostics +
98
170
  dependency_source_config_conflict_diagnostics +
171
+ rbs_coverage_diagnostics +
99
172
  expansion.fetch(:errors)
100
173
  end
101
174
 
@@ -120,6 +193,244 @@ module Rigor
120
193
 
121
194
  private
122
195
 
196
+ def pool_mode?
197
+ @workers.is_a?(Integer) && @workers.positive?
198
+ end
199
+
200
+ # Coordinator-side Environment used by the sequential code
201
+ # path. Pool mode builds one Environment per worker inside
202
+ # the worker Ractor's body instead.
203
+ def build_runner_environment
204
+ Environment.for_project(
205
+ libraries: @configuration.libraries,
206
+ signature_paths: @configuration.signature_paths,
207
+ cache_store: @cache_store,
208
+ plugin_registry: @plugin_registry,
209
+ dependency_source_index: @dependency_source_index,
210
+ rbs_extended_reporter: @rbs_extended_reporter,
211
+ boundary_cross_reporter: @boundary_cross_reporter,
212
+ bundler_bundle_path: @configuration.bundler_bundle_path,
213
+ bundler_auto_detect: @configuration.bundler_auto_detect,
214
+ bundler_lockfile: @configuration.bundler_lockfile,
215
+ rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
216
+ rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
217
+ synthetic_method_index: @synthetic_method_index
218
+ )
219
+ end
220
+
221
+ # ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
222
+ # Spawns `@workers` Ractors; each takes the shareable
223
+ # payload (Configuration, cache_root String, plugin
224
+ # Blueprint Array, explain Boolean) and builds its OWN
225
+ # WorkerSession internally. Files are distributed
226
+ # round-robin across the pool; each worker writes back to
227
+ # the main Ractor's mailbox via `Ractor.main.send` with
228
+ # one of three message kinds:
229
+ #
230
+ # - `[:prepare, diagnostics]` — once at startup, the
231
+ # session's `prepare_diagnostics` snapshot. The
232
+ # coordinator keeps the FIRST worker's snapshot only
233
+ # (plugin `#prepare` is deterministic per plugin, so
234
+ # each worker produces the same diagnostic set; surfacing
235
+ # them once avoids N× duplication).
236
+ # - `[:file, path, diagnostics]` — one per analysed file.
237
+ # - `[:done, drained_reporters]` — once at exit, the
238
+ # per-worker reporter snapshots for end-of-pool merge.
239
+ #
240
+ # The Ruby 4.0+ Ractor model uses a single per-Ractor
241
+ # mailbox (no `Ractor.yield`); workers push back via
242
+ # `Ractor.main.send`. The coordinator drains its mailbox
243
+ # via `Ractor.receive` until it has counted exactly
244
+ # `pool.size` `:done` messages.
245
+ #
246
+ # Diagnostic order: original path order. Workers may
247
+ # complete files out of order; the coordinator re-orders
248
+ # via the `results_by_path` Hash before flattening.
249
+ #
250
+ # Reporter merge: per-worker `RbsExtended::Reporter` and
251
+ # `BoundaryCrossReporter` entries are replayed into the
252
+ # runner-side accumulators via their `record_*` APIs,
253
+ # which dedupe on the same keys as a single-session run
254
+ # would. Net result: reporter state is identical to the
255
+ # sequential path.
256
+ def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
257
+ # Pre-warm class-level lazy memos on the MAIN Ractor.
258
+ # `Environment::ClassRegistry.default` is the
259
+ # default kwarg threaded through `Environment.new`
260
+ # inside each worker session; lazy-initialising it
261
+ # from a non-main Ractor would trip
262
+ # `Ractor::IsolationError`. Touching it here forces
263
+ # the (shareable) registry into the class-ivar cache
264
+ # before any worker reads.
265
+ Environment::ClassRegistry.default
266
+
267
+ # ADR-15 Phase 4b.x — pre-warm the RBS cache so
268
+ # workers serve every reflection query from the
269
+ # Marshal blob on disk. Without this, the first
270
+ # cache MISS inside a worker falls through to
271
+ # `RBS::EnvironmentLoader.new`, which reads a chain
272
+ # of non-`Ractor.shareable?` RubyGems / RBS module
273
+ # constants and raises `Ractor::IsolationError`.
274
+ # Pre-warming requires a `cache_store`; the run aborts
275
+ # to sequential mode otherwise. See ADR-15 Phase 4b.x
276
+ # for the full chain of failing constants.
277
+ if @cache_store.nil?
278
+ return analyze_files_sequentially_fallback(
279
+ files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
280
+ )
281
+ end
282
+ prewarm_rbs_cache_for_pool
283
+
284
+ configuration = @configuration
285
+ cache_root = @cache_store&.root
286
+ blueprints = @plugin_registry.blueprints
287
+ explain = @explain
288
+
289
+ pool = Array.new(@workers) do
290
+ Ractor.new(configuration, cache_root, blueprints, explain) do |configuration, cache_root, blueprints, explain|
291
+ cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
292
+ session = Rigor::Analysis::WorkerSession.new(
293
+ configuration: configuration,
294
+ cache_store: cache_store,
295
+ plugin_blueprints: blueprints,
296
+ explain: explain
297
+ )
298
+ main = Ractor.main
299
+ main.send([:prepare, session.prepare_diagnostics])
300
+
301
+ loop do
302
+ msg = Ractor.receive
303
+ break if msg.nil?
304
+
305
+ main.send([:file, msg, session.analyze(msg)])
306
+ end
307
+
308
+ main.send([:done, session.drain_reporters])
309
+ end
310
+ end
311
+
312
+ files.each_with_index { |path, index| pool[index % pool.size].send(path) }
313
+ pool.each { |worker| worker.send(nil) }
314
+
315
+ prepare_diagnostics = nil
316
+ results_by_path = {}
317
+ done_count = 0
318
+
319
+ while done_count < pool.size
320
+ message = Ractor.receive
321
+ case message.first
322
+ when :prepare
323
+ prepare_diagnostics ||= message.last
324
+ when :file
325
+ results_by_path[message[1]] = message[2]
326
+ when :done
327
+ merge_worker_reporters(message.last)
328
+ done_count += 1
329
+ end
330
+ end
331
+
332
+ pool.each(&:join)
333
+
334
+ Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
335
+ end
336
+
337
+ # End-of-run telemetry. Walks the cached
338
+ # `class_decl_paths` snapshot (sequential mode: from
339
+ # the coordinator's environment; pool mode: from the
340
+ # first worker's `:prepare` payload) and partitions the
341
+ # RBS class universe into "project sig/" (paths under
342
+ # `signature_paths`) vs "bundled" (everything else).
343
+ # Gem source-walk counts come from `dependency_source_index`
344
+ # which is already constructed regardless of pool mode.
345
+ # Wall + RSS are single syscalls; total cost is bounded
346
+ # by the snapshot size (~1000-2000 entries).
347
+ def build_run_stats(wall_started_at:, expansion:)
348
+ snapshot = @class_decl_paths_snapshot || {}.freeze
349
+ project_sig, bundled = RunStats.partition_classes(
350
+ class_decl_paths: snapshot,
351
+ signature_paths: @signature_paths_snapshot
352
+ )
353
+ RunStats.new(
354
+ wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
355
+ peak_rss_bytes: RunStats.peak_rss_bytes,
356
+ target_files: expansion.fetch(:files).size,
357
+ rbs_classes_total: snapshot.size,
358
+ rbs_classes_project_sig: project_sig,
359
+ rbs_classes_bundled: bundled,
360
+ rbs_attribution_available: RunStats.attribution_available?(class_decl_paths: snapshot),
361
+ gem_walk_classes: @dependency_source_index.class_to_gem.size,
362
+ gem_walk_gems: @dependency_source_index.resolved_gems.size
363
+ )
364
+ end
365
+
366
+ # ADR-15 Phase 4b.x — drives every cached RBS producer
367
+ # on the main Ractor so each worker can serve all
368
+ # reflection queries from disk (Marshal-load only).
369
+ # Builds a single coordinator-side {Environment} for
370
+ # this purpose; the env object is discarded immediately
371
+ # after the cache is warm — workers build their own
372
+ # `Environment.for_project` inside the Ractor body,
373
+ # which then routes through `cached_env` instead of
374
+ # `RBS::EnvironmentLoader.new`.
375
+ def prewarm_rbs_cache_for_pool
376
+ warm_env = Environment.for_project(
377
+ libraries: @configuration.libraries,
378
+ signature_paths: @configuration.signature_paths,
379
+ cache_store: @cache_store,
380
+ bundler_bundle_path: @configuration.bundler_bundle_path,
381
+ bundler_auto_detect: @configuration.bundler_auto_detect,
382
+ bundler_lockfile: @configuration.bundler_lockfile,
383
+ rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
384
+ rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
385
+ )
386
+ warm_env.rbs_loader&.prewarm
387
+ end
388
+
389
+ # ADR-15 Phase 4b.x — pool-mode safety net. When pool
390
+ # mode is configured but a precondition fails (currently:
391
+ # `--no-cache` would force workers through
392
+ # `EnvironmentLoader.new`), degrade to sequential
393
+ # analysis with a `:warning` `pool-degraded` diagnostic
394
+ # at run start. The actual per-file analysis runs on
395
+ # the coordinator, identical to the default sequential
396
+ # path.
397
+ def analyze_files_sequentially_fallback(files, reason:)
398
+ environment = build_runner_environment
399
+ diagnostics = files.flat_map { |path| analyze_file(path, environment) }
400
+ loader = environment.rbs_loader
401
+ @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
402
+ @signature_paths_snapshot = loader&.signature_paths || [].freeze
403
+ diagnostics.unshift(
404
+ Diagnostic.new(
405
+ path: ".rigor.yml", line: 1, column: 1,
406
+ message: "pool mode degraded to sequential: #{reason}",
407
+ severity: :warning, rule: "pool-degraded", source_family: :builtin
408
+ )
409
+ )
410
+ end
411
+
412
+ def merge_worker_reporters(drained)
413
+ rbs = drained.fetch(:rbs_extended)
414
+ rbs.fetch(:unresolved_payloads).each do |entry|
415
+ @rbs_extended_reporter.record_unresolved(
416
+ payload: entry.payload, source_location: entry.source_location
417
+ )
418
+ end
419
+ rbs.fetch(:lossy_projections).each do |entry|
420
+ @rbs_extended_reporter.record_lossy_projection(
421
+ head: entry.head, source_location: entry.source_location
422
+ )
423
+ end
424
+ drained.fetch(:boundary_cross).each do |entry|
425
+ @boundary_cross_reporter.record(
426
+ class_name: entry.class_name,
427
+ method_name: entry.method_name,
428
+ gem_name: entry.gem_name,
429
+ rbs_display: entry.rbs_display
430
+ )
431
+ end
432
+ end
433
+
123
434
  # Loads project-configured plugins through {Rigor::Plugin::Loader}
124
435
  # and returns the resulting {Rigor::Plugin::Registry}. Loader
125
436
  # failures are isolated: each surfaces as a `:plugin_loader`
@@ -304,6 +615,70 @@ module Rigor
304
615
  end
305
616
  end
306
617
 
618
+ # O4 Layer 3 slice 3 — graceful-degradation coverage
619
+ # report. When the project has a `Gemfile.lock` (slice 1)
620
+ # and one or more locked gems are not covered by ANY of
621
+ # the four RBS resolution paths (`DEFAULT_LIBRARIES`,
622
+ # `data/vendored_gem_sigs/`, slice-1 bundle-shipped
623
+ # `sig/`, slice-2 `rbs_collection.lock.yaml`), emit a
624
+ # single `:info` diagnostic summarising the uncovered set
625
+ # so the user can act on it (run `rbs collection install`,
626
+ # opt the gem into `dependencies.source_inference:`, or
627
+ # accept the `Dynamic[T]` fallback).
628
+ #
629
+ # Suppressed when the lockfile is empty, when every gem
630
+ # is covered, or when slice 1's `bundler.lockfile`
631
+ # discovery returned nothing (no lockfile to read).
632
+ def rbs_coverage_diagnostics
633
+ locked = Environment::LockfileResolver.locked_gems(
634
+ lockfile_path: @configuration.bundler_lockfile,
635
+ project_root: Dir.pwd,
636
+ auto_detect: @configuration.bundler_auto_detect
637
+ )
638
+ return [] if locked.empty?
639
+
640
+ bundle_sig_paths = Environment::BundleSigDiscovery.discover(
641
+ bundle_path: @configuration.bundler_bundle_path,
642
+ project_root: Dir.pwd,
643
+ auto_detect: @configuration.bundler_auto_detect,
644
+ locked_gems: locked
645
+ )
646
+ collection_paths = Environment::RbsCollectionDiscovery.discover(
647
+ lockfile_path: @configuration.rbs_collection_lockfile,
648
+ project_root: Dir.pwd,
649
+ auto_detect: @configuration.rbs_collection_auto_detect
650
+ )
651
+ rows = Environment::RbsCoverageReport.classify(
652
+ locked_gems: locked,
653
+ default_libraries: Environment::DEFAULT_LIBRARIES,
654
+ bundle_sig_paths: bundle_sig_paths,
655
+ rbs_collection_paths: collection_paths
656
+ )
657
+ missing = Environment::RbsCoverageReport.missing(rows)
658
+ return [] if missing.empty?
659
+
660
+ [build_rbs_coverage_missing_diagnostic(missing)]
661
+ end
662
+
663
+ def build_rbs_coverage_missing_diagnostic(missing)
664
+ sample_size = 5
665
+ sample = missing.first(sample_size).map(&:gem_name)
666
+ suffix = missing.size > sample_size ? ", and #{missing.size - sample_size} more" : ""
667
+ Diagnostic.new(
668
+ path: ".rigor.yml",
669
+ line: 1,
670
+ column: 1,
671
+ message: "#{missing.size} gem(s) in Gemfile.lock have no RBS available: " \
672
+ "#{sample.join(', ')}#{suffix}. " \
673
+ "Consider `rbs collection install` to fetch community RBS from " \
674
+ "`ruby/gem_rbs_collection`, ship `sig/` in the gem itself, or " \
675
+ "opt the gem into `dependencies.source_inference:` in `.rigor.yml`.",
676
+ severity: :info,
677
+ rule: "rbs.coverage.missing-gem",
678
+ source_family: :builtin
679
+ )
680
+ end
681
+
307
682
  # ADR-13 slice 3b — drains the per-run
308
683
  # {RbsExtended::Reporter} into one diagnostic per accumulated
309
684
  # event: