rigortype 0.3.1 → 0.3.2

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -247,9 +247,31 @@ module Rigor
247
247
 
248
248
  bound = bound.without_inferred_param_mark(node.name)
249
249
  bound = bound.with_local_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
250
+ bound = bound.with_optimistic_local(node.name, optimistic_rhs_origin(node.value, post_rhs))
250
251
  [rhs_type, bound]
251
252
  end
252
253
 
254
+ # Issue #286 — the optimistic-nil-free counterpart of {#rhs_origin}, differing in two ways. It does not
255
+ # gate on `Dynamic`: the values this channel marks are ordinary `Union` / `Constant` / `Nominal`
256
+ # carriers, which is the whole point. And it resolves a bare local read through its binding, so
257
+ # `w = v` keeps the mark — the same propagation `OriginLookup` performs for the Dynamic channel.
258
+ def optimistic_rhs_origin(value_node, scope_after_rhs)
259
+ optimistic_origin_for(value_node, scope_after_rhs)
260
+ end
261
+
262
+ # The effective optimistic-nil-free cause of an expression: the mark on its own node, else — for a bare
263
+ # local read or a local write used in value position (`if (x = MAP[k])`, where the write has already
264
+ # bound the mark) — the one propagated onto the binding.
265
+ def optimistic_origin_for(node, scope)
266
+ recorded = scope.optimistic_origins[node]
267
+ return recorded if recorded
268
+
269
+ case node
270
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
271
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
272
+ end
273
+ end
274
+
253
275
  # ADR-82 WD1 — the {Inference::DynamicOrigin} cause to propagate onto a local / ivar being bound to `rhs`.
254
276
  # Returns the cause recorded on the assignment's rhs node when the value is `Dynamic` (so a later
255
277
  # `x` / `@x` receiver-read resolves to why it is dynamic), else `nil` — `with_local_origin` /
@@ -277,6 +299,7 @@ module Rigor
277
299
  rhs_type, post_rhs = sub_eval(node.value, scope)
278
300
  bound = post_rhs.with_ivar(node.name, rhs_type)
279
301
  bound = bound.with_ivar_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
302
+ bound = bound.with_optimistic_ivar(node.name, optimistic_rhs_origin(node.value, post_rhs))
280
303
  [rhs_type, bound]
281
304
  end
282
305
 
@@ -543,19 +566,34 @@ module Rigor
543
566
  # non-falsey carriers like `Nominal[Integer]` (Integer is always truthy in Ruby — including 0) also collapse the
544
567
  # dead else.
545
568
  def live_branch_for_if(node, pred_type, post_pred)
546
- case Narrowing.predicate_certainty(pred_type)
569
+ verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
570
+ case verdict
547
571
  when :truthy then eval_branch_or_nil(node.statements, post_pred)
548
572
  when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
549
573
  end
550
574
  end
551
575
 
552
576
  def live_branch_for_unless(node, pred_type, post_pred)
553
- case Narrowing.predicate_certainty(pred_type)
577
+ verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
578
+ case verdict
554
579
  when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
555
580
  when :falsey then eval_branch_or_nil(node.statements, post_pred)
556
581
  end
557
582
  end
558
583
 
584
+ # ADR-101 — the branch elision MUST NOT conclude truthiness from a carrier whose nil-freeness rests on
585
+ # the `%a{implicitly-returns-nil}` that `RbsDispatch` reads past. Such a value is optimistic, not proof
586
+ # (see {Inference::OptimisticOrigin} and docs/internal-spec/inference-engine.md), so eliding an arm on
587
+ # it deletes a branch the program really takes when the lookup misses.
588
+ #
589
+ # The decline lives here and NOT in `Narrowing.falsey_nominal?` / `.narrow_falsey`: `&&=` / `||=` and
590
+ # the and/or surviving-left edge read those too, and widening the falsey fragment there would re-admit
591
+ # `nil` into a bound local and buy `possible nil receiver` false positives — a soundness fix paid for
592
+ # in FPs, which is the wrong trade.
593
+ def optimistic_carrier?(predicate, scope)
594
+ !optimistic_origin_for(predicate, scope).nil?
595
+ end
596
+
559
597
  def eval_else(node)
