rigortype 0.1.5 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -50
  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/project_scan.rb +39 -0
  9. data/lib/rigor/analysis/runner.rb +309 -22
  10. data/lib/rigor/analysis/worker_session.rb +14 -2
  11. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  13. data/lib/rigor/cache/store.rb +33 -3
  14. data/lib/rigor/cli/lsp_command.rb +129 -0
  15. data/lib/rigor/cli/type_of_command.rb +44 -5
  16. data/lib/rigor/cli.rb +74 -12
  17. data/lib/rigor/configuration.rb +38 -2
  18. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  19. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  20. data/lib/rigor/environment/rbs_loader.rb +45 -2
  21. data/lib/rigor/environment/reporters.rb +40 -0
  22. data/lib/rigor/environment.rb +106 -9
  23. data/lib/rigor/inference/acceptance.rb +48 -3
  24. data/lib/rigor/inference/expression_typer.rb +47 -0
  25. data/lib/rigor/inference/hkt_body.rb +171 -0
  26. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  27. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  28. data/lib/rigor/inference/hkt_registry.rb +223 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  30. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  31. data/lib/rigor/inference/method_dispatcher.rb +154 -3
  32. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  33. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  34. data/lib/rigor/inference/scope_indexer.rb +156 -12
  35. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  36. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  37. data/lib/rigor/language_server/buffer_table.rb +63 -0
  38. data/lib/rigor/language_server/completion_provider.rb +438 -0
  39. data/lib/rigor/language_server/debouncer.rb +86 -0
  40. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  41. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  42. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  43. data/lib/rigor/language_server/hover_provider.rb +74 -0
  44. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  45. data/lib/rigor/language_server/loop.rb +71 -0
  46. data/lib/rigor/language_server/project_context.rb +145 -0
  47. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  48. data/lib/rigor/language_server/server.rb +384 -0
  49. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  50. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  51. data/lib/rigor/language_server/uri.rb +40 -0
  52. data/lib/rigor/language_server.rb +29 -0
  53. data/lib/rigor/plugin/base.rb +63 -0
  54. data/lib/rigor/plugin/macro/heredoc_template.rb +125 -11
  55. data/lib/rigor/plugin/manifest.rb +54 -7
  56. data/lib/rigor/plugin/registry.rb +19 -0
  57. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  58. data/lib/rigor/rbs_extended.rb +82 -2
  59. data/lib/rigor/sig_gen/generator.rb +12 -3
  60. data/lib/rigor/type/app.rb +107 -0
  61. data/lib/rigor/type.rb +1 -0
  62. data/lib/rigor/version.rb +1 -1
  63. data/sig/rigor/environment.rbs +8 -4
  64. data/sig/rigor/inference.rbs +2 -0
  65. data/sig/rigor.rbs +3 -1
  66. metadata +54 -1
@@ -12,10 +12,13 @@ require_relative "../type/combinator"
12
12
  require_relative "../inference/coverage_scanner"
13
13
  require_relative "../inference/scope_indexer"
14
14
  require_relative "../inference/synthetic_method_scanner"
15
+ require_relative "../inference/project_patched_scanner"
15
16
  require_relative "../inference/method_dispatcher/file_folding"
17
+ require_relative "buffer_binding"
16
18
  require_relative "check_rules"
17
19
  require_relative "dependency_source_inference"
18
20
  require_relative "diagnostic"
21
+ require_relative "project_scan"
19
22
  require_relative "result"
20
23
  require_relative "run_stats"
21
24
  require_relative "worker_session"
@@ -52,21 +55,63 @@ module Rigor
52
55
  # Set to false to skip the stats summary entirely; the
53
56
  # CLI's `--no-stats` threads `false` through to keep
54
57
  # trivial-fixture runs from warming `.rigor/cache`.
55
- def initialize(configuration:, explain: false,
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
56
83
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
57
- plugin_requirer: nil, workers: 0, collect_stats: true)
84
+ plugin_requirer: nil, workers: 0, collect_stats: true,
85
+ buffer: nil, prebuilt: nil, environment: nil)
58
86
  @configuration = configuration
