rigortype 0.1.3 → 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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +154 -33
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
  9. data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +26 -6
  12. data/lib/rigor/analysis/result.rb +11 -3
  13. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  14. data/lib/rigor/analysis/run_stats.rb +193 -0
  15. data/lib/rigor/analysis/runner.rb +498 -12
  16. data/lib/rigor/analysis/worker_session.rb +327 -0
  17. data/lib/rigor/builtins/imported_refinements.rb +364 -55
  18. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  19. data/lib/rigor/cache/descriptor.rb +1 -1
  20. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  21. data/lib/rigor/cache/store.rb +39 -6
  22. data/lib/rigor/cli/diff_command.rb +1 -1
  23. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  24. data/lib/rigor/cli/type_of_command.rb +1 -1
  25. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  26. data/lib/rigor/cli/type_scan_report.rb +2 -2
  27. data/lib/rigor/cli.rb +61 -3
  28. data/lib/rigor/configuration/dependencies.rb +2 -2
  29. data/lib/rigor/configuration.rb +131 -6
  30. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  31. data/lib/rigor/environment/class_registry.rb +12 -3
  32. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  33. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  34. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  35. data/lib/rigor/environment/rbs_loader.rb +194 -6
  36. data/lib/rigor/environment/reflection.rb +152 -0
  37. data/lib/rigor/environment.rb +109 -6
  38. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  39. data/lib/rigor/flow_contribution/element.rb +1 -1
  40. data/lib/rigor/flow_contribution/fact.rb +1 -1
  41. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  42. data/lib/rigor/flow_contribution/merger.rb +3 -3
  43. data/lib/rigor/flow_contribution.rb +2 -2
  44. data/lib/rigor/inference/acceptance.rb +35 -1
  45. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  46. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  47. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  48. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  49. data/lib/rigor/inference/expression_typer.rb +77 -11
  50. data/lib/rigor/inference/fallback.rb +1 -1
  51. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  52. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  53. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -41
  54. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  55. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  56. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  57. data/lib/rigor/inference/method_dispatcher/method_folding.rb +135 -0
  58. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +7 -12
  59. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  60. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -44
  61. data/lib/rigor/inference/method_dispatcher.rb +274 -5
  62. data/lib/rigor/inference/method_parameter_binder.rb +22 -14
  63. data/lib/rigor/inference/narrowing.rb +129 -12
  64. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  65. data/lib/rigor/inference/scope_indexer.rb +14 -9
  66. data/lib/rigor/inference/statement_evaluator.rb +7 -7
  67. data/lib/rigor/inference/synthetic_method.rb +86 -0
  68. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  69. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  70. data/lib/rigor/plugin/blueprint.rb +60 -0
  71. data/lib/rigor/plugin/io_boundary.rb +0 -2
  72. data/lib/rigor/plugin/loader.rb +5 -3
  73. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  74. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  75. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  76. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  77. data/lib/rigor/plugin/macro.rb +31 -0
  78. data/lib/rigor/plugin/manifest.rb +102 -10
  79. data/lib/rigor/plugin/registry.rb +43 -2
  80. data/lib/rigor/plugin/services.rb +1 -1
  81. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  82. data/lib/rigor/plugin.rb +2 -0
  83. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  84. data/lib/rigor/rbs_extended.rb +131 -32
  85. data/lib/rigor/scope.rb +25 -8
  86. data/lib/rigor/sig_gen/classification.rb +36 -0
  87. data/lib/rigor/sig_gen/generator.rb +1048 -0
  88. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  89. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  90. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  91. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  92. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  93. data/lib/rigor/sig_gen/renderer.rb +157 -0
  94. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  95. data/lib/rigor/sig_gen/write_result.rb +48 -0
  96. data/lib/rigor/sig_gen/writer.rb +530 -0
  97. data/lib/rigor/sig_gen.rb +25 -0
  98. data/lib/rigor/trinary.rb +15 -11
  99. data/lib/rigor/type/bot.rb +6 -3
  100. data/lib/rigor/type/bound_method.rb +79 -0
  101. data/lib/rigor/type/combinator.rb +207 -3
  102. data/lib/rigor/type/constant.rb +13 -0
  103. data/lib/rigor/type/hash_shape.rb +0 -2
  104. data/lib/rigor/type/integer_range.rb +7 -7
  105. data/lib/rigor/type/refined.rb +18 -12
  106. data/lib/rigor/type/top.rb +4 -3
  107. data/lib/rigor/type/union.rb +20 -1
  108. data/lib/rigor/type.rb +1 -0
  109. data/lib/rigor/type_node/generic.rb +68 -0
  110. data/lib/rigor/type_node/identifier.rb +38 -0
  111. data/lib/rigor/type_node/indexed_access.rb +41 -0
  112. data/lib/rigor/type_node/integer_literal.rb +29 -0
  113. data/lib/rigor/type_node/name_scope.rb +52 -0
  114. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  115. data/lib/rigor/type_node/string_literal.rb +32 -0
  116. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  117. data/lib/rigor/type_node/union.rb +42 -0
  118. data/lib/rigor/type_node.rb +29 -0
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +2 -0
  121. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  122. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  123. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  124. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  125. data/sig/rigor/cli/diff_command.rbs +4 -0
  126. data/sig/rigor/cli/explain_command.rbs +4 -0
  127. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  128. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  129. data/sig/rigor/environment.rbs +8 -2
  130. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  131. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  132. data/sig/rigor/inference/builtins.rbs +2 -0
  133. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  134. data/sig/rigor/plugin/base.rbs +6 -0
  135. data/sig/rigor/plugin/blueprint.rbs +7 -0
  136. data/sig/rigor/plugin/fact_store.rbs +11 -0
  137. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  138. data/sig/rigor/plugin/load_error.rbs +6 -0
  139. data/sig/rigor/plugin/loader.rbs +20 -0
  140. data/sig/rigor/plugin/manifest.rbs +9 -0
  141. data/sig/rigor/plugin/registry.rbs +16 -0
  142. data/sig/rigor/plugin/services.rbs +3 -0
  143. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  144. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  145. data/sig/rigor/plugin.rbs +8 -0
  146. data/sig/rigor/scope.rbs +4 -2
  147. data/sig/rigor/type.rbs +28 -6
  148. data/sig/rigor.rbs +35 -2
  149. metadata +90 -1
