rigortype 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. metadata +92 -1
@@ -11,11 +11,17 @@ 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"
15
+ require_relative "../inference/project_patched_scanner"
14
16
  require_relative "../inference/method_dispatcher/file_folding"
17
+ require_relative "buffer_binding"
15
18
  require_relative "check_rules"
16
19
  require_relative "dependency_source_inference"
17
20
  require_relative "diagnostic"
21
+ require_relative "project_scan"
18
22
  require_relative "result"
23
+ require_relative "run_stats"
24
+ require_relative "worker_session"
19
25
 
20
26
  module Rigor
21
27
  module Analysis
@@ -35,19 +41,77 @@ module Rigor
35
41
  # run; the CLI's `--no-cache` flag wires `nil` through.
36
42
  # v0.0.9 group A slice 1 introduces the surface; later
37
43
  # slices route real producers through it.
38
- def initialize(configuration:, explain: false,
44
+ # @param workers [Integer] ADR-15 Phase 4b — when greater
45
+ # than zero, per-file analysis dispatches across a pool of
46
+ # N Ractor workers built around {WorkerSession}. Default
47
+ # `0` keeps the sequential code path bit-for-bit
48
+ # unchanged. Phase 4c will wire the CLI / `.rigor.yml`
49
+ # surface that produces non-zero values; this slice
50
+ # leaves the parameter as a programmatic opt-in only.
51
+ # @param collect_stats [Boolean] when true (default), `#run`
52
+ # builds a {RunStats} summary exposed via `result.stats`
53
+ # — this forces the RBS env build at end-of-run so the
54
+ # `class_decl_paths` snapshot has real source attribution.
55
+ # Set to false to skip the stats summary entirely; the
56
+ # CLI's `--no-stats` threads `false` through to keep
57
+ # trivial-fixture runs from warming `.rigor/cache`.
58
+ # @param prebuilt [Rigor::Analysis::ProjectScan, nil] when
59
+ # supplied, the runner adopts the pre-built plugin
60
+ # registry / dependency-source index / scanner outputs
61
+ # from the snapshot and skips the per-call pre-passes
62
+ # that produce them. Used by long-lived integrations
63
+ # (`Rigor::LanguageServer::ProjectContext`) to keep
64
+ # per-buffer requests fast — scanners walk the project
65
+ # once per generation rather than once per request, and
66
+ # plugin `#prepare` runs once per generation rather than
67
+ # once per request. Watched-file invalidation is the
68
+ # owner's responsibility; the runner trusts the snapshot
69
+ # it was given.
70
+ # @param environment [Rigor::Environment, nil] opt-in
71
+ # Environment override. When supplied, sequential mode uses
72
+ # the provided env instance in `#analyze_files` instead of
73
+ # building a fresh one via `Environment.for_project`, and
74
+ # attaches the runner's per-run reporter pair onto the
75
+ # env's mutable `Reporters` slot via
76
+ # `Environment#attach_reporters!`. Long-lived consumers
77
+ # (LSP `ProjectContext`) pass a shared env so per-publish
78
+ # work doesn't repeat the `Environment.for_project` build
79
+ # (bundler / lockfile / collection discovery, RbsLoader
80
+ # construction). Pool mode ignores the override — each
81
+ # worker continues to build its own Environment.
82
+ def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists
39
83
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
40
- plugin_requirer: nil)
84
+ plugin_requirer: nil, workers: 0, collect_stats: true,
85
+ buffer: nil, prebuilt: nil, environment: nil)
41
86
  @configuration = configuration
42
87
  @explain = explain
43
- @cache_store = cache_store
88
+ @cache_store = enforce_read_only_cache(cache_store, buffer)
44
89
  @plugin_requirer = plugin_requirer
90
+ @workers = workers
91
+ @collect_stats = collect_stats
92
+ @buffer = buffer
93
+ @prebuilt = prebuilt
94
+ @environment_override = environment
45
95
  @plugin_registry = Plugin::Registry::EMPTY
46
96
  @dependency_source_index = DependencySourceInference::Index::EMPTY
47
97
  @rbs_extended_reporter = RbsExtended::Reporter.new
48
98
  @boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
99
+ # `#run` resets these for each invocation; pre-seed them to
100
+ # empty containers so `build_run_stats` / `pre_file_diagnostics`
101
+ # (private, called only from `#run`) can read them without
102
+ # nil-guards.
103
+ @class_decl_paths_snapshot = {}.freeze
104
+ @signature_paths_snapshot = [].freeze
105
+ @cached_plugin_prepare_diagnostics = [].freeze
106
+ @project_discovered_classes = {}.freeze
49
107
  end
50
108
 