59
87
  @explain = explain
60
- @cache_store = cache_store
88
+ @cache_store = enforce_read_only_cache(cache_store, buffer)
61
89
  @plugin_requirer = plugin_requirer
62
90
  @workers = workers
63
91
  @collect_stats = collect_stats
92
+ @buffer = buffer
93
+ @prebuilt = prebuilt
94
+ @environment_override = environment
64
95
  @plugin_registry = Plugin::Registry::EMPTY
65
96
  @dependency_source_index = DependencySourceInference::Index::EMPTY
66
97
  @rbs_extended_reporter = RbsExtended::Reporter.new
67
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
68
107
  end
69
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
+
70
115
  # Walks every Ruby file under `paths`, parses it, builds a
71
116
  # per-node scope index through
72
117
  # `Rigor::Inference::ScopeIndexer`, and runs the
@@ -84,23 +129,18 @@ module Rigor
84
129
  target_ruby_error = validate_target_ruby
85
130
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
86
131
 
87
- @plugin_registry = load_plugins
88
- @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
89
132
  expansion = expand_paths(paths)
90
133
  @class_decl_paths_snapshot = {}.freeze
91
134
  @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(
97
- plugin_registry: @plugin_registry,
98
- paths: expansion.fetch(:files),
99
- environment: nil
100
- )
135
+
136
+ if @prebuilt
137
+ adopt_prebuilt_project_scan(@prebuilt)
138
+ else
139
+ run_project_pre_passes(expansion: expansion)
140
+ end
101
141
 
102
142
  diagnostics = pre_file_diagnostics(expansion)
103
- diagnostics += analyze_files(expansion.fetch(:files))
143
+ diagnostics += analyze_files(target_files(expansion))
104
144
  diagnostics += rbs_extended_reporter_diagnostics
105
145
  diagnostics += boundary_cross_diagnostics
106
146
 
@@ -110,6 +150,113 @@ module Rigor
110
150
  )
111
151
  end
112
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
+
113
260
  # ADR-15 Phase 4b — routes per-file analysis to either the
114
261
  # sequential coordinator-side Environment (legacy path,
115
262
  # default) or a Ractor worker pool built around
@@ -133,7 +280,7 @@ module Rigor
133
280
  if pool_mode?
134
281
  analyze_files_in_pool(files)
135
282
  else
136
- environment = build_runner_environment
283
+ environment = resolve_sequential_environment
137
284
  result = files.flat_map { |path| analyze_file(path, environment) }
138
285
  if @collect_stats
139
286
  loader = environment.rbs_loader
@@ -144,6 +291,23 @@ module Rigor
144
291
  end
145
292
  end
146
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
308
+ end
309
+ private :resolve_sequential_environment
310
+
147
311
  # Pre-file diagnostic streams that fire once per run rather
148
312
  # than per analyzed file: plugin load / prepare envelopes,
149
313
  # the ADR-10 dependency-source resolution surface, and the
@@ -162,9 +326,15 @@ module Rigor
162
326
  # against the coordinator-side plugin instances (which
163
327
  # the pool path never consults for per-file analysis).
164
328
  def pre_file_diagnostics(expansion)
165
- prepare = pool_mode? ? [] : plugin_prepare_diagnostics
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
166
335
  plugin_load_diagnostics +
167
336
  prepare +
337
+ pre_eval_diagnostics +
168
338
  dependency_source_diagnostics +
169
339
  dependency_source_budget_diagnostics +
170
340
  dependency_source_config_conflict_diagnostics +
@@ -172,6 +342,53 @@ module Rigor
172
342
  expansion.fetch(:errors)
173
343
  end
174
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
+
175
392
  # `target_ruby` flows through to Prism's `version:` option.
176
393
  # Prism enforces the supported range and raises
177
394
  # `ArgumentError` for versions it does not recognise. Run a
@@ -193,8 +410,54 @@ module Rigor
193
410
 
194
411
  private
195
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.
196
457
  def pool_mode?