560
598
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
561
599
 
@@ -21,6 +21,7 @@ module Rigor
21
21
  def initialize
22
22
  @entries = {}
23
23
  @desynchronized = {}
24
+ @dirty = {}
24
25
  end
25
26
 
26
27
  # Records a `textDocument/didOpen` event. Replaces any existing entry (LSP clients may re-open a
@@ -28,6 +29,7 @@ module Rigor
28
29
  # payload carries the client's full text, so the two views agree again.
29
30
  def open(uri:, bytes:, version:)
30
31
  @desynchronized.delete(uri)
32
+ @dirty.delete(uri)
31
33
  @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
32
34
  end
33
35
 
@@ -36,6 +38,7 @@ module Rigor
36
38
  # created — defensive.
37
39
  def change(uri:, bytes:, version:)
38
40
  @desynchronized.delete(uri)
41
+ @dirty[uri] = true
39
42
  @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
40
43
  end
41
44
 
@@ -52,6 +55,7 @@ module Rigor
52
55
  def apply_changes(uri:, changes:, version:)
53
56
  text = IncrementalSync.apply_all(@entries[uri]&.bytes, changes)
54
57
  @desynchronized.delete(uri)
58
+ @dirty[uri] = true
55
59
  @entries[uri] = Entry.new(uri: uri, bytes: text, version: version)
56
60
  true
57
61
  rescue IncrementalSync::UnappliableChange => e
@@ -73,9 +77,27 @@ module Rigor
73
77
  # Records a `textDocument/didClose` event. The entry is removed. Subsequent reads via `#[]` return nil.
74
78
  def close(uri:)
75
79
  @desynchronized.delete(uri)
80
+ @dirty.delete(uri)
76
81
  @entries.delete(uri)
77
82
  end
78
83
 
84
+ # Records a `textDocument/didSave`. The client has written the buffer, so the held text and the file on
85
+ # disk agree again and the URI stops being dirty.
86
+ #
87
+ # Dirtiness is the PROTOCOL's notion — "the client told us it changed and has not told us it saved" —
88
+ # not a byte comparison against disk. A comparison would look stricter and be weaker: it races with the
89
+ # editor's own write, and the server's truth is what the client notified.
90
+ def save(uri:)
91
+ @dirty.delete(uri)
92
+ end
93
+
94
+ # @return [Boolean] true when `uri` has unsaved changes. A buffer that is dirty may only be published
95
+ # from an analysis that bound ITS bytes — see the publish-set invariant in
96
+ # `docs/design/20260517-language-server.md`.
97
+ def dirty?(uri)
98
+ @dirty.key?(uri)
99
+ end
100
+
79
101
  def [](uri)
80
102
  @entries[uri]
81
103
  end
@@ -3,6 +3,7 @@
3
3
  require "tempfile"
4
4
 
5
5
  require_relative "uri"
6
+ require_relative "publish_batcher"
6
7
  require_relative "../analysis/runner"
7
8
  require_relative "../analysis/buffer_binding"
8
9
 
@@ -14,8 +15,10 @@ module Rigor
14
15
  # the Runner, and pushes the resulting LSP `Diagnostic[]` through the writer.
15
16
  #
16
17
  # Debouncing is wired via an optional `Debouncer` injected at construction (delay defaults to 200ms
17
- # quiet-time); without a debouncer each call blocks synchronously (primarily for specs). Ractor-pool
18
- # dispatch is queued.
18
+ # quiet-time); without a debouncer each call blocks synchronously (primarily for specs). When several
19
+ # buffers' debounce timers elapse around the same moment, the `PublishBatcher` coalesces them into one
20
+ # `#publish_many` round dispatched across the fork-based worker pool (issue #142) instead of N
21
+ # independent, GVL-serialized `Runner` calls.
19
22
  class DiagnosticPublisher
20
23
  # Maps Rigor severity symbols to LSP DiagnosticSeverity
21
24
  # integers per spec § "Diagnostic":
@@ -37,6 +40,10 @@ module Rigor
37
40
  # debounced publish fires. 0 with a debouncer means
38
41
  # "schedule on next-tick" (still async); without a
