rigortype 0.3.1 → 0.3.3

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 (116) 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/04-diagnostics.md +8 -0
  15. data/docs/manual/12-caching.md +17 -5
  16. data/docs/manual/15-type-protection-coverage.md +122 -1
  17. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  19. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  20. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  21. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  22. data/lib/rigor/analysis/check_rules.rb +107 -28
  23. data/lib/rigor/analysis/incremental_session.rb +156 -11
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  25. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  26. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  27. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  28. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  30. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  31. data/lib/rigor/analysis/runner.rb +78 -7
  32. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  33. data/lib/rigor/bleeding_edge.rb +132 -7
  34. data/lib/rigor/cache/descriptor.rb +6 -1
  35. data/lib/rigor/cache/engine_source.rb +162 -0
  36. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  37. data/lib/rigor/cli/check_command.rb +49 -28
  38. data/lib/rigor/cli/coverage_command.rb +15 -2
  39. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  40. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  41. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  42. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  43. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  44. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  45. data/lib/rigor/cli/protection_renderer.rb +13 -0
  46. data/lib/rigor/cli/protection_report.rb +11 -3
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  48. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  49. data/lib/rigor/configuration.rb +45 -6
  50. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  51. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  52. data/lib/rigor/environment/rbs_loader.rb +434 -75
  53. data/lib/rigor/environment.rb +28 -13
  54. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  55. data/lib/rigor/inference/expression_typer.rb +62 -4
  56. data/lib/rigor/inference/fork_map.rb +47 -4
  57. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  58. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  59. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  60. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
  61. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  62. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
  63. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  64. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  65. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  66. data/lib/rigor/inference/mutation_widening.rb +30 -25
  67. data/lib/rigor/inference/narrowing.rb +3 -1
  68. data/lib/rigor/inference/optimistic_origin.rb +123 -0
  69. data/lib/rigor/inference/protection_scanner.rb +18 -2
  70. data/lib/rigor/inference/receiver_alias.rb +57 -0
  71. data/lib/rigor/inference/scope_indexer.rb +197 -55
  72. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  73. data/lib/rigor/inference/statement_evaluator.rb +55 -4
  74. data/lib/rigor/language_server/buffer_table.rb +22 -0
  75. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  76. data/lib/rigor/language_server/project_context.rb +45 -0
  77. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  78. data/lib/rigor/language_server/server.rb +20 -1
  79. data/lib/rigor/language_server.rb +1 -0
  80. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  81. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  82. data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
  83. data/lib/rigor/protection/dependency_closure.rb +59 -0
  84. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  85. data/lib/rigor/protection/discovery_seed.rb +116 -0
  86. data/lib/rigor/protection/kill_signature.rb +31 -0
  87. data/lib/rigor/protection/mutation_cache.rb +355 -0
  88. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  89. data/lib/rigor/protection/mutator.rb +26 -4
  90. data/lib/rigor/runtime/jit.rb +63 -1
  91. data/lib/rigor/scope/discovery_index.rb +9 -0
  92. data/lib/rigor/scope.rb +114 -10
  93. data/lib/rigor/sig_gen/generator.rb +130 -31
  94. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  95. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  96. data/lib/rigor/sig_gen/renderer.rb +20 -1
  97. data/lib/rigor/sig_gen/write_result.rb +6 -4
  98. data/lib/rigor/sig_gen/writer.rb +12 -0
  99. data/lib/rigor/sig_gen.rb +1 -0
  100. data/lib/rigor/source/node_walker.rb +12 -0
  101. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  102. data/lib/rigor/type/nominal.rb +5 -2
  103. data/lib/rigor/type/singleton.rb +4 -1
  104. data/lib/rigor/version.rb +1 -1
  105. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  106. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  107. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  108. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  109. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  110. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  111. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  112. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  113. data/sig/rigor/inference/void_origin.rbs +6 -3
  114. data/sig/rigor/scope.rbs +12 -0
  115. data/sig/rigor.rbs +4 -0
  116. metadata +21 -1
@@ -62,9 +62,14 @@ module Rigor
62
62
  # pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
63
63
  # dependency graph a pooled recheck rebuilds equals the sequential one.
64
64
  def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
65
- workers: 0)
65
+ workers: 0, buffer: nil)
66
66
  @configuration = configuration
67
67
  @paths = paths
68
+ # Editor mode option B (#146) — the in-flight buffer, threaded into every internal Runner so the
69
+ # pre-passes and the closure re-analysis read the editor's bytes at the logical path. A session
70
+ # holding one MUST NOT persist its snapshot: its `@digests` and `@cache` describe bytes that exist
71
+ # only in the editor. {#run_buffer_recheck} is the only entry that honours that.
72
+ @buffer = buffer
68
73
  @environment = environment
69
74
  @cache_store = cache_store
70
75
  @plugin_requirer = plugin_requirer
@@ -93,6 +98,13 @@ module Rigor
93
98
  # ADR-89 WD2 — per-def observed-key return summaries: [path, symbol] => { keys:, returns:, effects: }.
94
99
  # Harvested from the ADR-84 return memo after each run; drives the behavioural-stability gate.