109
+ # ADR-pending editor mode — present when the runner is wired
110
+ # for the `--tmp-file` / `--instead-of` buffer-binding shape
111
+ # (`docs/design/20260516-editor-mode.md`). Nil for normal
112
+ # project runs.
113
+ attr_reader :buffer
114
+
51
115
  # Walks every Ruby file under `paths`, parses it, builds a
52
116
  # per-node scope index through
53
117
  # `Rigor::Inference::ScopeIndexer`, and runs the
@@ -60,29 +124,189 @@ module Rigor
60
124
  Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
61
125
  @configuration.fold_platform_specific_paths
62
126
 
127
+ wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
128
+
63
129
  target_ruby_error = validate_target_ruby
64
130
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
65
131
 
66
- @plugin_registry = load_plugins
67
- @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,
72
- 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
76
- )
77
132
  expansion = expand_paths(paths)
133
+ @class_decl_paths_snapshot = {}.freeze
134
+ @signature_paths_snapshot = []
135
+
136
+ if @prebuilt
137
+ adopt_prebuilt_project_scan(@prebuilt)
138
+ else
139
+ run_project_pre_passes(expansion: expansion)
140
+ end
78
141
 
79
142
  diagnostics = pre_file_diagnostics(expansion)
80
- diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
143
+ diagnostics += analyze_files(target_files(expansion))
81
144
  diagnostics += rbs_extended_reporter_diagnostics
82
145
  diagnostics += boundary_cross_diagnostics
83
146
 