39
42
  # debouncer the value is unused.
43
+ # The single Debouncer key every save round shares (#246). Per-URI keys would let two saves start two
44
+ # concurrent rounds; one key means a burst collapses into the last one.
45
+ PROJECT_ROUND_KEY = :__rigor_project_round__
46
+
40
47
  def initialize(writer:, buffer_table:, project_context:,
41
48
  debouncer: nil, debounce_seconds: 0.2)
42
49
  @writer = writer
@@ -44,23 +51,72 @@ module Rigor
44
51
  @project_context = project_context
45
52
  @debouncer = debouncer
46
53
  @debounce_seconds = debounce_seconds
54
+ @round_lock = Mutex.new
55
+ @round_running = false
56
+ @round_pending = false
57
+ # Issue #142 — coalesces buffers whose OWN debounce timers elapse close together into one
58
+ # `#publish_many` round instead of N independent GVL-serialized `Runner` calls. Separate from
59
+ # `@round_lock` above, which single-flights the whole-project SAVE round; this single-flights the
60
+ # per-buffer DIDCHANGE batch instead. See `PublishBatcher` for the coalescing mechanics.
61
+ @batcher = PublishBatcher.new(
62
+ on_batch: ->(uris) { publish_many(uris) },
63
+ on_error: ->(e) { warn "DiagnosticPublisher batch round: #{e.class}: #{e.message}" }
64
+ )
47
65
  end
48
66
 
49
67
  # Run analysis for the buffer at `uri` (looked up in the BufferTable) and push a
50
68
  # `textDocument/publishDiagnostics` notification. No-op when the URI isn't a `file://` form or the
51
69
  # buffer isn't currently open. When a Debouncer is wired, the analysis is scheduled async per the
52
- # configured `debounce_seconds`; otherwise it runs inline.
70
+ # configured `debounce_seconds` and joins the batch coalescing layer (`PublishBatcher`) once its own
71
+ # quiet-time elapses; otherwise it runs inline (primarily for specs).
53
72
  def publish_for(uri)
54
73
  path = Uri.to_path(uri)
55
74
  return if path.nil?
56
75
 
57
76
  if @debouncer
58
- @debouncer.schedule(uri, delay: @debounce_seconds) { run_and_notify(uri, path) }
77
+ @debouncer.schedule(uri, delay: @debounce_seconds) { @batcher.enqueue(uri) }
59
78
  else
60
79
  run_and_notify(uri, path)
61
80
  end
62
81
  end
63
82
 
83
+ # Issue #142 — publishes N dirty buffers' diagnostics through ONE dispatch across the fork-based
84
+ # worker pool (`Analysis::Runner::BufferPoolDispatcher`) instead of N independent, GVL-serialized
85
+ # `Runner` calls. Each URI's OWN `BufferBinding` (its logical path bound to its OWN editor tempfile)
86
+ # travels with it, so a worker analyses that buffer's in-flight bytes — never the file as it sits on
87
+ # disk — even when several buffers are dispatched in the same round.
88
+ #
89
+ # Degrades to `#run_and_notify`'s existing single-buffer path when only one URI is eligible after
90
+ # filtering (a buffer closed mid-debounce-window is dropped; a desynchronised one publishes empty
91
+ # immediately) — a lone edit takes exactly the path it takes today. The dispatcher itself degrades to
92
+ # sequential in-process execution for any other precondition (see
93
+ # `BufferPoolDispatcher#dispatchable?`), so a pool that cannot start never fails a publish, only slows
94
+ # it back down to today's wall time.
95
+ def publish_many(uris)
96
+ eligible = uris.uniq.filter_map { |uri| eligible_job(uri) }
97
+ return if eligible.empty?
98
+ return run_and_notify(eligible.first.fetch(:uri), eligible.first.fetch(:path)) if eligible.size == 1
99
+
100
+ publish_batch(eligible)
101
+ end
102
+
103
+ # Runs one whole-project save round and publishes to the publish set (#246). Called from `didSave`.
104
+ #
105
+ # Analysis scope is the whole project; the PUBLISH SET is the open buffers that are not dirty, plus the
106
+ # buffer that was just saved. A dirty buffer is excluded because only its own `didChange` analysis has
107
+ # seen its bytes — publishing this round's on-disk answer for it would replace correct markers with
108
+ # markers for a file the user has already changed.
109
+ #
110
+ # Scheduled through the same Debouncer the per-buffer path uses, under one project-wide key, so the
111
+ # dispatcher never blocks on it.
112
+ def publish_project(saved_uri)
113
+ if @debouncer
114
+ @debouncer.schedule(PROJECT_ROUND_KEY, delay: 0) { run_project_round(saved_uri) }
115
+ else
116
+ run_project_round(saved_uri)
117
+ end
118
+ end
119
+
64
120
  # Publishes an EMPTY diagnostic array for `uri`. The LSP-spec idiom for "clear inline markers" — called