95
100
  @return_summaries = {}
101
+ # ADR-67 WD6c lift — the `parameter_inference:` seed table the cached diagnostics were computed
102
+ # under (`{}` when the gate is off — the gate state is constant across a snapshot's lifetime because
103
+ # the configuration is part of the global fingerprint). A recheck recomputes the table fresh and
104
+ # diffs it against this: the pre-pass is whole-project by design, so the fresh table is ground truth
105
+ # and a missing invalidation edge is impossible by construction — the reason this is a table diff
106
+ # and not the caller→callee edge recording #204 first sketched.
107
+ @param_table = {}
96
108
  # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
97
109
  # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
98
110
  # banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
@@ -120,6 +132,9 @@ module Rigor
120
132
  @seed_bundles = runner.seed_bundles # ADR-85 WD2 — the freshly built bundle set for the next run.
121
133
  absorb_dependency_graph(runner)
122
134
  @return_summaries = runner.return_summaries # ADR-89 WD2 — the full-run behavioural surface.
135
+ # ADR-67 WD6c lift — the seed table the runner's own pre-pass computed ({} when the gate is off).
136
+ # Reading it back, rather than computing it here, keeps the baseline single-collect.
137
+ @param_table = runner.param_inferred_types
123
138
  @cache = per_file(diagnostics)
124
139
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
125
140
  diagnostics
@@ -134,30 +149,79 @@ module Rigor
134
149
  added = current - previous
135
150
  removed = previous - current
136
151
  changed = changed_paths(current & previous)
137
- affected = affected_closure(changed, added, removed)
152
+ # ADR-67 WD6c lift — recompute the whole-project inferred-param table BEFORE deciding the closure,
153
+ # and diff it against the snapshot's copy: an entry that moved (because a caller's argument type
154
+ # changed, a caller appeared, or one vanished) invalidates the CALLEE's file and its symbol
155
+ # dependents, none of which the file-digest tier can see (the callee's text is unchanged).
156
+ fresh_params = fresh_param_table(current, changed, added, removed)
157
+ param_files, param_pairs = param_seed_invalidation(fresh_params)
158
+ affected = affected_closure(changed, added, removed, param_files, param_pairs)
138
159
  analyze_set = affected & current
139
- runner = build_runner(analyze_only: analyze_set, record_dependencies: true)
160
+ # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
161
+ # was decided on (and the collector runs once per recheck, not twice).
162
+ runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
163
+ param_inferred_types: fresh_params)
140
164
  fresh = run_runner(runner).diagnostics
141
165
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
142
166
  reused = (current & previous) - affected.to_a
143
167
  merged = fresh + reused.flat_map { |path| @cache[path] || [] }
144
168
  absorb(runner, fresh, current, analyze_set, removed)
169
+ @param_table = fresh_params
145
170
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
146
171
  removed: removed.to_set, affected: affected, reused: reused.to_set)
147
172
  end
148
173
 
174
+ # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
175
+ # file, for the CLI's `--incremental --tmp-file=X --instead-of=Y`. Returns the {Recheck} when the
176
+ # snapshot could be reused, or nil when it could not — the caller then falls back to option A
177
+ # (single-file scope) rather than paying a full baseline, because that baseline would repeat on every
178
+ # keystroke: this session MUST NOT save, so nothing it computes can warm the next invocation.
179
+ #
180
+ # Not saving is the whole safety story. `@digests` and `@cache` here describe the buffer's bytes, which
181
+ # exist only in the editor; persisting them would make the next `rigor check --incremental` believe the
182
+ # on-disk file was already analysed in a state it was never in.
183
+ def run_buffer_recheck(snapshot:, fingerprint:)
184
+ Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
185
+ restored = fingerprint && snapshot.load(fingerprint: fingerprint)
186
+ break nil unless restored
187
+
188
+ restore(restored)
189
+ result = recheck
190
+ adopt_plugin_fact_fingerprint
191
+ # The ADR-88 gate applies unchanged: if the plugin fact surface moved, the cache-served files may be
192
+ # stale. A full baseline is the sound answer for `--incremental`, but in editor mode it is also the
193
+ # latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
194
+ break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
195
+
196
+ result
197
+ end
198
+ end
199
+
149
200
  # The frozen set of files a #recheck must re-analyse: the symbol/ancestry-granularity closure of the
150
201
  # changed files (slice 4), the added files themselves, the consumers of any symbol / class that
151
202
  # *appeared* in a changed OR added file (slice 3 — a now-defined `call.unresolved-toplevel` target or
152
203
  # `def.override-*` ancestor), and the consumers of every removed file (which now miss what it
153
204
  # provided). An added file has no before-state, so all its symbols / classes appear.