84
- Result.new(diagnostics: apply_severity_profile(diagnostics))
147
+ Result.new(
148
+ diagnostics: apply_severity_profile(diagnostics),
149
+ stats: @collect_stats ? build_run_stats(wall_started_at: wall_started_at, expansion: expansion) : nil
150
+ )
151
+ end
152
+
153
+ # Runs every project-wide pre-pass (`load_plugins` +
154
+ # `plugin#prepare` + dependency-source builder +
155
+ # synthetic-method scanner + project-patched scanner)
156
+ # exactly once, then returns a frozen
157
+ # {Rigor::Analysis::ProjectScan} snapshot.
158
+ #
159
+ # Long-lived integrations (`Rigor::LanguageServer::ProjectContext`)
160
+ # call this once per project-state generation and feed the
161
+ # snapshot back into `Runner.new(prebuilt: scan)` for every
162
+ # subsequent per-buffer publish. The cold pre-pass cost is
163
+ # paid once per generation rather than once per keystroke.
164
+ #
165
+ # Notes for callers:
166
+ # - The runner this method is called on may be a "build only"
167
+ # instance — `@buffer` is typically nil so the scanners
168
+ # observe on-disk bytes for the full project. Callers that
169
+ # want pre-passes to see a particular buffer's edits should
170
+ # build the runner WITH `buffer:` set.
171
+ # - The returned ProjectScan is frozen and shareable; the
172
+ # underlying `plugin_registry` is the same object that ran
173
+ # `#prepare`, so the per-plugin `services.fact_store` is
174
+ # already populated for subsequent dispatch use.
175
+ def prepare_project_scan(paths: @configuration.paths)
176
+ expansion = expand_paths(paths)
177
+ run_project_pre_passes(expansion: expansion)
178
+ ProjectScan.new(
179
+ plugin_registry: @plugin_registry,
180
+ dependency_source_index: @dependency_source_index,
181
+ synthetic_method_index: @synthetic_method_index,
182
+ project_patched_methods: @project_patched_methods,
183
+ plugin_prepare_diagnostics: @cached_plugin_prepare_diagnostics.dup.freeze,
184
+ pre_eval_diagnostics: @pre_eval_diagnostics_from_scanner.dup.freeze
185
+ )
186
+ end
187
+
188
+ # Internal: drives every project-wide pre-pass and stores
189
+ # the results on instance variables in the order the
190
+ # downstream `#run` body expects. Extracted so
191
+ # `#prepare_project_scan` and the prebuilt-less `#run` path
192
+ # share one implementation.
193
+ def run_project_pre_passes(expansion:)
194
+ @plugin_registry = load_plugins
195
+ @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
196
+ # ADR-18 slice 3 — plugin prepare MUST run before the
197
+ # synthetic-method scanner so cross-plugin facts
198
+ # (`:dry_type_aliases` etc.) are already published when
199
+ # the scanner resolves Tier C `returns_from_arg:`
200
+ # lookups. The diagnostics produced by prepare are
201
+ # captured here so `pre_file_diagnostics` can re-emit
202
+ # them in the existing order without invoking prepare
203
+ # twice. Pool mode still re-runs prepare per worker
204
+ # (workers don't see this early invocation), preserving
205
+ # the existing Phase 4b contract.
206
+ @cached_plugin_prepare_diagnostics =
207
+ pool_mode? ? [] : plugin_prepare_diagnostics
208
+ # ADR-16 slice 2b — Tier C pre-pass. Built once per run
209
+ # against the resolved file set + the loaded plugin
210
+ # registry's `heredoc_templates` so synthetic methods are
211
+ # visible cross-file when per-file inference dispatches.
212
+ @synthetic_method_index = Inference::SyntheticMethodScanner.scan(
213
+ plugin_registry: @plugin_registry,
214
+ paths: expansion.fetch(:files),
215
+ environment: nil,
216
+ fact_store: shared_fact_store,
217
+ buffer: @buffer
218
+ )
219
+ # ADR-17 slice 2 — pre-eval pre-pass. Built once per run
220
+ # from the `pre_eval:` entries that exist on disk
221
+ # (slice-1's `pre-eval.file-not-found` `:error` already
222
+ # surfaced any missing entries; the scanner skips them
223
+ # here). The resulting {ProjectPatchedMethods} registry
224
+ # is consulted by the dispatcher tier between plugins
225
+ # and dependency-source inference so project-side
226
+ # patches resolve cross-file.
227
+ existing_pre_eval = @configuration.pre_eval.select { |path| File.file?(path) }
228
+ pre_eval_outcome = Inference::ProjectPatchedScanner.scan(existing_pre_eval, buffer: @buffer)
229
+ @project_patched_methods = pre_eval_outcome.registry
230
+ @pre_eval_diagnostics_from_scanner = pre_eval_outcome.diagnostics
231
+ # Cross-file class discovery — walks every project file
232
+ # for `class Foo` / `module Bar` declarations so a
233
+ # `Foo.method_call` receiver in one file resolves a
234
+ # `class Foo` declared in a sibling file. Without this
235
+ # pre-pass each file's `discovered_classes` was per-file
236
+ # only, and lexical lookup fell back to stdlib `::Foo`
237
+ # for any user class shadowing a stdlib name (e.g.
238
+ # `Google::Cloud::Storage::File`). Cost is one extra
239
+ # parse pass over the project; small projects pay
240
+ # tens of ms, larger projects ~1s. Future optimisation
241
+ # can share parses with the existing scanner passes.
242
+ @project_discovered_classes =
243
+ Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
244
+ end
245
+
246
+ # Internal: adopts a frozen {ProjectScan} snapshot supplied
247
+ # to `Runner.new(prebuilt: ...)` by storing each slot on
248
+ # the runner's ivar surface, mirroring what
249
+ # `run_project_pre_passes` would have produced.
250
+ def adopt_prebuilt_project_scan(scan)
251
+ @plugin_registry = scan.plugin_registry
252
+ @dependency_source_index = scan.dependency_source_index
253
+ @synthetic_method_index = scan.synthetic_method_index
254
+ @project_patched_methods = scan.project_patched_methods
255
+ @cached_plugin_prepare_diagnostics = scan.plugin_prepare_diagnostics
256
+ @pre_eval_diagnostics_from_scanner = scan.pre_eval_diagnostics
257
+ end
258
+ private :run_project_pre_passes, :adopt_prebuilt_project_scan
259
+
260
+ # ADR-15 Phase 4b — routes per-file analysis to either the
261
+ # sequential coordinator-side Environment (legacy path,
262
+ # default) or a Ractor worker pool built around
263
+ # {WorkerSession} (opt-in via `workers:`). The sequential
264
+ # path is bit-for-bit unchanged from v0.1.4 / earlier; the
265
+ # pool path is the substrate exercised by phase 4c when
266
+ # `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:`
267
+ # is wired.
268
+ #
269
+ # Sequential mode also snapshots `class_decl_paths` from the
270
+ # local environment after the per-file loop completes so
271
+ # `RunStats` can attribute the RBS class universe between
272
+ # project-sig and bundled sources. The env stays a LOCAL
273
+ # variable (not an ivar) so it goes GC-eligible when the
274
+ # method returns — holding it as long-lived state added
275
+ # memory pressure that surfaced as a Bus Error during the
276
+ # spec suite under Ruby 4.0 + rbs 4.0.2.
277
+ def analyze_files(files)
278
+ return [] if files.empty?
279
+
280
+ if pool_mode?
281
+ analyze_files_in_pool(files)
282
+ else
283
+ environment = resolve_sequential_environment
284
+ result = files.flat_map { |path| analyze_file(path, environment) }
285
+ if @collect_stats
286
+ loader = environment.rbs_loader
287
+ @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
288
+ @signature_paths_snapshot = loader&.signature_paths || [].freeze
289
+ end
290
+ result
291
+ end
292
+ end
293
+
294
+ # Sequential-mode environment resolver. Returns the supplied
295
+ # `environment:` override (with the runner's fresh per-run
296
+ # reporter pair attached so dispatcher events route to THIS
297
+ # runner's diagnostics) when present; otherwise builds a
298
+ # fresh Environment per-call via {#build_runner_environment}
299
+ # — preserving the pre-override behaviour bit-for-bit.
300
+ def resolve_sequential_environment
301
+ return build_runner_environment unless @environment_override
302
+
303
+ @environment_override.attach_reporters!(
304
+ rbs_extended_reporter: @rbs_extended_reporter,
305
+ boundary_cross_reporter: @boundary_cross_reporter
306
+ )
307
+ @environment_override
85
308
  end
