rigortype 0.3.0 → 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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -21,8 +21,15 @@ module Rigor
21
21
  ERROR_SERVER_NOT_INITIALIZED = -32_002
22
22
  ERROR_INVALID_REQUEST_AFTER_SHUTDOWN = -32_600
23
23
 
24
- # `TextDocumentSyncKind::Full = 1`. Slice 10 (deferred) promotes to `Incremental = 2`.
25
- TEXT_DOCUMENT_SYNC_FULL = 1
24
+ # `TextDocumentSyncKind::Incremental = 2`: `didChange` carries range edits, which `IncrementalSync`
25
+ # splices into the buffer the table already holds instead of re-sending — and re-parsing — the whole
26
+ # document on every keystroke. The full-text entry form (a `contentChanges` entry with no `range`) stays
27
+ # legal under this mode and is still handled.
28
+ TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
29
+
30
+ # LSP `PositionEncodingKind`. UTF-16 is the protocol default and the encoding `IncrementalSync` does its
31
+ # offset arithmetic in; advertising it explicitly states the contract rather than leaving it implied.
32
+ POSITION_ENCODING_UTF16 = "utf-16"
26
33
 
27
34
  # Methods callable BEFORE `initialize`. Per LSP spec § 3 only `initialize` and `exit` are allowed
28
35
  # pre-initialization; every other request returns `ServerNotInitialized`. We also accept `shutdown` so a
@@ -88,6 +95,7 @@ module Rigor
88
95
  when "exit" then handle_exit
89
96
  when "textDocument/didOpen" then handle_did_open(params)
90
97
  when "textDocument/didChange" then handle_did_change(params)
98
+ when "textDocument/didSave" then handle_did_save(params)
91
99
  when "textDocument/didClose" then handle_did_close(params)
92
100
  when "textDocument/hover" then handle_hover(params)
93
101
  when "textDocument/documentSymbol" then handle_document_symbol(params)
@@ -151,9 +159,13 @@ module Rigor
151
159
 
152
160
  def advertised_capabilities
153
161
  caps = {
162
+ positionEncoding: POSITION_ENCODING_UTF16,
154
163
  textDocumentSync: {
155
164
  openClose: true,
156
- change: TEXT_DOCUMENT_SYNC_FULL
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 }
157
169
  }
158
170
  }
159
171
  caps[:hoverProvider] = true if @hover_provider
@@ -213,25 +225,42 @@ module Rigor
213
225
  nil
214
226
  end
215
227
 
216
- # textDocument/didChange under FULL sync. Each `contentChanges` entry carries only `{ text: }`; the LAST
217
- # entry is the new full document text. Per LSP spec § "FULL sync" the array MUST be exactly one entry in
218
- # practice we still take `.last` defensively for clients that pad. Triggers `publishDiagnostics`
219
- # afterwards.
228
+ # textDocument/didChange under INCREMENTAL sync. Every `contentChanges` entry is applied in order, each
229
+ # against the result of the previous an entry with a `range` splices that span, an entry without one is
230
+ # the full new document text. The application (and its UTF-16 offset arithmetic) lives in
231
+ # `IncrementalSync`; the table keeps the last known-good text and flags the URI when a change cannot be
232
+ # applied. Triggers `publishDiagnostics` either way: a desynchronised buffer publishes an EMPTY set,
233
+ # which clears the markers instead of leaving stale ones on screen.
220
234
  def handle_did_change(params)
221
235
  doc = params.fetch(:textDocument)
222
236
  changes = params.fetch(:contentChanges)
223
237
  return nil if changes.empty?
224
238
 
225
239
  uri = doc.fetch(:uri)