154
- def affected_closure(changed, added, removed)
205
+ #
206
+ # ADR-67 WD6c lift — `param_files` / `param_pairs` are the callee files (and their `[file, symbol]`
207
+ # pairs) whose inferred-param seeds moved since the snapshot. Their text is unchanged, so they enter
208
+ # the closure here: the files themselves re-analyse (their in-body diagnostics were computed under the
209
+ # old seeds), and their pairs join the SYMBOL fan-out — a seed change shifts the callee's inferred
210
+ # return exactly the way a body edit does, so it reuses the same audited dependents machinery. The
211
+ # pairs join AFTER the ADR-89 WD2 behavioural-stability pruning: that gate re-evaluates returns under
212
+ # the snapshot's OLD seeds, which is the wrong oracle for a pair whose seeds are the thing that moved.
213
+ def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
155
214
  scan = changed + added
156
215
  # Parse the changed / added set ONCE for the per-symbol fingerprints, the class declarations, AND the
157
216
  # ADR-89 WD1 declaration signatures. They were separate `discovered_def_index_for_paths` passes over
158
217
  # the same `scan` set — a duplicate re-parse of every changed file each recheck (recon §2 / the P6
159
218
  # recheck-floor audit).
160
- summary = scan.empty? ? nil : Inference::ScopeIndexer.scan_summary_for_paths(scan)
219
+ # `buffer:` is load-bearing for editor mode option B (#146), not an optimisation: this scan decides
220
+ # the closure, so reading the buffer's logical path from DISK would compare the snapshot's symbol
221
+ # fingerprints against bytes the user has already edited away — every dependent of the unsaved change
222
+ # would then be served from cache, which is the stale answer whole-project editor scope exists to
223
+ # avoid. `ScopeIndexer.scan_summary_for_paths` resolves each path through the binding.
224
+ summary = scan.empty? ? nil : Inference::ScopeIndexer.scan_summary_for_paths(scan, buffer: @buffer)
161
225
  scan_index = summary && summary[:def_index]
162
226
  declaration_signatures = (summary && summary[:declaration_signatures]) || {}
163
227
  new_fps = symbol_fingerprints_from_index(scan_index)
@@ -176,13 +240,23 @@ module Rigor
176
240
  # previously-observed call key + content-mutation effects) is unchanged is behaviourally stable: its
177
241
  # symbol dependents' cached diagnostics stay valid, so drop them. `symbol_pairs` is `changed_pairs`
178
242
  # minus those stable pairs, and only it (not `changed_pairs`) drives the symbol-dependent fan-out.
179
- symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index)
243
+ symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index) | param_pairs
180
244
  base = dependents_base(unstable, symbol_pairs)
181
245
  closure = base | changed.to_set | added.to_set | negative_affected(scan, new_fps, new_class_decls)
246
+ closure = param_seed_closure(closure, param_files)
182
247
  removed.each { |path| closure |= @dependents[path] || Set.new }
183
248
  closure.freeze
184
249
  end
185
250
 
251
+ # ADR-67 WD6c lift — the seed-invalidated callees' own contribution to the closure: the files
252
+ # themselves, plus — on a pre-slice-4 snapshot with no symbol edges, where the pairs' symbol fan-out
253
+ # found nothing — their file-level dependents (wider, always sound).
254
+ def param_seed_closure(closure, param_files)
255
+ closure |= param_files
256
+ param_files.each { |path| closure |= @dependents[path] || Set.new } if @symbol_sources.empty?
257
+ closure
258
+ end
259
+
186
260
  # The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
187
261
  # symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the
188
262
  # unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out.
@@ -194,6 +268,47 @@ module Rigor
194
268
  end
195
269
  end
196
270
 
271
+ # ADR-67 WD6c lift — the fresh whole-project inferred-param table for this recheck ({} when the gate
272
+ # is off). When NO file moved, the collector's inputs are unchanged — the project files are identical,
273
+ # and the env-side inputs (configuration, `sig/`, the gem set, the engine version) are constant under
274
+ # a matched snapshot fingerprint — so the stored table is provably identical and the whole-project
275
+ # re-collect is skipped (the ADR-87 null-recheck fast path stays collect-free).
276
+ def fresh_param_table(current, changed, added, removed)
277
+ return {} unless @configuration.parameter_inference
278
+ return @param_table if changed.empty? && added.empty? && removed.empty?
279
+
280
+ build_runner.collect_param_inference_table(current)
281
+ end
282
+
283
+ # ADR-67 WD6c lift — the `[files, pairs]` the fresh table invalidates. For every `[class, method,
284
+ # kind]` entry that differs from the snapshot's copy (added, removed, or value-changed — `Type#==`
285
+ # structural equality, the same comparison the collector's own fixpoint termination uses, already
286
+ # exercised across Marshal round-trips by its fork workers), every snapshot file defining that symbol
287
+ # re-analyses and its `[file, symbol]` pair joins the symbol fan-out. An entry attributable to NO
288
+ # snapshot file is a def that first appeared in this edit: its file is in the changed/added set (so it
289
+ # re-analyses anyway) and its prior callers are the negative-dependency closure's job — nothing is
290
+ # lost by skipping it here. The restored types are compared and then DISCARDED (the run seeds from the
291
+ # fresh table), so a cache-carried stale memo ivar can never poison a live lookup.
292
+ def param_seed_invalidation(fresh_params)
293
+ return [Set.new, Set.new] if fresh_params.equal?(@param_table) || @param_table == fresh_params
294
+
295
+ files = Set.new
296
+ pairs = Set.new
297
+ (@param_table.keys | fresh_params.keys).each do |key|
298
+ next if @param_table[key] == fresh_params[key]
299
+
300
+ class_name, method_name, kind = key
301
+ symbol = "#{class_name}#{kind == :singleton ? '.' : '#'}#{method_name}"
302
+ @symbol_fingerprints.each do |path, symbols|
303
+ next unless symbols.key?(symbol)
304
+
305
+ files << path
306
+ pairs << [path, symbol]
307
+ end
308
+ end
309
+ [files, pairs]
310
+ end
311
+
197
312
  # ADR-89 WD2 — `changed_pairs` minus the behaviourally-STABLE pairs whose symbol dependents may be