309
+ private :resolve_sequential_environment
86
310
 
87
311
  # Pre-file diagnostic streams that fire once per run rather
88
312
  # than per analyzed file: plugin load / prepare envelopes,
@@ -90,15 +314,81 @@ module Rigor
90
314
  # `expand_paths` errors for `paths:` entries that don't
91
315
  # exist or aren't `.rb`. Aggregated here so `#run` stays
92
316
  # under the ABC budget.
317
+ #
318
+ # ADR-15 Phase 4b — `plugin_prepare_diagnostics` runs on
319
+ # the coordinator's plugin registry under sequential mode;
320
+ # under pool mode each worker re-runs `prepare` against
321
+ # its own plugin instances, so the pool path drains the
322
+ # first worker's prepare-diagnostic snapshot into the
323
+ # aggregated diagnostic stream instead (see
324
+ # {#analyze_files_in_pool}). Skipping the coordinator
325
+ # prepare in pool mode avoids double-running `#prepare`
326
+ # against the coordinator-side plugin instances (which
327
+ # the pool path never consults for per-file analysis).
93
328
  def pre_file_diagnostics(expansion)
329
+ # ADR-18 slice 3 — prepare diagnostics are captured
330
+ # earlier in #run (before the synthetic-method scanner)
331
+ # so cross-plugin facts are available to the scanner.
332
+ # We re-surface the captured diagnostics here so the
333
+ # existing pre_file_diagnostics ordering is preserved.
334
+ prepare = pool_mode? ? [] : @cached_plugin_prepare_diagnostics
94
335
  plugin_load_diagnostics +
95
- plugin_prepare_diagnostics +
336
+ prepare +
337
+ pre_eval_diagnostics +
96
338
  dependency_source_diagnostics +
97
339
  dependency_source_budget_diagnostics +
98
340
  dependency_source_config_conflict_diagnostics +
341
+ rbs_coverage_diagnostics +
99
342
  expansion.fetch(:errors)
100
343
  end
101
344
 