65
121
  # from `didClose` so clients drop stale highlights when the user closes a buffer.
66
122
  def publish_empty(uri)
@@ -75,6 +131,73 @@ module Rigor
75
131
 
76
132
  private
77
133
 
134
+ # Single-flight. The Debouncer only cancels a task that has not started, so without this two rounds
135
+ # could run concurrently over one session's mutable state. A save that arrives mid-round sets the
136
+ # pending flag instead of starting a second round, and the running one repeats once when it finishes —
137
+ # so a burst of saves costs at most one extra round, and the last save is always accounted for.
138
+ def run_project_round(saved_uri)
139
+ return unless claim_round
140
+
141
+ loop do
142
+ execute_project_round(saved_uri)
143
+ break unless consume_pending
144
+ end
145
+ end
146
+
147
+ # True when this call owns the round. A save arriving while one is in flight records itself as pending
148
+ # instead — the running round will pick it up.
149
+ def claim_round
150
+ @round_lock.synchronize do
151
+ if @round_running
152
+ @round_pending = true
153
+ next false
154
+ end
155
+
156
+ @round_running = true
157
+ true
158
+ end
159
+ end
160
+
161
+ # True when a save arrived mid-round and the loop should run once more. Releases ownership otherwise.
162
+ # `next`, not `break`: inside `Mutex#synchronize`'s block, `break` returns from the SYNCHRONIZE call, so
163
+ # a `break` written here would leave the caller's `loop` spinning forever — which is exactly what it did
164
+ # before this was split out.
165
+ def consume_pending
166
+ @round_lock.synchronize do
167
+ next(@round_running = false) unless @round_pending
168
+
169
+ @round_pending = false
170
+ true
171
+ end
172
+ end
173
+
174
+ # One round: analyse the project as it now stands on disk, then publish each target URI's slice.
175
+ # The generation captured before the analysis is re-read after it — a `didChangeWatchedFiles` or a
176
+ # configuration change during a long round invalidates the world these diagnostics describe, and
177
+ # publishing them would put an answer about a superseded project on the user's screen.
178
+ def execute_project_round(saved_uri)
179
+ generation = @project_context.generation
180
+ diagnostics = @project_context.project_diagnostics
181
+ return if generation != @project_context.generation
182
+
183
+ by_path = diagnostics.group_by(&:path)
184
+ publish_set(saved_uri).each do |uri|
185
+ path = Uri.to_path(uri)
186
+ next if path.nil?
187
+
188
+ notify(uri, (by_path[path] || []).filter_map { |diagnostic| to_lsp_diagnostic(diagnostic, path) })
189
+ end
190
+ end
191
+
192
+ # The URIs this round may speak for: every open buffer whose bytes are the ones on disk. The just-saved
193
+ # URI qualifies by definition (the client wrote it), and is named explicitly so a client that sends
194
+ # `didSave` without a preceding applied `didChange` still refreshes it.
195
+ def publish_set(saved_uri)
196
+ ([saved_uri] + @buffer_table.uris).uniq.select do |uri|
197
+ @buffer_table.open?(uri) && !@buffer_table.dirty?(uri) && !@buffer_table.desynchronized?(uri)
198
+ end
199
+ end
200
+
78
201
  def run_and_notify(uri, path)
79
202
  entry = @buffer_table[uri]
80
203
  # The buffer may have been closed during the debounce window — drop the publish; the empty