198
313
  # skipped. A pair `[path, "Class#method"]` is a candidate when its file is declaration-stable (WD1), it
199
314
  # carries a persisted return summary, and the (edited) def is GATE-ELIGIBLE — its only cross-file body
@@ -266,7 +381,9 @@ module Rigor
266
381
  # mutating session state. Returns the merged diagnostics.
267
382
  def reanalyze_subset(subset)
268
383
  affected = subset.to_set
269
- runner = build_runner(analyze_only: affected)
384
+ # ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
385
+ # exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
386
+ runner = build_runner(analyze_only: affected, param_inferred_types: @param_table)
270
387
  fresh = run_runner(runner).diagnostics
271
388
  reused = @analyzed - affected.to_a
272
389
  fresh + reused.flat_map { |path| @cache[path] || [] }
@@ -278,7 +395,12 @@ module Rigor
278
395
  # persist the updated snapshot for the next process. Returns `[diagnostics, warm]` — `warm` is true
279
396
  # when a snapshot was restored. A nil `fingerprint` (uncomputable inputs) disables persistence: a
280
397
  # plain full run.
281
- def run_incremental(snapshot:, fingerprint:)
398
+ # `persist: false` runs the same restore / recheck / baseline decision without writing the snapshot
399
+ # back. The language server (#246) needs exactly that: it keeps its session in memory for the life of
400
+ # the process, and writing shared state would race the way its read-only cache store already declines
401
+ # to. Every soundness gate below — the fingerprint, the ADR-88 fact surface — is unchanged, so the
402
+ # decision to reuse is made identically whether or not the result is saved.
403
+ def run_incremental(snapshot:, fingerprint:, persist: true)
282
404
  # ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
283
405
  # invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
284
406
  # `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
@@ -320,7 +442,7 @@ module Rigor
320
442
  warm = false
321
443
  skip_save = false
322
444
  end
323
- snapshot.save(fingerprint: fingerprint, payload: to_payload) if fingerprint && !skip_save
445
+ snapshot.save(fingerprint: fingerprint, payload: to_payload) if persist && fingerprint && !skip_save
324
446
  [diagnostics, warm]
325
447
  end
326
448
  end
@@ -386,6 +508,7 @@ module Rigor
386
508
  @missing = payload.missing || {}
387
509
  @class_decls = payload.class_decls || {}
388
510
  @return_summaries = payload.return_summaries || {}
511
+ @param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
389
512
  @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
390
513
  @ancestry_dependents = Incremental.invert(@ancestry_sources)
391
514
  @negative_dependents = Incremental.invert(@missing)
@@ -398,7 +521,8 @@ module Rigor
398
521
  symbol_fingerprints: @symbol_fingerprints, missing: @missing,
399
522
  class_decls: @class_decls, seed_bundles: @seed_bundles,
400
523
  plugin_fact_digest: @plugin_fact_digest,
401
- return_summaries: marshal_safe_return_summaries
524
+ return_summaries: marshal_safe_return_summaries,
525
+ param_table: marshal_safe_param_table
402
526
  )
403
527
  end
404
528
 
@@ -416,6 +540,18 @@ module Rigor
416
540
  end
417
541
  end
418
542
 
543
+ # ADR-67 WD6c lift — the param table filtered to Marshal-clean entries, the same guard (and reason) as
544
+ # {#marshal_safe_return_summaries} above. A dropped entry re-appears as "added" in the next recheck's
545
+ # diff, so its callee re-checks — the conservative direction.
546
+ def marshal_safe_param_table
547
+ @param_table.each_with_object({}) do |(key, params), safe|
548
+ Marshal.dump(params)
549
+ safe[key] = params
550
+ rescue StandardError
551
+ next
552
+ end
553
+ end
554
+
419
555
  # Fold a #recheck's fresh results back into the cache + graph so the session is correct across
420
556
  # multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
421
557
  # are evicted from every map, and the analyzed-file list advances to `current`.
@@ -669,7 +805,7 @@ module Rigor
669
805
  Runner.new(
670
806
  configuration: @configuration, cache_store: @cache_store, environment: @environment,
671
807
  plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
672
- workers: @workers, **
808
+ workers: @workers, buffer: @buffer, **
673
809
  )
674
810
  end
675
811
 
@@ -696,10 +832,19 @@ module Rigor
696
832
  candidates.reject { |path| stat_fresh?(path) }