@@ -6,15 +6,19 @@ require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../cache/store"
8
8
  require_relative "../plugin"
9
+ require_relative "../rbs_extended/reporter"
9
10
  require_relative "../reflection"
10
11
  require_relative "../type/combinator"
11
12
  require_relative "../inference/coverage_scanner"
12
13
  require_relative "../inference/scope_indexer"
14
+ require_relative "../inference/synthetic_method_scanner"
13
15
  require_relative "../inference/method_dispatcher/file_folding"
14
16
  require_relative "check_rules"
15
17
  require_relative "dependency_source_inference"
16
18
  require_relative "diagnostic"
17
19
  require_relative "result"
20
+ require_relative "run_stats"
21
+ require_relative "worker_session"
18
22
 
19
23
  module Rigor
20
24
  module Analysis
@@ -22,7 +26,8 @@ module Rigor
22
26
  RUBY_GLOB = "**/*.rb"
23
27
  DEFAULT_CACHE_ROOT = ".rigor/cache"
24
28
 
25
- attr_reader :cache_store, :plugin_registry, :dependency_source_index
29
+ attr_reader :cache_store, :plugin_registry, :dependency_source_index,
30
+ :rbs_extended_reporter, :boundary_cross_reporter
26
31
 
27
32
  # @param configuration [Rigor::Configuration]
28
33
  # @param explain [Boolean] surface fail-soft fallback events
@@ -33,15 +38,33 @@ module Rigor
33
38
  # run; the CLI's `--no-cache` flag wires `nil` through.
34
39
  # v0.0.9 group A slice 1 introduces the surface; later