@@ -89,6 +212,60 @@ module Rigor
89
212
  notify(uri, diagnostics)
90
213
  end
91
214
 
215
+ # @return [Hash, nil] `{ uri:, path:, bytes: }` when `uri` is eligible for the batch, or nil to
216
+ # exclude it. Mirrors `#run_and_notify`'s own guards: a buffer closed during the debounce window is
217
+ # dropped silently (its didClose empty publish already cleared the markers); a desynchronised buffer
218
+ # publishes an EMPTY set immediately (same as the single-buffer path) rather than joining the batch.
219
+ def eligible_job(uri)
220
+ entry = @buffer_table[uri]
221
+ return nil if entry.nil?
222
+
223
+ path = Uri.to_path(uri)
224
+ return nil if path.nil?
225
+
226
+ if @buffer_table.desynchronized?(uri)
227
+ notify(uri, [])
228
+ return nil
229
+ end
230
+
231
+ { uri: uri, path: path, bytes: entry.bytes }
232
+ end
233
+
234
+ # Materialises one tempfile + `BufferBinding` per job, dispatches all of them through
235
+ # `BufferPoolDispatcher#analyze` in ONE call, and publishes each job's own slice. `dispatcher.analyze`
236
+ # returns diagnostics IN INPUT ORDER, so `jobs`/`bindings`/the result array stay index-aligned — the
237
+ # parent absorbs worker results in this stable order, so two runs of the same dirty set publish
238
+ # byte-identical results.
239
+ def publish_batch(jobs)
240
+ with_tempfiles(jobs) do |bound_jobs|
241
+ bindings = bound_jobs.map { |job| job.fetch(:binding) }
242
+ dispatcher = Analysis::Runner::BufferPoolDispatcher.new(
243
+ configuration: @project_context.configuration,
244
+ cache_store: @project_context.cache_store,
245
+ environment: @project_context.environment,
246
+ prebuilt: @project_context.project_scan,
247
+ workers: worker_count
248
+ )
249
+ diagnostics_per_binding = dispatcher.analyze(bindings)
250
+ bound_jobs.each_with_index do |job, index|
251
+ diagnostics = diagnostics_per_binding.fetch(index, []).filter_map do |diagnostic|
252
+ to_lsp_diagnostic(diagnostic, job.fetch(:path))
253
+ end
254
+ notify(job.fetch(:uri), diagnostics)
255
+ end
256
+ end
257
+ end
258
+
259
+ # Worker-pool size, mirroring `rigor check`'s own precedence minus the CLI flag the LSP does not have:
260
+ # env `RIGOR_RACTOR_WORKERS` (if set and non-empty) wins, else `.rigor.yml` `parallel.workers:` (0 —
261
+ # sequential — by default). See `docs/design/20260517-language-server.md` § "Concurrency".
262
+ def worker_count
263
+ env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
264
+ return [Integer(env_value), 0].max if env_value && !env_value.empty?
265
+
266
+ @project_context.configuration.parallel_workers
267
+ end
268
+
92
269
  # Runs `Analysis::Runner` with a `BufferBinding` so the buffer bytes (instead of the on-disk file) drive
93
270
  # the parse. The `Rigor::Analysis::ProjectScan` cached on the ProjectContext is passed through
94
271
  # `prebuilt:` so plugin `#prepare`, the dependency-source walker, and the synthetic-method /
@@ -121,6 +298,28 @@ module Rigor
121
298
  tmp&.unlink
122
299
  end
123
300
 
301
+ # Plural form of `#with_tempfile` for `#publish_batch` — writes one tempfile per job up front, yields
302
+ # each job Hash augmented with its own `binding:` (a `BufferBinding` pairing the job's logical path
303
+ # with ITS OWN physical tempfile), and unlinks every tempfile afterward regardless of how the block
304
+ # exits.
305
+ def with_tempfiles(jobs)
306
+ tempfiles = []
307
+ bound_jobs = jobs.map do |job|
308
+ tmp = Tempfile.new(["rigor-lsp-buffer-", ".rb"])
309
+ tmp.write(job.fetch(:bytes))
310
+ tmp.flush
311
+ tempfiles << tmp
312
+ binding = Analysis::BufferBinding.new(logical_path: job.fetch(:path), physical_path: tmp.path)
313
+ job.merge(binding: binding)
314
+ end
315
+ yield bound_jobs
316
+ ensure
317
+ tempfiles.each do |tmp|
318
+ tmp.close
319
+ tmp.unlink
320
+ end
321
+ end
322
+
124
323
  # @return [Hash, nil] the LSP `Diagnostic` Hash, or nil to