697
833
  end
698
834
 
835
+ # A bound buffer's logical path is never fresh: the bytes to analyse live in the editor's temp file, and
836
+ # the recorded entry describes the file on disk. Re-analysing it when the two happen to agree costs one
837
+ # file; trusting the stat tuple would serve the editor its own stale diagnostics.
838
+ def buffer_path?(path)
839
+ !@buffer.nil? && path == @buffer.logical_path
840
+ end
841
+
699
842
  # True when `path`'s recorded stat entry proves it unchanged since the last analysis. Any stat / parse
700
843
  # failure (missing entry, unreadable / vanished file) reads as NOT fresh (→ re-analyse), preserving the
701
844
  # prior `digest(path) != recorded` "changed" semantics for a file that cannot be validated.
702
845
  def stat_fresh?(path)
846
+ return false if buffer_path?(path)
847
+
703
848
  entry = @digests[path]
704
849
  return false if entry.nil?
705
850
 
@@ -71,6 +71,20 @@ module Rigor
71
71
  new.digest_registry(registry)
72
72
  end
73
73
 
74
+ # The fact surface reduced to the one String a cache KEY can carry, or nil when the surface cannot be
75
+ # seen at all (an opaque plugin — one that contributes call-site types while declaring none of the three
76
+ # fingerprint channels). A nil obliges the caller to decline caching entirely, which is the same
77
+ # conservative direction {Result#reusable_against?} takes for the incremental snapshot: a key that
78
+ # silently omitted an invisible input would serve a stale value rather than miss.
79
+ #
80
+ # Keeping the opaque decision here, rather than at each cache's call site, means a new consumer cannot
81
+ # key on `digest` while forgetting that an opaque surface makes it meaningless.
82
+ # @return [String, nil]
83
+ def self.key_digest(registry)
84
+ result = from_registry(registry)
85
+ result.opaque? ? nil : result.digest.to_s
86
+ end
87
+
74
88
  # Loads the plugins and runs every `#prepare` hook sequentially, returning the prepared registry (nil on
75
89
  # any failure → the caller treats it as "no fact surface").
76
90
  def self.prepared_registry(configuration:, cache_store:, plugin_requirer:)
@@ -660,6 +660,8 @@ module Rigor
660
660
  "The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
661
661
  "The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
662
662
  "be enumerated statically and under-warning is the FP-safe direction.",
663
+ "The marker does not open the comment (documentation prose quoting the syntax, a doubled " \
664
+ "`##` comment, or an `=begin` block) — that is not parsed as a suppression either.",
663
665
  "The comment merely mentions the marker followed by non-token text (documentation prose " \
664
666
  "like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
665
667
  ],
@@ -684,6 +686,7 @@ module Rigor
684
686
  does_not_fire_when: [
685
687
  "At least one token follows the marker (each token is then checked by " \
686
688
  "`suppression.unknown-rule` instead).",
689
+ "The marker does not open the comment (documentation prose quoting the syntax).",
687
690
  "Non-token text follows the marker (documentation prose mentioning the syntax)."
688
691
  ],
689
692
  suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
@@ -730,7 +733,7 @@ module Rigor
730
733
  summary: "A comment uses a suppression marker Rigor does not recognise " \
731
734
  "(`rigor:disable-next-line`, `rigor:enable`, ...).",