35
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`.
36
55
  def initialize(configuration:, explain: false,
37
56
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
38
- plugin_requirer: nil)
57
+ plugin_requirer: nil, workers: 0, collect_stats: true)
39
58
  @configuration = configuration
40
59
  @explain = explain
41
60
  @cache_store = cache_store
42
61
  @plugin_requirer = plugin_requirer
62
+ @workers = workers
63
+ @collect_stats = collect_stats
43
64
  @plugin_registry = Plugin::Registry::EMPTY
44
65
  @dependency_source_index = DependencySourceInference::Index::EMPTY
66
+ @rbs_extended_reporter = RbsExtended::Reporter.new
67
+ @boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
45
68
  end
46
69
 
47
70
  # Walks every Ruby file under `paths`, parses it, builds a
@@ -56,24 +79,69 @@ module Rigor
56
79
  Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
57
80
  @configuration.fold_platform_specific_paths
58
81
 
82
+ wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+
59
84
  target_ruby_error = validate_target_ruby
60
85
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
61
86
 
62
87
  @plugin_registry = load_plugins
63
88
  @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
64
- environment = Environment.for_project(
65
- libraries: @configuration.libraries,
66
- signature_paths: @configuration.signature_paths,
67
- 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(
68
97
  plugin_registry: @plugin_registry,
69
- dependency_source_index: @dependency_source_index
98
+ paths: expansion.fetch(:files),
99
+ environment: nil
70
100
  )
71
- expansion = expand_paths(paths)
72
101
 
73
102
  diagnostics = pre_file_diagnostics(expansion)
74
- diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
103
+ diagnostics += analyze_files(expansion.fetch(:files))
104
+ diagnostics += rbs_extended_reporter_diagnostics
105
+ diagnostics += boundary_cross_diagnostics
106
+
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
75
112
 
76
- Result.new(diagnostics: apply_severity_profile(diagnostics))
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
77
145
  end
78
146
 
79
147
  # Pre-file diagnostic streams that fire once per run rather
@@ -82,12 +150,25 @@ module Rigor
82
150
  # `expand_paths` errors for `paths:` entries that don't
83
151
  # exist or aren't `.rb`. Aggregated here so `#run` stays
84
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).
85
164
  def pre_file_diagnostics(expansion)
165
+ prepare = pool_mode? ? [] : plugin_prepare_diagnostics
86
166
  plugin_load_diagnostics +
87
- plugin_prepare_diagnostics +
167
+ prepare +
88
168
  dependency_source_diagnostics +
89
169
  dependency_source_budget_diagnostics +
90
170
  dependency_source_config_conflict_diagnostics +
171
+ rbs_coverage_diagnostics +
91
172
  expansion.fetch(:errors)
92
173
  end
93
174
 
@@ -112,6 +193,244 @@ module Rigor
112
193
 
113
194
  private
114
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
+
115
434
  # Loads project-configured plugins through {Rigor::Plugin::Loader}
116
435
  # and returns the resulting {Rigor::Plugin::Registry}. Loader
117
436
  # failures are isolated: each surfaces as a `:plugin_loader`
@@ -296,6 +615,173 @@ module Rigor
296
615
  end
297
616
  end