125
324
  # skip diagnostics outside the buffer's own path (e.g.
126
325
  # `.rigor.yml`-anchored info diagnostics get filtered —
@@ -3,6 +3,8 @@
3
3
  require_relative "../environment"
4
4
  require_relative "../cache/store"
5
5
  require_relative "../analysis/runner"
6
+ require_relative "../analysis/incremental_session"
7
+ require_relative "../cache/incremental_snapshot"
6
8
 
7
9
  module Rigor
8
10
  module LanguageServer
@@ -34,6 +36,9 @@ module Rigor
34
36
  # watch fires → publish) refreshes automatically; the rare in-flight edit to a substrate-DSL file is the
35
37
  # documented edge case.
36
38
  class ProjectContext
39
+ # `generation` is the invalidation counter: a long-running save round (#246) captures it on entry and
40
+ # discards its result if it moved, so diagnostics computed against a world that has since been
41
+ # invalidated never reach the editor.
37
42
  attr_reader :configuration, :generation
38
43
 
39
44
  def initialize(configuration:)
@@ -42,6 +47,8 @@ module Rigor
42
47
  @environment = nil
43
48
  @cache_store = nil
44
49
  @project_scan = nil
50
+ @incremental_session = nil
51
+ @session_primed = false
45
52
  end
46
53
 
47
54
  # Returns the cached `Rigor::Environment` for this session, building it on first access. The build
@@ -90,6 +97,29 @@ module Rigor
90
97
  @project_scan ||= build_project_scan
91
98
  end
92
99
 
100
+ # Whole-project diagnostics for one save round (#246). The first call primes an in-process
101
+ # {Analysis::IncrementalSession} — seeded from the on-disk snapshot a terminal
102
+ # `rigor check --incremental` may have left, and otherwise from a full baseline; every later call is a
103
+ # recheck against what changed on disk since.
104
+ #
105
+ # The session is never persisted. Its state lives as long as this context, which is what a long-running
106
+ # server needs, and writing it would race exactly the way the read-only {#cache_store} exists to avoid.
107
+ # Seeding is therefore one-directional: the terminal warms the server, not the reverse.
108
+ def project_diagnostics
109
+ session = (@incremental_session ||= build_incremental_session)
110
+ return session.recheck.diagnostics if @session_primed
111
+
112
+ @session_primed = true
113
+ diagnostics, = session.run_incremental(
114
+ snapshot: Cache::IncrementalSnapshot.new(root: @configuration.cache_path),
115
+ fingerprint: Cache::IncrementalSnapshot.fingerprint(
116
+ configuration: @configuration, roots: @configuration.paths
117
+ ),
118
+ persist: false
119
+ )
120
+ diagnostics
121
+ end
122
+
93
123
  # Drops every cached collaborator and bumps the generation. The next reader rebuilds from scratch.
94
124
  # Triggered by `workspace/didChangeWatchedFiles` for project source files and by
95
125
  # `workspace/didChangeConfiguration`.
@@ -97,6 +127,10 @@ module Rigor
97
127
  @generation += 1
98
128
  @environment = nil
99
129
  @project_scan = nil
130
+ # The session's per-file cache was computed against the old environment / project scan, so it cannot
131
+ # outlive them. The next save round primes a fresh one.
132
+ @incremental_session = nil
133
+ @session_primed = false
100
134
  # Cache store stays — it's content-addressed; a stale env build won't be served because the file
101
135
  # digest mixed into the cache key has changed.
102
136
  nil
@@ -104,6 +138,17 @@ module Rigor
104
138
 
105
139
  private
106
140
 