345
+ # Returns the per-run shared `Plugin::FactStore` instance.
346
+ # All loaded plugins share this store through their
347
+ # respective `Plugin::Services` (the same instance is
348
+ # threaded by `Plugin::Loader.load`). Returns `nil` when
349
+ # no plugins are loaded.
350
+ def shared_fact_store
351
+ return nil if @plugin_registry.nil? || @plugin_registry.empty?
352
+
353
+ @plugin_registry.plugins.first&.services&.fact_store
354
+ end
355
+
356
+ # ADR-17 slice 1 — surface a `:error` diagnostic for each
357
+ # `pre_eval:` entry whose resolved path doesn't exist on
358
+ # disk. Loud failure mode (`:error`, not `:warning`):
359
+ # a missing pre_eval path is a configuration mistake the
360
+ # user must fix before analysis is meaningful.
361
+ #
362
+ # Slice 2 adds the `:warning` `pre-eval.parse-error`
363
+ # stream from the pre-pass scanner — accumulated as
364
+ # `@pre_eval_diagnostics_from_scanner` during {#run} and
365
+ # merged here so both diagnostics flow through the same
366
+ # severity / ordering pipeline.
367
+ def pre_eval_diagnostics
368
+ not_found = @configuration.pre_eval.filter_map do |path|
369
+ next if File.file?(path)
370
+
371
+ Diagnostic.new(
372
+ path: ".rigor.yml", line: 1, column: 1,
373
+ message: "pre_eval entry not found: #{path.inspect}. " \
374
+ "Pre-evaluation requires the file to exist on disk; remove the entry " \
375
+ "or create the file before re-running analysis.",
376
+ severity: :error,
377
+ rule: "pre-eval.file-not-found",
378
+ source_family: :builtin
379
+ )
380
+ end
381
+ not_found + Array(@pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
382
+ end
383
+
384
+ def diagnostic_from_hash(hash)
385
+ Diagnostic.new(
386
+ path: hash.fetch(:path), line: hash.fetch(:line), column: hash.fetch(:column),
387
+ message: hash.fetch(:message), severity: hash.fetch(:severity),
388
+ rule: hash.fetch(:rule), source_family: :builtin
389
+ )
390
+ end
391
+
102
392
  # `target_ruby` flows through to Prism's `version:` option.
103
393
  # Prism enforces the supported range and raises
104
394
  # `ArgumentError` for versions it does not recognise. Run a
@@ -120,6 +410,291 @@ module Rigor
120
410
 
121
411
  private
122
412
 
413
+ # Editor mode § "Scope choice — option A". Under
414
+ # `buffer:` non-nil the per-file analysis emits diagnostics
415
+ # ONLY for the buffer's logical path; the rest of `paths:`
416
+ # is consumed by the project-wide pre-passes (synthetic
417
+ # methods, project-patched methods, plugin facts) but
418
+ # contributes no per-file diagnostics. This is the v1 cut
419
+ # before a per-file diagnostic cache exists; option B (full
420
+ # project + incremental cache) is queued.
421
+ #
422
+ # The buffer's logical path is added to the file list even
423
+ # if it's not under `paths:` — per design § "Failure
424
+ # envelope": "--instead-of=Y with Y not under any paths:
425
+ # directory → treated as a valid logical identity for the
426
+ # buffer".
427
+ def target_files(expansion)
428
+ files = expansion.fetch(:files)
429
+ return files if @buffer.nil?
430
+
431
+ [@buffer.logical_path]
432
+ end
433
+
434
+ # Editor mode (`buffer:` non-nil) auto-flips the cache store
435
+ # to `read_only: true` so multiple debounced editor invocations
436
+ # against the same project don't churn the on-disk cache or
437
+ # race on schema-version writes. Cache reads continue
438
+ # unchanged; misses still run the producer block but the
439
+ # result is not persisted. Per design doc § "Cache behaviour".
440
+ def enforce_read_only_cache(cache_store, buffer)
441
+ return cache_store if buffer.nil?
442
+ return cache_store if cache_store.nil?
443
+ return cache_store if cache_store.read_only?
444
+
445
+ Cache::Store.new(root: cache_store.root, read_only: true)
446
+ end
447
+
448
+ # ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`.
449
+ # Editor mode (`buffer:` non-nil) silently overrides pool
450
+ # mode to sequential: per design § "Ractor pool mode", the
451
+ # pool's warm-up cost dominates one-file wall time, so the
452
+ # pool gains nothing on a per-buffer invocation. The override
453
+ # is part of the contract — not a degradation diagnostic —
454
+ # because `--workers=N` is a project-scale knob and editor
455
+ # mode is per-buffer; the conflict resolves toward the more
456
+ # specific axis.
457
+ def pool_mode?
458
+ return false unless @workers.is_a?(Integer) && @workers.positive?
459
+
460
+ @buffer.nil?
461
+ end
462
+
463
+ # Coordinator-side Environment used by the sequential code
464
+ # path. Pool mode builds one Environment per worker inside
465
+ # the worker Ractor's body instead.
466
+ def build_runner_environment
467
+ Environment.for_project(
468
+ libraries: @configuration.libraries,
469
+ signature_paths: @configuration.signature_paths,
470
+ cache_store: @cache_store,
471
+ plugin_registry: @plugin_registry,
472
+ dependency_source_index: @dependency_source_index,
473
+ rbs_extended_reporter: @rbs_extended_reporter,
474
+ boundary_cross_reporter: @boundary_cross_reporter,
475
+ bundler_bundle_path: @configuration.bundler_bundle_path,
476
+ bundler_auto_detect: @configuration.bundler_auto_detect,
477
+ bundler_lockfile: @configuration.bundler_lockfile,
478
+ rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
479
+ rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
480
+ synthetic_method_index: @synthetic_method_index,
481
+ project_patched_methods: @project_patched_methods
482
+ )
483
+ end
484
+
485
+ # ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
486
+ # Spawns `@workers` Ractors; each takes the shareable
487
+ # payload (Configuration, cache_root String, plugin
488
+ # Blueprint Array, explain Boolean) and builds its OWN
489
+ # WorkerSession internally. Files are distributed
490
+ # round-robin across the pool; each worker writes back to
491
+ # the main Ractor's mailbox via `Ractor.main.send` with
492
+ # one of three message kinds:
493
+ #
494
+ # - `[:prepare, diagnostics]` — once at startup, the
495
+ # session's `prepare_diagnostics` snapshot. The
496
+ # coordinator keeps the FIRST worker's snapshot only
497
+ # (plugin `#prepare` is deterministic per plugin, so
498
+ # each worker produces the same diagnostic set; surfacing
499
+ # them once avoids N× duplication).
500
+ # - `[:file, path, diagnostics]` — one per analysed file.
501
+ # - `[:done, drained_reporters]` — once at exit, the
502
+ # per-worker reporter snapshots for end-of-pool merge.
503
+ #
504
+ # The Ruby 4.0+ Ractor model uses a single per-Ractor
505
+ # mailbox (no `Ractor.yield`); workers push back via
506
+ # `Ractor.main.send`. The coordinator drains its mailbox
507
+ # via `Ractor.receive` until it has counted exactly
508
+ # `pool.size` `:done` messages.
509
+ #
510
+ # Diagnostic order: original path order. Workers may
511
+ # complete files out of order; the coordinator re-orders
512
+ # via the `results_by_path` Hash before flattening.
513
+ #
514
+ # Reporter merge: per-worker `RbsExtended::Reporter` and
515
+ # `BoundaryCrossReporter` entries are replayed into the
516
+ # runner-side accumulators via their `record_*` APIs,
517
+ # which dedupe on the same keys as a single-session run
518
+ # would. Net result: reporter state is identical to the
519
+ # sequential path.
520
+ def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
521
+ # Pre-warm class-level lazy memos on the MAIN Ractor.
522
+ # `Environment::ClassRegistry.default` is the
523
+ # default kwarg threaded through `Environment.new`
524
+ # inside each worker session; lazy-initialising it
525
+ # from a non-main Ractor would trip
526
+ # `Ractor::IsolationError`. Touching it here forces
527
+ # the (shareable) registry into the class-ivar cache
528
+ # before any worker reads.
529
+ Environment::ClassRegistry.default
530
+
531
+ # ADR-15 Phase 4b.x — pre-warm the RBS cache so
532
+ # workers serve every reflection query from the
533
+ # Marshal blob on disk. Without this, the first
534
+ # cache MISS inside a worker falls through to
535
+ # `RBS::EnvironmentLoader.new`, which reads a chain
536
+ # of non-`Ractor.shareable?` RubyGems / RBS module
537
+ # constants and raises `Ractor::IsolationError`.
538
+ # Pre-warming requires a `cache_store`; the run aborts
539
+ # to sequential mode otherwise. See ADR-15 Phase 4b.x
540
+ # for the full chain of failing constants.
541
+ if @cache_store.nil?
542
+ return analyze_files_sequentially_fallback(
543
+ files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
544
+ )
545
+ end
546
+ prewarm_rbs_cache_for_pool
547
+
548
+ configuration = @configuration
549
+ cache_root = @cache_store&.root
550
+ blueprints = @plugin_registry.blueprints
551
+ explain = @explain
552
+
553
+ pool = Array.new(@workers) do
554
+ Ractor.new(configuration, cache_root, blueprints, explain) do |configuration, cache_root, blueprints, explain|
555
+ cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
556
+ session = Rigor::Analysis::WorkerSession.new(
557
+ configuration: configuration,
558
+ cache_store: cache_store,
559
+ plugin_blueprints: blueprints,
560
+ explain: explain
561
+ )
562
+ main = Ractor.main
563
+ main.send([:prepare, session.prepare_diagnostics])
564
+
565
+ loop do
566
+ msg = Ractor.receive
567
+ break if msg.nil?
568
+
569
+ main.send([:file, msg, session.analyze(msg)])
570
+ end
571
+
572
+ main.send([:done, session.drain_reporters])
573
+ end
574
+ end
575
+
576
+ files.each_with_index { |path, index| pool[index % pool.size].send(path) }
577
+ pool.each { |worker| worker.send(nil) }
578
+
579
+ prepare_diagnostics = nil
580
+ results_by_path = {}
581
+ done_count = 0
582
+
583
+ while done_count < pool.size
584
+ message = Ractor.receive
585
+ case message.first
586
+ when :prepare
587
+ prepare_diagnostics ||= message.last
588
+ when :file
589
+ results_by_path[message[1]] = message[2]
590
+ when :done
591
+ merge_worker_reporters(message.last)
592
+ done_count += 1
593
+ end
594
+ end
595
+
596
+ pool.each(&:join)
597
+
598
+ Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
599
+ end
600
+
601
+ # End-of-run telemetry. Walks the cached
602
+ # `class_decl_paths` snapshot (sequential mode: from
603
+ # the coordinator's environment; pool mode: from the
604
+ # first worker's `:prepare` payload) and partitions the
605
+ # RBS class universe into "project sig/" (paths under
606
+ # `signature_paths`) vs "bundled" (everything else).
607
+ # Gem source-walk counts come from `dependency_source_index`
608
+ # which is already constructed regardless of pool mode.
609
+ # Wall + RSS are single syscalls; total cost is bounded
610
+ # by the snapshot size (~1000-2000 entries).
611
+ def build_run_stats(wall_started_at:, expansion:)
612
+ snapshot = @class_decl_paths_snapshot
613
+ project_sig, bundled = RunStats.partition_classes(
614
+ class_decl_paths: snapshot,
615
+ signature_paths: @signature_paths_snapshot
616
+ )
617
+ RunStats.new(
618
+ wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
619
+ peak_rss_bytes: RunStats.peak_rss_bytes,
620
+ target_files: expansion.fetch(:files).size,
621
+ rbs_classes_total: snapshot.size,
622
+ rbs_classes_project_sig: project_sig,
623
+ rbs_classes_bundled: bundled,
624
+ rbs_attribution_available: RunStats.attribution_available?(class_decl_paths: snapshot),
625
+ gem_walk_classes: @dependency_source_index.class_to_gem.size,
626
+ gem_walk_gems: @dependency_source_index.resolved_gems.size
627
+ )
628
+ end
629
+
630
+ # ADR-15 Phase 4b.x — drives every cached RBS producer
631
+ # on the main Ractor so each worker can serve all
632
+ # reflection queries from disk (Marshal-load only).
633
+ # Builds a single coordinator-side {Environment} for
634
+ # this purpose; the env object is discarded immediately
635
+ # after the cache is warm — workers build their own
636
+ # `Environment.for_project` inside the Ractor body,
637
+ # which then routes through `cached_env` instead of
638
+ # `RBS::EnvironmentLoader.new`.
639
+ def prewarm_rbs_cache_for_pool
640
+ warm_env = Environment.for_project(
641
+ libraries: @configuration.libraries,
642
+ signature_paths: @configuration.signature_paths,
643
+ cache_store: @cache_store,
644
+ bundler_bundle_path: @configuration.bundler_bundle_path,
645
+ bundler_auto_detect: @configuration.bundler_auto_detect,
646
+ bundler_lockfile: @configuration.bundler_lockfile,
647
+ rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
648
+ rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
649
+ )
650
+ warm_env.rbs_loader&.prewarm
651
+ end
652
+
653
+ # ADR-15 Phase 4b.x — pool-mode safety net. When pool
654
+ # mode is configured but a precondition fails (currently:
655
+ # `--no-cache` would force workers through
656
+ # `EnvironmentLoader.new`), degrade to sequential
657
+ # analysis with a `:warning` `pool-degraded` diagnostic
658
+ # at run start. The actual per-file analysis runs on
659
+ # the coordinator, identical to the default sequential
660
+ # path.
661
+ def analyze_files_sequentially_fallback(files, reason:)
662
+ environment = build_runner_environment
663
+ diagnostics = files.flat_map { |path| analyze_file(path, environment) }
664
+ loader = environment.rbs_loader
665
+ @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
666
+ @signature_paths_snapshot = loader&.signature_paths || [].freeze
667
+ diagnostics.unshift(
668
+ Diagnostic.new(
669
+ path: ".rigor.yml", line: 1, column: 1,
670
+ message: "pool mode degraded to sequential: #{reason}",
671
+ severity: :warning, rule: "pool-degraded", source_family: :builtin
672
+ )
673
+ )
674
+ end
675
+
676
+ def merge_worker_reporters(drained)
677
+ rbs = drained.fetch(:rbs_extended)
678
+ rbs.fetch(:unresolved_payloads).each do |entry|
679
+ @rbs_extended_reporter.record_unresolved(
680
+ payload: entry.payload, source_location: entry.source_location
681
+ )
682
+ end
683
+ rbs.fetch(:lossy_projections).each do |entry|
684
+ @rbs_extended_reporter.record_lossy_projection(
685
+ head: entry.head, source_location: entry.source_location
686
+ )
687
+ end
688
+ drained.fetch(:boundary_cross).each do |entry|
689
+ @boundary_cross_reporter.record(
690
+ class_name: entry.class_name,
691
+ method_name: entry.method_name,
692
+ gem_name: entry.gem_name,
693
+ rbs_display: entry.rbs_display
694
+ )
695
+ end
696
+ end
697
+
123
698
  # Loads project-configured plugins through {Rigor::Plugin::Loader}
124
699
  # and returns the resulting {Rigor::Plugin::Registry}. Loader
125
700
  # failures are isolated: each surfaces as a `:plugin_loader`
@@ -304,6 +879,70 @@ module Rigor
304
879
  end
305
880
  end
306
881
 
882
+ # O4 Layer 3 slice 3 — graceful-degradation coverage
883
+ # report. When the project has a `Gemfile.lock` (slice 1)
884
+ # and one or more locked gems are not covered by ANY of
885
+ # the four RBS resolution paths (`DEFAULT_LIBRARIES`,
886
+ # `data/vendored_gem_sigs/`, slice-1 bundle-shipped
887
+ # `sig/`, slice-2 `rbs_collection.lock.yaml`), emit a
888
+ # single `:info` diagnostic summarising the uncovered set
889
+ # so the user can act on it (run `rbs collection install`,
890
+ # opt the gem into `dependencies.source_inference:`, or
891
+ # accept the `Dynamic[T]` fallback).
892
+ #
893
+ # Suppressed when the lockfile is empty, when every gem
894
+ # is covered, or when slice 1's `bundler.lockfile`
895
+ # discovery returned nothing (no lockfile to read).
896
+ def rbs_coverage_diagnostics
897
+ locked = Environment::LockfileResolver.locked_gems(
898
+ lockfile_path: @configuration.bundler_lockfile,
899
+ project_root: Dir.pwd,
900
+ auto_detect: @configuration.bundler_auto_detect
901
+ )
902
+ return [] if locked.empty?
903
+
904
+ bundle_sig_paths = Environment::BundleSigDiscovery.discover(
905
+ bundle_path: @configuration.bundler_bundle_path,
906
+ project_root: Dir.pwd,
907
+ auto_detect: @configuration.bundler_auto_detect,
908
+ locked_gems: locked
909
+ )
910
+ collection_paths = Environment::RbsCollectionDiscovery.discover(
911
+ lockfile_path: @configuration.rbs_collection_lockfile,
912
+ project_root: Dir.pwd,
913
+ auto_detect: @configuration.rbs_collection_auto_detect
914
+ )
915
+ rows = Environment::RbsCoverageReport.classify(
916
+ locked_gems: locked,
917
+ default_libraries: Environment::DEFAULT_LIBRARIES,
918
+ bundle_sig_paths: bundle_sig_paths,
919
+ rbs_collection_paths: collection_paths
920
+ )
921
+ missing = Environment::RbsCoverageReport.missing(rows)
922
+ return [] if missing.empty?
923
+
924
+ [build_rbs_coverage_missing_diagnostic(missing)]
925
+ end
926
+
927
+ def build_rbs_coverage_missing_diagnostic(missing)
928
+ sample_size = 5
929
+ sample = missing.first(sample_size).map(&:gem_name)
930
+ suffix = missing.size > sample_size ? ", and #{missing.size - sample_size} more" : ""
931
+ Diagnostic.new(
932
+ path: ".rigor.yml",
933
+ line: 1,
934
+ column: 1,
935
+ message: "#{missing.size} gem(s) in Gemfile.lock have no RBS available: " \
936
+ "#{sample.join(', ')}#{suffix}. " \
937
+ "Consider `rbs collection install` to fetch community RBS from " \
938
+ "`ruby/gem_rbs_collection`, ship `sig/` in the gem itself, or " \
939
+ "opt the gem into `dependencies.source_inference:` in `.rigor.yml`.",
940
+ severity: :info,
941
+ rule: "rbs.coverage.missing-gem",
942
+ source_family: :builtin
943
+ )
944
+ end
945
+
307
946
  # ADR-13 slice 3b — drains the per-run
308
947
  # {RbsExtended::Reporter} into one diagnostic per accumulated
309
948
  # event:
@@ -520,7 +1159,11 @@ module Rigor
520
1159
  Array(paths).each do |path|
521
1160
  if File.directory?(path)
522
1161
  files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
523
- elsif File.file?(path) && path.end_with?(".rb")
1162
+ # Editor-mode bypass: the buffer's logical path is treated
1163
+ # as a real `.rb` file regardless of on-disk presence —
1164
+ # `parse_source` reads bytes from the buffer's physical
1165
+ # path. Common case: LSP client editing a brand-new file.
1166
+ elsif accept_as_ruby_file?(path)
524
1167
  files << path
525
1168
  elsif File.exist?(path)
526
1169
  errors << path_error(path, "not a Ruby file (expected `.rb` or a directory)")
@@ -531,6 +1174,11 @@ module Rigor
531
1174
  { files: files, errors: errors }
532
1175
  end
533
1176
 
1177
+ def accept_as_ruby_file?(path)
1178
+ (File.file?(path) && path.end_with?(".rb")) ||
1179
+ (@buffer && path == @buffer.logical_path)
1180
+ end
1181
+
534
1182
  # `Configuration#exclude_patterns` is a list of glob patterns
535
1183
  # checked against each globbed path via `File.fnmatch?` (without
536
1184
  # `FNM_PATHNAME`, so `**` and `*` both span path separators —
@@ -560,11 +1208,25 @@ module Rigor
560
1208
  )
561
1209
  end
562
1210
 
1211
+ # Reads + parses the source at `path`. Under editor mode
1212
+ # (`@buffer` set) reads bytes from `@buffer.physical_path`
1213
+ # when `path` matches the logical binding, then parses with
1214
+ # `filepath: path` so Prism's location data carries the
1215
+ # LOGICAL path. Non-binding paths go through the cheaper
1216
+ # `Prism.parse_file` codepath unchanged.
1217
+ def parse_source(path)
1218
+ physical = @buffer ? @buffer.resolve(path) : path
1219
+ return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
1220
+
1221
+ Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
1222
+ end
1223
+
563
1224
  def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
564
- parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
1225
+ parse_result = parse_source(path)
565
1226
  return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
566
1227
 
567
1228
  scope = Scope.empty(environment: environment, source_path: path)
1229
+ scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
568
1230
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
569
1231
  diagnostics = CheckRules.diagnose(
570
1232
  path: path,