226
- @buffer_table.change(
240
+ @buffer_table.apply_changes(
227
241
  uri: uri,
228
- bytes: changes.last.fetch(:text),
242
+ changes: changes,
229
243
  version: doc.fetch(:version)
230
244
  )
231
245
  @publisher&.publish_for(uri)
232
246
  nil
233
247
  end
234
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
+
235
264
  # textDocument/hover REQUEST. Slice 5 returns either a `Hover` payload (markdown contents wrapping type
236
265
  # + erased-RBS info) or nil when no expression is at the queried position. Nil maps to `result: null`
237
266
  # per LSP spec; clients suppress the popup. Returns `MethodNotFound` when no hover_provider is wired
@@ -9,10 +9,12 @@ module Rigor
9
9
  end
10
10
  end
11
11
 
12
+ require_relative "language_server/incremental_sync"
12
13
  require_relative "language_server/buffer_table"
13
14
  require_relative "language_server/uri"
14
15
  require_relative "language_server/project_context"
15
16
  require_relative "language_server/debouncer"
17
+ require_relative "language_server/publish_batcher"
16
18
  require_relative "language_server/synchronized_writer"
17
19
  require_relative "language_server/diagnostic_publisher"
18
20
  require_relative "language_server/hover_renderer"
@@ -8,6 +8,9 @@ require "prism"
8
8
  require_relative "manifest"
9
9
  require_relative "node_context"
10
10
  require_relative "../analysis/diagnostic"
11
+ # `producer generation_cap:` defaults to (and validates against) `Cache::Store::UNBOUNDED_GENERATIONS`, and a
12
+ # plugin class body can be evaluated before anything else pulled the cache layer in.
13
+ require_relative "../cache/store"
11
14
  require_relative "../source/node_walker"
12
15
 
13
16
  module Rigor
@@ -86,17 +89,40 @@ module Rigor
86
89
  #
87
90
  # Producer ids are auto-prefixed `plugin.<manifest.id>.` at the cache layer (slice 6-C) so plugin-side
88
91
  # ids cannot collide with built-in producers.
89
- def producer(id, watch: nil, serialize: nil, deserialize: nil, &block)
92
+ #
93
+ # `generation_cap:` declares how many generations of this producer's entries survive
94
+ # `Cache::Store#evict!`'s compaction pass. The default —
95
+ # `Cache::Store::UNBOUNDED_GENERATIONS` — suits the usual plugin producer, which keys per file or per
96
+ # discovered unit and keeps many entries live at once; only the size-based LRU pass bounds it. A
97
+ # producer whose entries are WHOLE-PROJECT and content-keyed (each run's inputs produce a fresh key
98
+ # and orphan the previous one) should declare a small positive Integer instead, so the orphans are
99
+ # reclaimed rather than accumulating under the global byte cap.
100
+ def producer(id, watch: nil, serialize: nil, deserialize: nil,
101
+ generation_cap: Cache::Store::UNBOUNDED_GENERATIONS, &block)
90
102
  raise ArgumentError, "Plugin::Base.producer requires a block body" if block.nil?
91
103
 
92
104
  validate_producer_watch!(watch)
105
+ validate_producer_generation_cap!(id, generation_cap)
93
106
  @producers ||= {}
94
107
  @producers[id.to_sym] = {
95
- block: block, watch: watch, serialize: serialize, deserialize: deserialize
108
+ block: block, watch: watch, serialize: serialize, deserialize: deserialize,
109
+ generation_cap: generation_cap
96
110
  }.freeze
97
111
  id.to_sym
98
112
  end
99
113
 
114
+ # A bad `generation_cap:` is caught at class-definition time (plugin load) rather than at the first
115
+ # `cache_for` round-trip, so the plugin author sees it before any caching happens.
116
+ def validate_producer_generation_cap!(id, generation_cap)
117
+ return if generation_cap == Cache::Store::UNBOUNDED_GENERATIONS
118
+ return if generation_cap.is_a?(Integer) && generation_cap.positive?
119
+
120
+ raise ArgumentError,
121
+ "Plugin::Base.producer #{id.inspect} generation_cap: must be a positive Integer or " \
122
+ "#{Cache::Store::UNBOUNDED_GENERATIONS.inspect}, got #{generation_cap.inspect}"
123
+ end
124
+ private :validate_producer_generation_cap!
125
+
100
126
  # ADR-60 WD3 — `watch:` is nil (no glob coverage), a static tuple Array, or a Proc evaluated per
101
127
  # `cache_for` call.
102
128
  def validate_producer_watch!(watch)
@@ -728,6 +754,7 @@ module Rigor
728
754
  store.fetch_or_validate(
729
755
  producer_id: prefixed_id,
730
756
  key_descriptor: key_descriptor,
757
+ generation_cap: producer[:generation_cap],
731
758
  params: params,
732
759
  serialize: pair_serializer(producer[:serialize]),
733
760
  deserialize: pair_deserializer(producer[:deserialize])
@@ -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
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ require_relative "../analysis/buffer_binding"
6
+ require_relative "../analysis/runner"
7
+ require_relative "diagnostic_oracle"
8
+ require_relative "discovery_seed"
9
+ require_relative "kill_signature"
10
+
11
+ module Rigor
12
+ module Protection
13
+ # Issue #254 — the ADR-69 Seam 1 kill oracle that judges a mutant by the WHOLE dependent closure.
14
+ #
15
+ # {DiagnosticOracle} re-analyses the mutated file alone, so the most valuable catch Rigor delivers is
16
+ # scored as a miss: change what a method returns and the diagnostic lands in its *callers*, which is
17
+ # exactly the cross-file reach the analyzer exists for. This oracle counts a kill when a new diagnostic
18
+ # (against the clean baseline of the same file set) appears anywhere in `{mutated} ∪ dependents[mutated]`
19
+ # — the ADR-46 reverse edge, supplied by {DependencyClosure}.
20
+ #
21
+ # **It is strictly additive, by construction.** The mutated file's verdict is delegated to a real
22
+ # {DiagnosticOracle}, built with exactly the knowledge the shipped oracle would have had (the
23
+ # `discovery-seeded-mutation-sites` seed when that feature is adopted, nothing when it is not), and only
24
+ # when that says "survived" is the closure consulted. So this feature moves `killed` in ONE direction and
25
+ # cannot silently re-decide a mutant the current oracle already kills. That separation is deliberate: what
26
+ # the oracle KNOWS is #253/#260's axis, and mixing the two here made the measurement uninterpretable —
27
+ # on redmine `app/models` an early build lost 11 kills to the richer knowledge while gaining none from the
28
+ # closure, and the two effects were indistinguishable in the total.
29
+ #
30
+ # **The mutant's bytes are never on the measured file's disk.** For the closure half they are written to a
31
+ # process-private temp file and bound to the measured path through {Analysis::BufferBinding} — the #146
32
+ # editor seam, whose whole purpose is "analyse THESE bytes at THAT logical path". The binding reaches three
33
+ # places that would otherwise read the file as it sits on disk:
34
+ #
35
+ # 1. the per-file parse (`Runner#parse_source` resolves through the binding);
36
+ # 2. the discovery tables the dependents resolve the mutated `def`s through
37
+ # ({DiscoverySeed.tables_for_buffer} — the change-detection half: the mutated path's digest is the
38
+ # mutant's, so its bundle is invalidated and re-walked);
39
+ # 3. diagnostic locations, which stay on the LOGICAL path, so a signature computed against a mutant is
40
+ # comparable with the baseline's.
41
+ #
42
+ # Miss any of them and every dependent reads the clean bytes, no diagnostic ever appears outside the
43
+ # mutated file, and the run reports a plausible number that measured nothing new.
44
+ #
45
+ # Cost. The closure is consulted only for the mutants the mutated file did not already kill (≈30% of them
46
+ # on Rigor's own `lib`), and each costs one analysis per dependent (mean 1.85 there) plus a ≈15ms seed
47
+ # re-fold. Every per-mutant analysis keeps `cache_store: nil`: a `--threshold` CI gate must never be
48
+ # handed a stale clean hit.
49
+ class ClosureKillOracle
50
+ # The clean baseline of one measured file, kept as TWO sets rather than their union. The mutated file's
51
+ # half must be compared against exactly what the single-file oracle would compare against — a diagnostic
52
+ # the closure baseline happens to carry at the mutated path (it analyses that file under cross-file
53
+ # knowledge the single-file run does not have) must not mask a kill the shipped oracle would report.
54
+ Baseline = Data.define(:own, :dependents)
55
+
56
+ # @param configuration [Rigor::Configuration]
57
+ # @param environment [Rigor::Environment] built once by the caller.
58
+ # @param project_scan [Rigor::Analysis::ProjectScan] built once by the caller; adopted per analysis
59
+ # through `prebuilt:`, exactly as {DiagnosticOracle} does.
60
+ # @param paths [Array<String>] the measured file set, in canonical order (the seed's span: a class
61
+ # declared outside it stays unknown, as it does for Tier 1's seed and for {DiscoverySeed}).
62
+ # @param dependents [Hash{String => Array<String>}] {DependencyClosure} map, restricted to `paths`.
63
+ # @param seed_bundles [Hash{String => Hash}] {DiscoverySeed.bundles} over the same `paths`.
64
+ # @param discovery_seed [Hash, nil] the `discovery-seeded-mutation-sites` seed when that feature is also
65
+ # adopted, nil otherwise. It goes to the delegated {DiagnosticOracle} verbatim, so the mutated file's
66
+ # verdict is byte-for-byte the verdict that feature combination produces without this one; its
67
+ # `param_inferred_types` slot additionally rides the per-mutant closure seed, so an admitted site is
68
+ # judged with the knowledge that admitted it (issue #260's amended decision). The table is not
69
+ # refreshed per mutant — the collector is a whole-project pre-pass, and one mutated method body does
70
+ # not justify re-running it thousands of times.
71
+ def initialize(configuration:, environment:, project_scan:, paths:, dependents:, seed_bundles:,
72
+ discovery_seed: nil)
73
+ @configuration = configuration
74
+ @environment = environment
75
+ @project_scan = project_scan
76
+ @paths = paths
77
+ @dependents = dependents
78
+ @seed_bundles = seed_bundles
79
+ @discovery_seed = discovery_seed
80
+ @param_inferred_types = discovery_seed && discovery_seed[:param_inferred_types]
81
+ @single = DiagnosticOracle.new(
82
+ configuration: configuration, environment: environment, project_scan: project_scan,
83
+ discovery_seed: discovery_seed
84
+ )
85
+ end
86
+
87
+ # The clean baselines a mutant must add a diagnostic to: the mutated file's (the shipped oracle's own,
88
+ # unchanged) and the dependents'. Computed once per measured file by the caller ({MutationScanner}),
89
+ # never per mutant. The dependents' half is bound through the same buffer machinery a mutant is, so the
90
+ # clean and mutant runs of the closure differ in exactly one input — the bytes.
91
+ def baseline(source:, path:)
92
+ Baseline.new(
93
+ own: @single.baseline(source: source, path: path),
94
+ dependents: dependents_signatures(source, path)
95
+ )
96
+ end
97
+
98
+ # Killed iff the mutant introduces a diagnostic the baseline did not carry — in the mutated file (the
99
+ # shipped verdict), or, failing that, in any dependent of it (what this feature adds).
100
+ def killed?(mutant_source:, path:, baseline:)
101
+ return true if @single.killed?(mutant_source: mutant_source, path: path, baseline: baseline.own)
102
+
103
+ dependents = @dependents[path] || []
104
+ return false if dependents.empty?
105
+
106
+ dependents_signatures(mutant_source, path).any? { |sig| !baseline.dependents.include?(sig) }
107
+ end
108
+
109
+ # The file set a kill is looked for in: the mutated file plus its measured dependents.
110
+ def closure_for(path)
111
+ [path, *(@dependents[path] || [])]
112
+ end
113
+
114
+ private
115
+
116
+ # The diagnostic signatures the dependents of `path` report while `source` stands in for it. Empty (and
117
+ # analysis-free) when nothing depends on the file — 120 of Rigor's own 349 `lib` files.
118
+ def dependents_signatures(source, path)
119
+ dependents = @dependents[path] || []
120
+ return Set.new if dependents.empty?
121
+
122
+ with_mutant(source, path) do |buffer, seed|
123
+ KillSignature.signatures_of(analyse(dependents, buffer, seed))
124
+ end
125
+ end
126
+
127
+ # Binds `source` to `path` for the duration of the block, yielding the binding and the seed tables
128
+ # rebuilt against it. One temp file per process (never per mutant): the digest that invalidates the
129
+ # mutated file's bundle is taken over the file's CONTENT, and the per-run digest memo is not installed
130
+ # out here, so rewriting one path in place cannot serve a stale answer.
131
+ def with_mutant(source, path)
132
+ File.binwrite(mutant_file.path, source)
133
+ buffer = Analysis::BufferBinding.new(logical_path: path, physical_path: mutant_file.path)
134
+ yield(buffer, seed_for(buffer))
135
+ end
136
+
137
+ # The process-private mutant file. Created lazily, and re-created after a fork ({CLI::MutationForkScan}
138
+ # workers must not share one path), which the pid guard detects.
139
+ def mutant_file
140
+ return @mutant_file if @mutant_file && @mutant_pid == Process.pid
141
+
142
+ @mutant_pid = Process.pid
143
+ @mutant_file = Tempfile.new(["rigor-mutant-", ".rb"])
144
+ end
145
+
146
+ def seed_for(buffer)
147
+ tables = DiscoverySeed.tables_for_buffer(paths: @paths, bundles: @seed_bundles, buffer: buffer)
148
+ return tables if @param_inferred_types.nil? || @param_inferred_types.empty?
149
+
150
+ tables.merge(param_inferred_types: @param_inferred_types).freeze
151
+ end
152
+
153
+ # One analysis of `paths` with the mutant bound. `prebuilt:` keeps the RBS environment + whole-project
154
+ # pre-pass the caller paid for once; `discovery_seed:` (issue #260's runner seam) is what carries the
155
+ # cross-file knowledge into a prebuilt run, and here it is the MUTANT-refreshed table set — without it a
156
+ # dependent resolves the `def` still on disk and no mutation could ever be visible to it.
157
+ #
158
+ # `analyze_only:` is load-bearing, not an optimisation. A `buffer:` alone selects editor-mode option A,
159
+ # whose analysed set IS the buffer's single logical path — every other file is read by the pre-passes
160
+ # and emits nothing. A closure oracle built that way would report zero cross-file kills and look
161
+ # entirely plausible doing it. Passing both selects option B (#146), where the closure wins and the
162
+ # buffer is one member of it.
163
+ def analyse(paths, buffer, seed)
164
+ Analysis::Runner.new(
165
+ configuration: @configuration, environment: @environment, prebuilt: @project_scan,
166
+ cache_store: nil, collect_stats: false, buffer: buffer, discovery_seed: seed, analyze_only: paths
167
+ ).run(paths).diagnostics
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analysis/runner"
4
+
5
+ module Rigor
6
+ module Protection
7
+ # Issue #254 — the **dependent closure** the Tier-2 kill oracle re-analyses: for each measured file, the
8
+ # measured files that read a declaration or a method body from it.
9
+ #
10
+ # ADR-46 already records exactly this edge, but only under `record_dependencies: true` — an ordinary
11
+ # analysis (and every per-mutant analysis) records nothing. So the map is built ONCE, by one recording
12
+ # pass over the measured paths, and then read for every mutant. This is the one place ADR-46's REVERSE
13
+ # index is the right tool (the forward edge is a cache-key input; #134 needs that one).
14
+ #
15
+ # Cost, measured on Rigor's own `lib` (349 files, 8 workers, warm cache): ≈4.3s for the recording pass —
16
+ # roughly one extra `rigor check` next to a multi-minute mutation run, paid once. The pass CANNOT reuse
17
+ # the caller's `prebuilt:` {Analysis::ProjectScan}: `Runner#ensure_project_discovery` is a no-op under
18
+ # `prebuilt:`, so a prebuilt recording run would resolve nothing cross-file and record an empty graph —
19
+ # a silently empty closure, which is precisely the failure mode this feature exists to remove. It
20
+ # therefore runs its own pre-passes, sharing only the environment and the (read-only) cache store.
21
+ #
22
+ # Not fail-soft on purpose: an exception here aborts the run rather than degrading the measurement to
23
+ # "no dependents", which would look exactly like a plausible effectiveness number.
24
+ module DependencyClosure
25
+ module_function
26
+
27
+ # @param paths [Array<String>] the measured file set, in caller order.
28
+ # @param configuration [Rigor::Configuration]
29
+ # @param environment [Rigor::Environment] built once by the caller.
30
+ # @param cache_store [Rigor::Cache::Store, nil] threaded to the recording run only (its RBS-env and
31
+ # plugin-producer tiers); the per-mutant analyses stay `cache_store: nil` regardless.
32
+ # @param workers [Integer] fork-pool workers for the recording pass (the pool records per worker and
33
+ # marshals the records back, so a pooled graph equals the sequential one).
34
+ # @return [Hash{String => Array<String>}] frozen `path => sorted dependents`, restricted to `paths`.
35
+ def build(paths:, configuration:, environment:, cache_store: nil, workers: 0)
36
+ runner = Analysis::Runner.new(
37
+ configuration: configuration, environment: environment, cache_store: cache_store,
38
+ collect_stats: false, record_dependencies: true, workers: workers
39
+ )
40
+ runner.run(paths)
41
+ index(runner.file_dependents, paths)
42
+ end
43
+
44
+ # Restricts a raw {Analysis::Runner#file_dependents} map to the measured set: a dependent outside it is
45
+ # not being measured, so re-analysing it would report a diagnostic against a file the run never
46
+ # baselined. Self-edges are dropped (the mutated file is the closure's own head), and each list is
47
+ # sorted so a `--threshold` gate reads the same number whatever order the recording pass finished in.
48
+ #
49
+ # @return [Hash{String => Array<String>}]
50
+ def index(dependents, paths)
51
+ measured = Set.new(paths)
52
+ paths.to_h do |path|
53
+ list = (dependents[path] || []).select { |dep| measured.include?(dep) && dep != path }
54
+ [path, list.sort.freeze]
55
+ end.freeze
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../analysis/runner"
4
+ require_relative "kill_signature"
4
5
 
5
6
  module Rigor
6
7
  module Protection
@@ -15,21 +16,28 @@ module Rigor
15
16
  # disk write). Passing `prebuilt:` disables the run-result cache (whose key digests the *disk* file), so a
16
17
  # mutant is never served a stale clean hit.
17
18
  class DiagnosticOracle
18
- def initialize(configuration:, environment:, project_scan:)
19
+ # @param discovery_seed [Hash, nil] issue #260 — the cross-file discovery tables (see {DiscoverySeed})
20
+ # the per-mutant analysis is seeded with, threaded through to `Runner.new(discovery_seed:)`. Without
21
+ # it the runner's `prebuilt:` path carries frozen-empty discovery tables, so a receiver whose class is
22
+ # declared in a *sibling* file reads `Dynamic` and NO mutation at that site can produce a diagnostic —
23
+ # a site the caller's site filter may nonetheless have admitted, and then measured as an unkillable
24
+ # survivor. nil (the default) keeps the shipped single-file oracle.
25
+ def initialize(configuration:, environment:, project_scan:, discovery_seed: nil)
19
26
  @configuration = configuration
20
27
  @environment = environment
21
28
  @project_scan = project_scan
29
+ @discovery_seed = discovery_seed
22
30
  end
23
31
 
24
32
  # The clean per-file baseline: the diagnostic signatures a mutant must add to count as killed. Computed
25
33
  # once per file by the caller.
26
34
  def baseline(source:, path:)
27
- analyse(source, path).to_set { |d| sig(d) }
35
+ KillSignature.signatures_of(analyse(source, path))
28
36
  end
29
37
 
30
38
  # Killed iff the mutant introduces a diagnostic not in `baseline`.
31
39
  def killed?(mutant_source:, path:, baseline:)
32
- analyse(mutant_source, path).any? { |d| !baseline.include?(sig(d)) }
40
+ analyse(mutant_source, path).any? { |d| !baseline.include?(KillSignature.of(d)) }
33
41
  end
34
42
 
35
43
  private
@@ -37,13 +45,9 @@ module Rigor
37
45
  def analyse(source, path)
38
46
  Rigor::Analysis::Runner.new(
39
47
  configuration: @configuration, environment: @environment, prebuilt: @project_scan,
40
- cache_store: nil, collect_stats: false
48
+ cache_store: nil, collect_stats: false, discovery_seed: @discovery_seed
41
49
  ).run_source(source: source, path: path).diagnostics
42
50
  end
43
-
44
- def sig(diagnostic)
45
- [diagnostic.rule, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message]
46
- end
47
51
  end
48
52
  end
49
53
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../inference/parameter_inference_collector"
4
+ require_relative "../inference/scope_indexer"
5
+
6
+ module Rigor
7
+ module Protection
8
+ # Issue #260 — the cross-file knowledge Tier 2 hands to BOTH halves of a mutation measurement.
9
+ #
10
+ # Site selection and the kill oracle used to disagree by construction: the site filter judged a receiver
11
+ # against a scope seeded with `discovered_classes`, so `Account.find` (with `Account` declared in a sibling
12
+ # file) entered the denominator — while the oracle re-analysed each mutant through
13
+ # `Runner.new(prebuilt:)#run_source`, whose cross-file discovery tables stay frozen-empty, so the same
14
+ # receiver read `Dynamic` and NO mutation at that site could ever produce a diagnostic. Every admitted
15
+ # cross-file site was therefore a guaranteed survivor: on Rigor's own `lib`, +2,183 sites bought +2,187
16
+ # survivors and moved `killed` by −4.
17
+ #
18
+ # The answer (issue #260's recorded decision) is one table set, built once, threaded to both sides: the
19
+ # {Mutator} base scope and {DiagnosticOracle}'s runner seam. This module builds that table set — the FULL
20
+ # discovery bundle (`discovered_def_nodes`, `discovered_methods`, superclasses, includes, …), not just the
21
+ # class-identity slice site selection needed, because resolving `find` on `singleton(Account)` is what makes
22
+ # the site killable.
23
+ #
24
+ # The shape is a plain Hash of {Scope::DiscoveryIndex} slot names, which is exactly what both consumers take:
25
+ # `scope.discovery.with(**tables)` on one side, `Runner#project_scope_seed_tables` on the other.
26
+ #
27
+ # Built ONCE on the parent, before {CLI::MutationForkScan} forks, so children copy-on-write inherit it.
28
+ # Nothing here crosses the marshal boundary (only per-file results do) — which matters, because the def-node
29
+ # tables hold live `Prism::Node`s.
30
+ module DiscoverySeed
31
+ module_function
32
+
33
+ # @param paths [Array<String>] the measured file set; the seed spans these files only, exactly as Tier 1's
34
+ # seed does. A class declared outside them stays unknown.
35
+ # @param environment [Rigor::Environment] the plugin-aware environment, built once by the caller.
36
+ # @param target_ruby [String] Prism parse version for the parameter-inference pre-pass.
37
+ # @param workers [Integer] worker count for that pre-pass (0 keeps it sequential).
38
+ # @return [Hash{Symbol => Object}] frozen seed tables; empty when the paths yield nothing.
39
+ def build(paths:, environment:, target_ruby:, workers: 0)
40
+ tables = discovery_tables(paths)
41
+ params = Inference::ParameterInferenceCollector.collect(
42
+ files: paths, environment: environment, target_ruby: target_ruby, workers: workers
43
+ )
44
+ tables[:param_inferred_types] = params unless params.empty?
45
+ tables.freeze
46
+ end
47
+
48
+ # Issue #254 — the per-file discovery *bundles* (ADR-85 WD2) the closure kill oracle re-folds once per
49
+ # mutant. A bundle is one file's isolated contribution to the tables above, plus the content digest that
50
+ # decides whether it is still valid; folding the whole set reconstructs exactly what {#discovery_tables}
51
+ # builds. Built ONCE on the parent (before {CLI::MutationForkScan} forks, so children copy-on-write
52
+ # inherit it), and cheap: ≈0.36s over Rigor's own 349-file `lib`.
53
+ #
54
+ # @param paths [Array<String>] the measured file set, in canonical (caller) order.
55
+ # @return [Hash{String => Hash}] per-path bundles, the input {#tables_for_buffer} re-folds.
56
+ def bundles(paths:)
57
+ Inference::ScopeIndexer.discovered_project_index_incremental(paths, seed_bundles: {}).fetch(:bundles)
58
+ end
59
+
60
+ # Issue #254 — the seed tables for ONE mutant: every measured file's bundle folded back unchanged
61
+ # EXCEPT the file `buffer` binds, which is re-walked from the buffer's bytes.
62
+ #
63
+ # This is where the substitution reaches change detection rather than only the analysis. The
64
+ # incremental pass digests each path through the binding, so the mutated file's digest is the MUTANT's
65
+ # and never matches its cached bundle — it is re-walked, and every dependent then reads the mutated
66
+ # `def` bodies out of the seed instead of the ones still on disk. Without it a closure re-analysis
67
+ # would resolve the clean method, produce no new diagnostic anywhere but the mutated file itself, and
68
+ # report a plausible-looking effectiveness number that measured nothing new.
69
+ #
70
+ # ≈15ms over 349 files (one re-walk plus a whole-set fold), against ≈210ms for one mutant's analysis.
71
+ #
72
+ # @param paths [Array<String>] the measured file set, in the same order {#bundles} was built from.
73
+ # @param bundles [Hash{String => Hash}] that bundle set.
74
+ # @param buffer [Rigor::Analysis::BufferBinding] the mutant binding (logical path → mutant bytes).
75
+ # @return [Hash{Symbol => Object}] frozen seed tables.
76
+ def tables_for_buffer(paths:, bundles:, buffer:)
77
+ index = Inference::ScopeIndexer.discovered_project_index_incremental(
78
+ paths, seed_bundles: bundles, buffer: buffer
79
+ )
80
+ seed_tables(index).freeze
81
+ end
82
+
83
+ # The whole-project discovery tables, from the single-walk combined pass (one parse per file, both
84
+ # collectors driven over the same tree) that {Analysis::Runner::ProjectPrePasses#discover} uses. Empty
85
+ # tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing" branch keeps working.
86
+ #
87
+ # `discovered_class_sources` is deliberately NOT carried: the runner itself seeds it only under dependency
88
+ # recording (it is read by the ancestry accessors solely to record cross-file edges), and Tier 2 never
89
+ # records.
90
+ def discovery_tables(paths)
91
+ seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
92
+ end
93
+
94
+ # Maps a `{ classes:, def_index: }` discovery index onto the {Scope::DiscoveryIndex} slot names both
95
+ # consumers take. Shared by the whole-walk ({#discovery_tables}) and bundle-fold ({#tables_for_buffer})
96
+ # producers so a mutant's seed and the parent's seed can never disagree about shape.
97
+ def seed_tables(index)
98
+ def_index = index.fetch(:def_index)
99
+ tables = { discovered_classes: index.fetch(:classes) }
100
+ %i[
101
+ def_nodes singleton_def_nodes def_sources singleton_def_sources superclasses includes
102
+ method_visibilities methods data_member_layouts struct_member_layouts
103
+ ].each do |slot|
104
+ tables[seed_key(slot)] = def_index.fetch(slot)
105
+ end
106
+ tables.reject { |_, table| table.nil? || table.empty? }
107
+ end
108
+
109
+ # The def-index slot names match the {Scope::DiscoveryIndex} ones under a `discovered_` prefix, except the
110
+ # two member-layout tables, which carry the same name on both sides.
111
+ def seed_key(slot)
112
+ slot.to_s.end_with?("member_layouts") ? slot : :"discovered_#{slot}"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Protection
5
+ # ADR-69 Seam 1 — the identity a *kill* is judged by, shared by every diagnostic-based oracle.
6
+ #
7
+ # A mutant is killed when re-analysis produces a diagnostic the clean baseline did not carry, so the
8
+ # comparison needs an identity that is stable across two runs of the same code and distinct for two
9
+ # genuinely different reports. `path` is part of it deliberately: the closure oracle (issue #254) pools
10
+ # the baseline of several files into one set, and the same rule firing at the same line of a *different*
11
+ # file must not be mistaken for the baseline's.
12
+ #
13
+ # Lifted out of {DiagnosticOracle} when {ClosureKillOracle} arrived: two oracles disagreeing about what
14
+ # "the same diagnostic" means would show up only as an unexplained kill-count delta between them.
15
+ module KillSignature
16
+ module_function
17
+
18
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
19
+ # @return [Array] the comparison key.
20
+ def of(diagnostic)
21
+ [diagnostic.rule, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message]
22
+ end
23
+
24
+ # @param diagnostics [Enumerable<Rigor::Analysis::Diagnostic>]
25
+ # @return [Set<Array>] the signature set of `diagnostics`.
26
+ def signatures_of(diagnostics)
27
+ diagnostics.to_set { |diagnostic| of(diagnostic) }
28
+ end
29
+ end
30
+ end
31
+ end