141
+ # The session shares this context's warm Environment, so a round does not rebuild the RBS universe. It
142
+ # deliberately does NOT share the prebuilt ProjectScan: a round runs after a save, i.e. after the file
143
+ # on disk moved, and the scan is what a changed file invalidates.
144
+ def build_incremental_session
145
+ Analysis::IncrementalSession.new(
146
+ configuration: @configuration,
147
+ environment: environment,
148
+ cache_store: cache_store
149
+ )
150
+ end
151
+
107
152
  def build_project_scan
108
153
  runner = Analysis::Runner.new(
109
154
  configuration: @configuration,
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module LanguageServer
5
+ # Issue #142 — coalesces keys that become "ready" close together in wall-clock time into ONE batched
6
+ # round instead of firing one round per key. `DiagnosticPublisher` uses this to fold a burst of buffers
7
+ # whose OWN per-URI debounce timers elapse around the same moment (a workspace-wide rename, a git branch
8
+ # switch that touches many open files) into one `#publish_many` dispatch across the fork-based worker
9
+ # pool — but the mechanism itself carries no LSP- or URI-specific knowledge, so it stays a small,
10
+ # independently testable collaborator rather than inline state on `DiagnosticPublisher`.
11
+ #
12
+ # Single-flight: the first `#enqueue` call to arrive owns the round and runs `on_batch` with every key
13
+ # currently pending (deduplicated); a call that arrives while a round is running just adds its key and
14
+ # returns — the running round loops once more before releasing ownership, so nothing queued mid-round is
15
+ # dropped. The same claim/consume shape `DiagnosticPublisher#run_project_round` already uses for the
16
+ # whole-project save round (#246), generalised to an arbitrary key type and an arbitrary batch action.
17
+ class PublishBatcher
18
+ # @param on_batch [#call] `(keys) -> void`, called with the deduplicated Array of keys pending at the
19
+ # start of one round. May be called more than once in a row when keys keep arriving while a round
20
+ # runs.
21
+ # @param on_error [#call, nil] `(exception) -> void`, called when `on_batch` raises. A round must
22
+ # never wedge the coalescing lock for every future `#enqueue` call — ownership is always released
23
+ # before this fires. Whatever was mid-flight when the round raised is lost; anything enqueued by a
24
+ # concurrent `#enqueue` call after this round's drain but before the rescue stays pending and rides
25
+ # the NEXT round instead. Defaults to a no-op (the exception is swallowed silently).
26
+ def initialize(on_batch:, on_error: nil)
27
+ @on_batch = on_batch
28
+ @on_error = on_error
29
+ @lock = Mutex.new
30
+ @pending = []
31
+ @running = false
32
+ end
33
+
34
+ # Adds `key` to the pending set and, if no round is currently running, becomes the round and drains
35
+ # every key pending (looping until none remain) before returning. A call that arrives while another is
36
+ # already running the round returns immediately having only enqueued its key.
37
+ def enqueue(key)
38
+ start = false
39
+ @lock.synchronize do
40
+ @pending << key
41
+ unless @running
42
+ @running = true
43
+ start = true
44
+ end
45
+ end
46
+ return unless start
47
+
48
+ run_owned_round
49
+ end
50
+
51
+ private
52
+
53
+ def run_owned_round
54
+ loop do
55
+ run_batch
56
+ break unless more_pending?
57
+ end
58
+ rescue StandardError => e
59
+ @lock.synchronize { @running = false }
60
+ @on_error&.call(e)
61
+ end
62
+
63
+ # True when another key joined `@pending` while this round was running — the caller's loop should run
64
+ # once more rather than release ownership. Releases ownership (resets `@running`) otherwise.
65
+ def more_pending?
66
+ @lock.synchronize do
67
+ if @pending.empty?
68
+ @running = false
69
+ false
70
+ else
71
+ true
72
+ end
73
+ end
74
+ end
75
+
76
+ def run_batch
77
+ keys = @lock.synchronize { @pending.uniq.tap { @pending.clear } }
78
+ @on_batch.call(keys)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -95,6 +95,7 @@ module Rigor
95
95
  when "exit" then handle_exit
96
96
  when "textDocument/didOpen" then handle_did_open(params)
97
97
  when "textDocument/didChange" then handle_did_change(params)
98
+ when "textDocument/didSave" then handle_did_save(params)
98
99
  when "textDocument/didClose" then handle_did_close(params)
99
100
  when "textDocument/hover" then handle_hover(params)
100
101
  when "textDocument/documentSymbol" then handle_document_symbol(params)
@@ -161,7 +162,10 @@ module Rigor
161
162
  positionEncoding: POSITION_ENCODING_UTF16,
162
163
  textDocumentSync: {
163
164
  openClose: true,
164
- change: TEXT_DOCUMENT_SYNC_INCREMENTAL
165
+ change: TEXT_DOCUMENT_SYNC_INCREMENTAL,
166
+ # `includeText: false` — the round reads the file the client just wrote, so the payload's copy
167
+ # would be redundant. See `handle_did_save`.
168
+ save: { includeText: false }
165
169
  }
166
170
  }