732
735
  fires_when: [
733
- "A comment carries `rigor:disable-<suffix>` with a suffix other than `file`, or " \
736
+ "A comment OPENS with `rigor:disable-<suffix>` for a suffix other than `file`, or with " \
734
737
  "`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
735
738
  "<rule>` — followed by nothing or a rule-list-shaped remainder.",
736
739
  "Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
@@ -740,6 +743,7 @@ module Rigor
740
743
  does_not_fire_when: [
741
744
  "The marker is one of the two recognised forms (their tokens are then checked by " \
742
745
  "`suppression.unknown-rule` / `suppression.empty` instead).",
746
+ "The marker does not open the comment (documentation prose quoting the spelling).",
743
747
  "Non-token text follows the marker (documentation prose mentioning the spelling)."
744
748
  ],
745
749
  suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
@@ -13,6 +13,7 @@ end
13
13
 
14
14
  require_relative "../version"
15
15
  require_relative "../cache/descriptor"
16
+ require_relative "../cache/engine_source"
16
17
  require_relative "../cache/rbs_descriptor"
17
18
  require_relative "../environment/default_libraries"
18
19
 
@@ -51,7 +52,7 @@ module Rigor
51
52
  def descriptor(configuration:, files:, explain:, rbs_config_entries:)
52
53
  Cache::Descriptor.new(
53
54
  gems: [Cache::RbsDescriptor.rbs_gem_entry],
54
- configs: rbs_config_entries + [
55
+ configs: rbs_config_entries + engine_source_entries + [
55
56
  config_entry("configuration", Marshal.dump(configuration.to_h)),
56
57
  config_entry("engine",
57
58
  "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{explain}"),
@@ -62,6 +63,21 @@ module Rigor
62
63
  nil
63
64
  end
64
65
 
66
+ # Issue #285 — the `engine` slot above pins the engine by VERSION, which identifies the source only
67
+ # for a released gem. A checkout (a contributor's, or a `bundle add rigor, github:` clone) gets one
68
+ # extra slot carrying a digest of the engine's own source, so editing `lib/rigor/inference/*.rb` no
69
+ # longer replays the pre-edit diagnostics out of a warm cache. A released install adds NO entry, so
70
+ # its key — and its hit rate — are exactly what they were.
71
+ #
72
+ # {Cache::EngineSource::Unavailable} is left to propagate into `descriptor`'s rescue, which disables
73
+ # the cache for the run: an engine we cannot identify must not be keyed by its version alone.
74
+ def engine_source_entries
75
+ identity = Cache::EngineSource.process_identity
76
+ return [] if identity.nil?
77
+
78
+ [config_entry("engine-source", identity)]
79
+ end
80
+
65
81
  def config_entry(key, payload)
66
82
  Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
67
83
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ require_relative "../../environment"
6
+ require_relative "../../runtime/jit"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ class Runner
11
+ # Issue #142 — LSP multi-buffer dispatch. Runs one independent single-buffer `Runner#run` call per
12
+ # dirty buffer due to publish, distributed across a fork-based worker pool, reusing the SAME warm
13
+ # `Environment` + `ProjectScan` a sequential per-buffer publish already shares via
14
+ # `Rigor::LanguageServer::ProjectContext` — so a batch pays zero extra RBS-env-build or plugin
15
+ # `#prepare` cost over today's sequential path; only the per-file inference itself moves off the main
16
+ # process.
17
+ #
18
+ # Deliberately a SIBLING of {PoolCoordinator}, not a method on it. PoolCoordinator's unit of dispatch is
19
+ # "one shared {WorkerSession}, a project's files split across it" — that shape needs cross-worker
20
+ # reporter merge because ONE session's reporters accumulate per file. This dispatcher's unit of
21
+ # dispatch is "one COMPLETE `Runner#run` call per job", and each job is already fully self-contained
22
+ # (its own fresh reporters, its own severity-profile stamp, its own diagnostic aggregation) by
23
+ # construction — there is nothing to merge back except the diagnostics array itself. Forcing this
24
+ # through PoolCoordinator's constructor (which exists to wire ONE Runner's own mutable ivar surface
25
+ # through reader procs) would buy nothing.
26
+ #
27
+ # Sequential-equivalence contract: each job's diagnostics are EXACTLY what
28
+ # `Runner.new(buffer:, prebuilt:, environment:, configuration:, cache_store:, collect_stats: false)
29
+ # .run([binding.logical_path]).diagnostics`
30
+ # returns today — the same call `LanguageServer::DiagnosticPublisher#run_analysis` already makes
31
+ # inline for a single buffer. This dispatcher only changes WHERE that call executes (a forked child vs.
32
+ # the parent), never what it computes — so two runs of the same dirty set publish byte-identical
33
+ # results, and a run that cannot use the pool falls back to running every job in-process rather than
34
+ # failing.
35
+ #
36
+ # Buffer-substitution correctness (the highest-risk part of #142): EVERY job carries its OWN
37
+ # `BufferBinding` — a fork worker never shares one session-wide binding the way `PoolCoordinator`'s
38
+ # editor-mode single-buffer contract does. A binding not threaded all the way into the child's `Runner`
39
+ # would make that worker parse the file as it sits on disk and publish plausible-looking, stale
40
+ # diagnostics; every code path below carries `binding` explicitly rather than defaulting to nil.
41
+ class BufferPoolDispatcher
42
+ # Below this many bindings, `#analyze` takes the sequential in-process path even when the pool is
43
+ # otherwise available (workers positive, `fork` available, `cache_store` present). Reviewed and
44
+ # required for #142 to merge: a throwaway measurement against this repo's own `lib/rigor` (46-353
45
+ # files, ~1-2ms/file analysis once `Environment` + `ProjectScan` are warm — the exact steady state
46
+ # this dispatcher runs in) showed the FORK + `Marshal` + `Process.waitpid2` overhead exceeding the
47
+ # parallelism win below roughly N=12, and only paying off cleanly from N=16 up (interleaved,
48
+ # median-of-5, `workers: 8`):
49
+ #
50
+ # N=8 sequential=0.018s pooled=0.027s speedup=0.66x (pool SLOWER)
51
+ # N=12 sequential=0.021s pooled=0.021s speedup=1.00x (break-even, noisy)
52
+ # N=16 sequential=0.028s pooled=0.025s speedup=1.09x
53
+ # N=24 sequential=0.038s pooled=0.026s speedup=1.47x
54
+ # N=32 sequential=0.050s pooled=0.029s speedup=1.73x
55
+ #
56
+ # 16 sits with margin ABOVE the noisy break-even (12), not at it — full method + table in #142's PR
57
+ # description.
58
+ #
59
+ # This is deliberately a FIXED default, not a per-project auto-calibrated one. Per-file analysis cost
60
+ # varies enormously by project — this repo's own `lib/rigor` is ~1-2ms/file warm; a large Rails
61
+ # controller or model file can cost far more, which would make pooling profitable at a MUCH smaller N
62
+ # there. A cost-aware gate — deciding from the publisher's own observed per-publish duration instead
63
+ # of a bare file count — would adapt automatically, but needs plumbing that does not exist today (the
64
+ # publisher tracks no timing history) and is disproportionate to what closing #142 calls for; it is a
65
+ # reasonable follow-up, not attempted here. A project whose crossover sits at a different N overrides
66
+ # via `RIGOR_LSP_POOL_MIN_BATCH` (mirrors `RIGOR_RACTOR_WORKERS`'s override shape).
67
+ DEFAULT_MIN_BATCH_SIZE = 16
68
+
69
+ # @return [Integer] `RIGOR_LSP_POOL_MIN_BATCH` when set to a non-empty value, else
70
+ # {DEFAULT_MIN_BATCH_SIZE}.
71
+ def self.resolve_min_batch_size
72
+ env_value = ENV.fetch("RIGOR_LSP_POOL_MIN_BATCH", nil)
73
+ return DEFAULT_MIN_BATCH_SIZE if env_value.nil? || env_value.empty?
74
+
75
+ Integer(env_value)
76
+ end
77
+
78
+ # @param configuration [Rigor::Configuration]
79
+ # @param cache_store [Rigor::Cache::Store, nil]
80
+ # @param environment [Rigor::Environment] the warm, shared per-session Environment
81
+ # (`ProjectContext#environment`) every job's Runner reuses instead of rebuilding.
82
+ # @param prebuilt [Rigor::Analysis::ProjectScan] the warm, shared pre-pass snapshot
83
+ # (`ProjectContext#project_scan`) every job's Runner adopts instead of re-scanning.
84
+ # @param workers [Integer] pool size. `#analyze` degrades to sequential in-process execution — one
85
+ # job at a time, no `fork` — when fewer than 2 bindings are submitted, fewer than `min_batch_size`
86
+ # bindings are submitted, `workers` is not positive, `fork` is unavailable on this platform, or
87
+ # `cache_store` is nil. The middle two are the fork-pool-is-not-worth-it-yet gate documented on
88
+ # {DEFAULT_MIN_BATCH_SIZE}; the last two mirror
89
+ # {PoolCoordinator#analyze_files_in_pool}'s own fork-pool preconditions.
90
+ # @param min_batch_size [Integer] see {DEFAULT_MIN_BATCH_SIZE}. Exposed as a constructor param
91
+ # (rather than read from the env internally) purely for spec control; production callers get the
92
+ # resolved default.
93
+ def initialize(configuration:, cache_store:, environment:, prebuilt:, workers:,
94
+ min_batch_size: self.class.resolve_min_batch_size)
95
+ @configuration = configuration
96
+ @cache_store = cache_store
97
+ @environment = environment
98
+ @prebuilt = prebuilt
99
+ @workers = workers
100
+ @min_batch_size = min_batch_size
101
+ end
102
+
103
+ # Runs one `Runner#run([binding.logical_path])` per binding and returns `Array<Array<Diagnostic>>` —
104
+ # one diagnostics array per input binding, IN INPUT ORDER. The parent always absorbs worker results
105
+ # re-indexed by POSITION, never keyed on path, so two bindings that happen to share a logical path
106
+ # (should not occur — one dirty buffer per URI — but is not assumed away) can never collide or
107
+ # silently overwrite one another.
108
+ def analyze(bindings)
109
+ return [] if bindings.empty?
110
+ return bindings.map { |binding| run_one(binding) } unless dispatchable?(bindings)
111
+
112
+ dispatch_in_fork_pool(bindings)
113
+ end
114
+
115
+ private
116
+
117
+ def dispatchable?(bindings)
118
+ bindings.size > 1 && bindings.size >= @min_batch_size &&
119
+ @workers.is_a?(Integer) && @workers.positive? &&
120
+ Process.respond_to?(:fork) && !@cache_store.nil?
121
+ end
122
+
123
+ # One job, run exactly as `DiagnosticPublisher#run_analysis` runs it today — the equivalence contract
124
+ # this whole class exists to preserve.
125
+ def run_one(binding)
126
+ Runner.new(
127
+ configuration: @configuration, cache_store: @cache_store, collect_stats: false,
128
+ buffer: binding, prebuilt: @prebuilt, environment: @environment
129
+ ).run([binding.logical_path]).diagnostics
130
+ end
131
+
132
+ def dispatch_in_fork_pool(bindings)
133
+ # Pre-warm the memoized class registry on the parent so every forked child inherits it via
134
+ # copy-on-write instead of each re-computing it — mirrors `PoolCoordinator`'s own pool entry points.
135
+ Environment::ClassRegistry.default
136
+
137
+ worker_count = [@workers, bindings.size].min
138
+ indexed = bindings.each_with_index.to_a
139
+ slices = indexed.each_slice((indexed.size.to_f / worker_count).ceil).to_a
140
+ results_by_index = {}
141
+
142
+ degraded = Dir.mktmpdir("rigor-lsp-buffer-pool") do |tmpdir|
143
+ children = slices.each_with_index.map do |slice, worker_index|
144
+ out_path = File.join(tmpdir, "worker-#{worker_index}")
145
+ { pid: fork { run_fork_worker(slice, out_path) }, slice: slice, out_path: out_path }
146
+ end
147
+ collect_fork_results(children, results_by_index)
148
+ end
149
+
150
+ degraded.each do |slice|
151
+ slice.each { |binding, index| results_by_index[index] = run_one(binding) }
152
+ end
153
+
154
+ indexed.map { |_binding, index| results_by_index.fetch(index, []) }
155
+ end
156
+
157
+ # Child-process body. `fork` copies only the calling thread, so any deferred-YJIT deadline the parent
158
+ # armed does not survive into this child — re-arm it first, mirroring
159
+ # `PoolCoordinator#run_fork_worker`. In practice the LSP process enables YJIT immediately at boot
160
+ # (`Runtime::Jit.enable_now` in `LspCommand`, never the deferred-deadline path), so this is a cheap
161
+ # no-op today — kept for parity with every other fork site and in case that boot-time choice changes.
162
+ # `exit!` skips `at_exit` / stdio flush; the payload is already durable on disk by then.
163
+ def run_fork_worker(slice, out_path)
164
+ Runtime::Jit.rearm_after_fork
165
+ results = slice.to_h { |binding, index| [index, run_one(binding)] }
166
+ File.binwrite(out_path, Marshal.dump(results))
167
+ exit!(0)
168
+ rescue StandardError
169
+ exit!(1)
170
+ end
171
+
172
+ # Waits for every forked child, merges each successful payload into `results_by_index`, and returns
173
+ # the (binding, index) slices whose worker exited abnormally, for in-process degrade.
174
+ def collect_fork_results(children, results_by_index)
175
+ degraded = []
176
+ children.each do |child|
177
+ _, status = Process.waitpid2(child[:pid])
178
+ payload = fork_worker_payload(status, child[:out_path])
179
+ if payload
180
+ results_by_index.merge!(payload)
181
+ else
182
+ degraded << child[:slice]
183
+ end
184
+ end
185
+ degraded
186
+ end
187
+
188
+ # @return [Hash, nil] the child's `{index => diagnostics}` payload, or nil when the child exited
189
+ # abnormally or wrote no readable payload. `Marshal.load` is safe here: the blob was written by our
190
+ # own forked child to a temp file we created.
191
+ def fork_worker_payload(status, out_path)
192
+ return nil unless status.success? && File.exist?(out_path)
193
+
194
+ Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
195
+ rescue StandardError
196
+ nil
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -557,19 +557,37 @@ module Rigor
557
557
  return [] if @source_rbs_synthesis_reporter.empty?
558
558
 
559
559
  @source_rbs_synthesis_reporter.entries.map do |entry|
560
- Diagnostic.new(
561
- path: entry.path, line: 1, column: 1,
562
- message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
563
- "#{entry.message}. The file's analysis falls back to no inline-RBS " \
564
- "contribution. Fix the inline-RBS comment grammar or remove the " \
565
- "annotation to silence this diagnostic.",
566
- severity: :info,
567
- rule: "source-rbs-synthesis-failed",
568
- source_family: :builtin
569
- )
560
+ entry.kind == :not_honoured ? not_honoured_diagnostic(entry) : synthesis_failed_diagnostic(entry)
570
561
  end
571
562
  end
572
563
 
564
+ def synthesis_failed_diagnostic(entry)
565
+ Diagnostic.new(
566
+ path: entry.path, line: 1, column: 1,
567
+ message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
568
+ "#{entry.message}. The file's analysis falls back to no inline-RBS " \
569
+ "contribution. Fix the inline-RBS comment grammar or remove the " \
570
+ "annotation to silence this diagnostic.",
571
+ severity: :info,
572
+ rule: "source-rbs-synthesis-failed",
573
+ source_family: :builtin
574
+ )
575
+ end
576
+
577
+ # ADR-32 WD12 — the synthesis SUCCEEDED; one annotation inside it was parsed and then contributed
578
+ # nothing. Distinct from the failure above in the only way that matters to the reader: the rest of
579
+ # the file's annotations ARE in effect, so the advice is to fix one comment, not to distrust the file.
580
+ def not_honoured_diagnostic(entry)
581
+ Diagnostic.new(
582
+ path: entry.path, line: 1, column: 1,
583
+ message: "plugin `#{entry.plugin_id}` parsed an inline-RBS annotation in this file but did " \
584
+ "not honour it: #{entry.message} The file's other annotations are unaffected.",
585
+ severity: :info,
586
+ rule: "source-rbs-annotation-not-honoured",
587
+ source_family: :builtin
588
+ )
589
+ end
590
+
573
591
  # ADR-10 slice 5c — drains the per-run {DependencySourceInference::BoundaryCrossReporter} into
574
592
  # `dynamic.dependency-source.boundary-cross` `:info` diagnostics. Each event flags a call site
575
593
  # where RBS dispatch produced a concrete answer AND a `mode: :full` opt-in gem's source catalog