298
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
+
682
+ # ADR-13 slice 3b — drains the per-run
683
+ # {RbsExtended::Reporter} into one diagnostic per accumulated
684
+ # event:
685
+ #
686
+ # - `dynamic.rbs-extended.unresolved` for every annotation
687
+ # payload the parser could not turn into a {Rigor::Type}.
688
+ # Surfaces typos and references to plugin-supplied names
689
+ # the project did not enable.
690
+ # - `dynamic.shape.lossy-projection` for every shape-projection
691
+ # type function (`pick_of`, …) applied to a carrier that
692
+ # loses precision (anything other than `HashShape` / `Tuple`).
693
+ #
694
+ # Both are authored `:info`; the severity profile re-stamps
695
+ # them per project taste. Path / line / column come from the
696
+ # annotation's `RBS::Location` when available, falling back
697
+ # to `.rigor.yml`-style file-level attribution otherwise.
698
+ def rbs_extended_reporter_diagnostics
699
+ return [] if @rbs_extended_reporter.empty?
700
+
701
+ unresolved = @rbs_extended_reporter.unresolved_payloads.map do |entry|
702
+ build_reporter_diagnostic(
703
+ entry.source_location,
704
+ rule: "dynamic.rbs-extended.unresolved",
705
+ message: "`RBS::Extended` directive payload could not be resolved: " \
706
+ "#{entry.payload.inspect}. Check for typos or enable a plugin " \
707
+ "that contributes the referenced type vocabulary."
708
+ )
709
+ end
710
+
711
+ lossy = @rbs_extended_reporter.lossy_projections.map do |entry|
712
+ build_reporter_diagnostic(
713
+ entry.source_location,
714
+ rule: "dynamic.shape.lossy-projection",
715
+ message: "Shape projection `#{entry.head}` applied to a carrier without a " \
716
+ "literal shape; the projection degrades to the input type. Author " \
717
+ "a `HashShape` / `Tuple` carrier or accept the unchanged result."
718
+ )
719
+ end
720
+
721
+ unresolved + lossy
722
+ end
723
+
724
+ # ADR-10 slice 5c — drains the per-run
725
+ # {DependencySourceInference::BoundaryCrossReporter} into
726
+ # `dynamic.dependency-source.boundary-cross` `:info`
727
+ # diagnostics. Each event flags a call site where RBS
728
+ # dispatch produced a concrete answer AND a `mode: :full`
729
+ # opt-in gem's source catalog ALSO contains an entry for
730
+ # the same `(class_name, method_name)` — i.e., both
731
+ # contracts have an opinion. RBS still wins on the
732
+ # dispatch result; the diagnostic is purely advisory so
733
+ # the user can verify the two contracts haven't drifted.
734
+ #
735
+ # Severity profile re-stamps the rule per project taste.
736
+ # The diagnostic carries no `path` / `line` / `column`
737
+ # because the crossing is per-method-per-gem, not
738
+ # per-call-site — the diagnostic anchors at `.rigor.yml`
739
+ # like the other `dependency-source.*` diagnostics that
740
+ # report on opt-in configuration.
741
+ def boundary_cross_diagnostics
742
+ return [] if @boundary_cross_reporter.empty?
743
+
744
+ @boundary_cross_reporter.entries.map do |entry|
745
+ Diagnostic.new(
746
+ path: ".rigor.yml", line: 1, column: 1,
747
+ message: "`#{entry.class_name}##{entry.method_name}` is contributed by both " \
748
+ "RBS (#{entry.rbs_display}) and the `mode: :full` opt-in gem " \
749
+ "`#{entry.gem_name}`. RBS wins on dispatch; verify the gem source " \
750
+ "has not drifted from its RBS contract.",
751
+ severity: :info,
752
+ rule: "dynamic.dependency-source.boundary-cross",
753
+ source_family: :builtin
754
+ )
755
+ end
756
+ end
757
+
758
+ def build_reporter_diagnostic(source_location, rule:, message:)
759
+ path, line, column = location_fields(source_location)
760
+ Diagnostic.new(
761
+ path: path, line: line, column: column,
762
+ message: message, severity: :info, rule: rule, source_family: :builtin
763
+ )
764
+ end
765
+
766
+ def location_fields(source_location)
767
+ return [".rigor.yml", 1, 1] if source_location.nil?
768
+
769
+ path = location_path(source_location)
770
+ line = source_location.respond_to?(:start_line) ? source_location.start_line : 1
771
+ column = source_location.respond_to?(:start_column) ? source_location.start_column + 1 : 1
772
+ [path, line, column]
773
+ rescue StandardError
774
+ [".rigor.yml", 1, 1]
775
+ end
776
+
777
+ def location_path(source_location)
778
+ buffer = source_location.respond_to?(:buffer) ? source_location.buffer : nil
779
+ return ".rigor.yml" if buffer.nil? || !buffer.respond_to?(:name)
780
+
781
+ name = buffer.name.to_s
782
+ name.empty? ? ".rigor.yml" : name
783
+ end
784
+
299
785
  # ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
300
786
  # hook once per run, after the loader's `#init` pass and
301
787
  # before per-file iteration. Plugins publish facts here
@@ -453,7 +939,7 @@ module Rigor
453
939
  parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
454
940
  return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
455
941
 
456
- scope = Scope.empty(environment: environment)
942
+ scope = Scope.empty(environment: environment, source_path: path)
457
943
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
458
944
  diagnostics = CheckRules.diagnose(
459
945
  path: path,