167
171
  caps[:hoverProvider] = true if @hover_provider
@@ -242,6 +246,21 @@ module Rigor
242
246
  nil
243
247
  end
244
248
 
249
+ # textDocument/didSave notification. Marks the buffer clean — the client has written it, so the held
250
+ # text and the file on disk agree — and starts a whole-project publish round (#246).
251
+ #
252
+ # This is where whole-project scope lives, rather than on `didChange`: a round costs ~0.6s on a
253
+ # mid-sized project against a 250ms `didChange` p50 budget, and "the rest of the project catches up"
254
+ # is what saving means to the user. Because the saved bytes are the bytes on disk, the round needs no
255
+ # buffer binding at all. Design: `docs/design/20260517-language-server.md` § "Whole-project publishes
256
+ # on save".
257
+ def handle_did_save(params)
258
+ uri = params.fetch(:textDocument).fetch(:uri)
259
+ @buffer_table.save(uri: uri)
260
+ @publisher&.publish_project(uri)
261
+ nil
262
+ end
263
+
245
264
  # textDocument/hover REQUEST. Slice 5 returns either a `Hover` payload (markdown contents wrapping type
246
265
  # + erased-RBS info) or nil when no expression is at the queried position. Nil maps to `result: null`
247
266
  # per LSP spec; clients suppress the popup. Returns `MethodNotFound` when no hover_provider is wired
@@ -14,6 +14,7 @@ require_relative "language_server/buffer_table"
14
14
  require_relative "language_server/uri"
15
15
  require_relative "language_server/project_context"
16
16
  require_relative "language_server/debouncer"
17
+ require_relative "language_server/publish_batcher"
17
18
  require_relative "language_server/synchronized_writer"
18
19
  require_relative "language_server/diagnostic_publisher"
19
20
  require_relative "language_server/hover_renderer"
@@ -16,17 +16,23 @@ module Rigor
16
16
  # single reporter across the run; entries are appended one at a time during env build (before any per-file
17
17
  # analysis runs), so no locking is needed.
18
18
  class SourceRbsSynthesisReporter
19
- Entry = Data.define(:plugin_id, :path, :message)
19
+ # `kind` separates the two outcomes the Runner reports differently (ADR-32 WD12): `:failed` is a
20
+ # synthesis that raised or could not parse, `:not_honoured` a synthesis that SUCCEEDED while silently
21
+ # dropping an annotation it parsed. They are not the same news — the first says the file contributed
22
+ # nothing, the second that it contributed all but one thing — so they carry distinct diagnostic ids.
23
+ # Defaults to `:failed`, the pre-WD12 meaning, so an older caller records what it always did.
24
+ Entry = Data.define(:plugin_id, :path, :message, :kind)
20
25
 
21
26
  def initialize
22
27
  @entries = []
23
28
  end
24
29
 
25
- def record(plugin_id:, path:, message:)
30
+ def record(plugin_id:, path:, message:, kind: :failed)
26
31
  @entries << Entry.new(
27
32
  plugin_id: plugin_id.to_s.dup.freeze,
28
33
  path: path.to_s.dup.freeze,
29
- message: message.to_s.dup.freeze
34
+ message: message.to_s.dup.freeze,
35
+ kind: kind
30
36
  )
31
37
  nil
32
38
  end