197
- @workers.is_a?(Integer) && @workers.positive?
458
+ return false unless @workers.is_a?(Integer) && @workers.positive?
459
+
460
+ @buffer.nil?
198
461
  end
199
462
 
200
463
  # Coordinator-side Environment used by the sequential code
@@ -214,7 +477,8 @@ module Rigor
214
477
  bundler_lockfile: @configuration.bundler_lockfile,
215
478
  rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
216
479
  rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
217
- synthetic_method_index: @synthetic_method_index
480
+ synthetic_method_index: @synthetic_method_index,
481
+ project_patched_methods: @project_patched_methods
218
482
  )
219
483
  end
220
484
 
@@ -345,7 +609,7 @@ module Rigor
345
609
  # Wall + RSS are single syscalls; total cost is bounded
346
610
  # by the snapshot size (~1000-2000 entries).
347
611
  def build_run_stats(wall_started_at:, expansion:)
348
- snapshot = @class_decl_paths_snapshot || {}.freeze
612
+ snapshot = @class_decl_paths_snapshot
349
613
  project_sig, bundled = RunStats.partition_classes(
350
614
  class_decl_paths: snapshot,
351
615
  signature_paths: @signature_paths_snapshot
@@ -895,7 +1159,11 @@ module Rigor
895
1159
  Array(paths).each do |path|
896
1160
  if File.directory?(path)
897
1161
  files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
898
- 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)
899
1167
  files << path
900
1168
  elsif File.exist?(path)
901
1169
  errors << path_error(path, "not a Ruby file (expected `.rb` or a directory)")
@@ -906,6 +1174,11 @@ module Rigor
906
1174
  { files: files, errors: errors }
907
1175
  end
908
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
+
909
1182
  # `Configuration#exclude_patterns` is a list of glob patterns
910
1183
  # checked against each globbed path via `File.fnmatch?` (without
911
1184
  # `FNM_PATHNAME`, so `**` and `*` both span path separators —
@@ -935,11 +1208,25 @@ module Rigor
935
1208
  )
936
1209
  end
937
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
+
938
1224
  def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
939
- parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
1225
+ parse_result = parse_source(path)
940
1226
  return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
941
1227
 
942
1228
  scope = Scope.empty(environment: environment, source_path: path)
1229
+ scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
943
1230
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
944
1231
  diagnostics = CheckRules.diagnose(
945
1232
  path: path,
@@ -88,10 +88,11 @@ module Rigor
88
88
  # directly-unrecognised node, mirroring
89
89
  # {Rigor::Analysis::Runner#explain_diagnostics}.
90
90
  def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
91
- plugin_blueprints: [], explain: false)
91
+ plugin_blueprints: [], explain: false, buffer: nil)
92
92
  @configuration = configuration
93
93
  @cache_store = cache_store
94
94
  @explain = explain
95
+ @buffer = buffer
95
96
 
96
97
  # NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
97
98
  # is process-global state. Writing it from a non-main
@@ -137,7 +138,7 @@ module Rigor
137
138
  # profile re-stamping is intentionally NOT applied — that
138
139
  # is a per-run aggregate concern handled by the caller.
139
140
  def analyze(path)
140
- parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
141
+ parse_result = parse_source(path)
141
142
  return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
142
143
 
143
144
  scope = Scope.empty(environment: @environment, source_path: path)
@@ -174,6 +175,17 @@ module Rigor
174
175
 
175
176
  private
176
177
 
178
+ # See {Runner#parse_source}. Same contract: if `@buffer`
179
+ # binds `path` to a physical file, read the physical bytes
180
+ # but stamp the parse buffer's `filepath:` as the LOGICAL
181
+ # path so downstream diagnostics carry the logical path.
182
+ def parse_source(path)
183
+ physical = @buffer ? @buffer.resolve(path) : path
184
+ return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
185
+
186
+ Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
187
+ end
188
+
177
189
  # Mirrors {Runner#build_trust_policy}. Workers under Phase
178
190
  # 4b will need the same trust derivation, and the
179
191
  # configuration is already shareable, so deriving it inside