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
@@ -4,6 +4,8 @@ require "fileutils"
4
4
  require "digest"
5
5
  require "zlib"
6
6
 
7
+ require_relative "engine_source"
8
+
7
9
  module Rigor
8
10
  module Cache
9
11
  # ADR-46 — disk persistence for the incremental analyzer's per-file state, so a `--incremental` session
@@ -14,8 +16,16 @@ module Rigor
14
16
  # this snapshot is loaded UNCONDITIONALLY when the global fingerprint matches — the per-file digests
15
17
  # *inside* it drive the incremental re-analysis decision; they do not gate the load. The fingerprint
16
18
  # captures the inputs whose change requires a full rebuild — the resolved configuration, the RBS
17
- # environment, the engine version but NOT the analyzed source contents. A fingerprint mismatch (config /
18
- # gem / version change) drops the snapshot and forces a full re-analysis, the conservative direction.
19
+ # environment, the engine version and (on a checkout) the engine's own source but NOT the analyzed
20
+ # source contents. A fingerprint mismatch (config / gem / version / engine change) drops the snapshot and
21
+ # forces a full re-analysis, the conservative direction.
22
+ #
23
+ # An engine change drops the WHOLE snapshot rather than part of it, which is a soundness point before it
24
+ # is a simplicity one: every section here except `digests` is a value the analyzer computed, so a changed
25
+ # engine can move any of it — including the dependency edges, where a new engine recording an edge the old
26
+ # one missed would let a recheck skip the very file that needed re-analysing. Retaining the one
27
+ # engine-independent section would not pay either: `digests` is 2.5% of a 2.5 MB snapshot of this repo,
28
+ # and re-deriving it is a file-digest walk costing ~0.2% of the full run it would be saving.
19
29
  #
20
30
  # Every operation is fault-tolerant: a missing, unreadable, schema-mismatched, fingerprint-mismatched, or
21
31
  # corrupt snapshot loads as nil (→ a cold full run), and a write failure is swallowed (→ the next run is
@@ -38,8 +48,11 @@ module Rigor
38
48
  # bundle (the per-def parameter-shape / visibility / ancestry surface the declaration-stability gate
39
49
  # compares) and WD2 adds `return_summaries` (per-def observed-key return descriptors + mutation-effect
40
50
  # sets the behavioural-stability gate compares); a pre-10 blob mismatches the SCHEMA gate and loads as
41
- # nil (a clean cold rebuild — no migration).
42
- SCHEMA = 10
51
+ # nil (a clean cold rebuild — no migration). 11: ADR-67 WD6c lift adds `param_table` (the inferred-param
52
+ # seed table the run's diagnostics were computed under, diffed on the next recheck to invalidate a
53
+ # callee whose seeds moved because a caller changed); a pre-11 blob mismatches the SCHEMA gate and
54
+ # loads as nil (a clean cold rebuild — no migration).
55
+ SCHEMA = 11
43
56
 
44
57
  # The persisted per-file state.
45
58
  # `cache` maps an analyzed file to its diagnostics.
@@ -68,20 +81,35 @@ module Rigor
68
81
  # (Marshal-clean type tuples), their `describe(:short)` return descriptors, and the content-mutated
69
82
  # parameter positions. A recheck re-evaluates a declaration-stable changed callee at these keys and,
70
83
  # when every return + the effects are unchanged, skips its symbol dependents.
84
+ # ADR-67 WD6c lift:
85
+ # `param_table` is the `parameter_inference:` seed table (`[class, method, kind] => {param => Type}`)
86
+ # the run that wrote the snapshot seeded its analysis from — `{}` when the gate is off. A recheck
87
+ # recomputes the table fresh (the pre-pass is whole-project by design) and diffs it against this copy;
88
+ # a changed entry invalidates the callee's file and its symbol dependents. The types are Marshal-clean
89
+ # by the session's per-entry filter; a dropped entry re-checks its callee, the conservative direction.
71
90
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
72
91
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
73
92
  :missing, :class_decls, :seed_bundles, :plugin_fact_digest,
74
- :return_summaries)
93
+ :return_summaries, :param_table)
75
94
 
76
95
  # The global fingerprint that gates a snapshot load: a digest of the inputs whose change requires a full
77
- # rebuild — the engine version + schema, the resolved configuration, the analysis **roots** (the path
78
- # arguments, e.g. `["lib"]`, NOT the expanded file list — so a snapshot is keyed to an invocation's roots
79
- # but adding / removing a file under them is handled incrementally by the session, not a full rebuild),
80
- # the resolved gem set (`Gemfile.lock` / `rbs_collection`), and the project's own RBS (`signature_paths`
81
- # file contents). Built WITHOUT constructing the RBS environment so the warm path can gate the load
82
- # cheaply, before the costly env build. The `--verify-incremental` gate is the safety net for any
83
- # under-capture (it would surface as an incremental-vs-full mismatch). Returns nil on any error → the
84
- # caller falls back to a non-persisted run.
96
+ # rebuild — the engine version + schema, the engine's own SOURCE when the version does not pin it, the
97
+ # resolved configuration, the analysis **roots** (the path arguments, e.g. `["lib"]`, NOT the expanded
98
+ # file list so a snapshot is keyed to an invocation's roots but adding / removing a file under them is
99
+ # handled incrementally by the session, not a full rebuild), the resolved gem set (`Gemfile.lock` /
100
+ # `rbs_collection`), and the project's own RBS (`signature_paths` file contents). Built WITHOUT
101
+ # constructing the RBS environment so the warm path can gate the load cheaply, before the costly env
102
+ # build. The `--verify-incremental` gate is the safety net for any under-capture (it would surface as an
103
+ # incremental-vs-full mismatch). Returns nil on any error → the caller falls back to a non-persisted run.
104
+ #
105
+ # Issue #285, wired here by #289 — every value in this snapshot is something the ANALYZER computed, so
106
+ # `Rigor::VERSION` alone is not enough to identify what produced it. A version pins the engine's bytes
107
+ # for a RubyGems install and for nothing else, so on a checkout a warm recheck served diagnostics a
108
+ # pre-edit analyzer had computed: editing `lib/rigor/inference/*.rb` moved no ANALYZED file, the changed
109
+ # set came back empty, and 357 unchanged files replayed their old answers.
110
+ # {EngineSource.process_identity} closes it, and answers nil for a version-pinned tree — which adds no
111
+ # part, so a released gem's fingerprint is byte-identical to the pre-#289 one and its warm snapshots
112
+ # survive the upgrade untouched.
85
113
  def self.fingerprint(configuration:, roots:)
86
114
  parts = [
87
115
  "engine:#{Rigor::VERSION}:#{SCHEMA}",
@@ -91,8 +119,15 @@ module Rigor
91
119
  "rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
92
120
  "sig:#{digest_signature_paths(configuration.signature_paths)}"
93
121
  ]
122
+ identity = EngineSource.process_identity
123
+ parts << "engine-source:#{identity}" if identity
94
124
  Digest::SHA256.hexdigest(parts.join("\x00"))
95
125
  rescue StandardError
126
+ # {EngineSource::Unavailable} lands here too, and nil is the answer it requires rather than one it
127
+ # merely tolerates: an engine we cannot identify must DISABLE the snapshot, never fall back to the
128
+ # version-only key that is the blind spot above. Nil does disable it on both sides —
129
+ # {Analysis::IncrementalSession} guards the load AND the save on the fingerprint, so nothing stale is
130
+ # read and no nil-keyed blob is written for the next equally-unidentifiable run to match against.
96
131
  nil
97
132
  end
98
133
 
@@ -124,9 +159,42 @@ module Rigor
124
159
  # The stored {Payload}, or nil when absent / unreadable / schema or fingerprint mismatch / corrupt.
125
160
  # Never raises.
126
161
  def load(fingerprint:)
162
+ data = read_data
163
+ return nil unless data && data[:fingerprint] == fingerprint
164
+
165
+ payload_from(data)
166
+ end
167
+
168
+ # Issue #134 slice 2 — the same load against SEVERAL acceptable fingerprints, reading the blob once.
169
+ # A reader that did not itself write the snapshot cannot know which analysis ROOTS it was written under
170
+ # (`rigor check --incremental lib` and a bare `rigor check --incremental` produce different fingerprints
171
+ # for the same project), and `#load` would have to re-inflate + re-unmarshal the whole blob per candidate.
172
+ # The fingerprint that matched is returned alongside the payload so the caller can mix it into ITS own
173
+ # cache key — the snapshot's identity is exactly what "these dependency edges came from that world" means.
174
+ #
175
+ # @param fingerprints [Array<String>] candidates, most-specific first.
176
+ # @return [Array(String, Payload), nil] `[matched fingerprint, payload]`, or nil on any miss.
177
+ def load_any(fingerprints:)
178
+ data = read_data
179
+ return nil if data.nil?
180
+
181
+ matched = Array(fingerprints).compact.find { |candidate| data[:fingerprint] == candidate }
182
+ return nil if matched.nil?
183
+
184
+ [matched, payload_from(data)]
185
+ end
186
+
187
+ # The raw stored Hash when it is present, readable, and schema-current; nil otherwise. Never raises —
188
+ # a missing, corrupt, or stale-schema snapshot is a cold run, not an error (the ADR-45 invariant).
189
+ def read_data
127
190
  data = Marshal.load(Zlib::Inflate.inflate(File.binread(@path))) # rubocop:disable Security/MarshalLoad
128
- return nil unless data.is_a?(Hash) && data[:schema] == SCHEMA && data[:fingerprint] == fingerprint
191
+ data.is_a?(Hash) && data[:schema] == SCHEMA ? data : nil
192
+ rescue StandardError
193
+ nil
194
+ end
195
+ private :read_data
129
196
 
197
+ def payload_from(data)
130
198
  Payload.new(
131
199
  cache: data[:cache], sources: data[:sources],
132
200
  digests: data[:digests], analyzed: data[:analyzed],
@@ -137,11 +205,11 @@ module Rigor
137
205
  class_decls: data[:class_decls] || {},
138
206
  seed_bundles: data[:seed_bundles] || {},
139
207
  plugin_fact_digest: data[:plugin_fact_digest],
140
- return_summaries: data[:return_summaries] || {}
208
+ return_summaries: data[:return_summaries] || {},
209
+ param_table: data[:param_table] || {}
141
210
  )
142
- rescue StandardError
143
- nil
144
211
  end
212
+ private :payload_from
145
213
 
146
214
  # Persist `payload` under `fingerprint`. Writes via a temp file + atomic rename so a concurrent reader
147
215
  # never sees a half-written snapshot. Returns true on success, false on any failure (never raises).
@@ -158,7 +226,8 @@ module Rigor
158
226
  class_decls: payload.class_decls,
159
227
  seed_bundles: payload.seed_bundles,
160
228
  plugin_fact_digest: payload.plugin_fact_digest,
161
- return_summaries: payload.return_summaries
229
+ return_summaries: payload.return_summaries,
230
+ param_table: payload.param_table
162
231
  )
163
232
  blob = Zlib::Deflate.deflate(raw)
164
233
  tmp = "#{@path}.#{Process.pid}.tmp"
@@ -48,9 +48,6 @@ module Rigor
48
48
 
49
49
  configuration = load_check_configuration(options)
50
50
  configuration = apply_bleeding_edge_override(configuration, options)
51
- conflict = parameter_inference_incremental_conflict(configuration, options)
52
- return conflict unless conflict.nil?
53
-
54
51
  config_warnings = warn_unresolved_config(configuration)
55
52
  cache_root = configuration.cache_path
56
53
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
@@ -62,7 +59,7 @@ module Rigor
62
59
  return finalize_cache_hit(probed, configuration, options, config_warnings) unless probed.nil?
63
60
 
64
61
  load_check_dependencies
65
- special = dispatch_special_check_mode(configuration, options, cache_root)
62
+ special = dispatch_special_check_mode(configuration, options, cache_root, buffer)
66
63
  return special unless special.nil?
67
64
 
68
65
  invocation = invoke_check(
@@ -143,30 +140,24 @@ module Rigor
143
140
  exit_code
144
141
  end
145
142
 
146
- # ADR-67 WD6c — `parameter_inference:` and the ADR-46 incremental modes are mutually exclusive in slice 1.
147
- # The parameter table introduces cross-file edges (a caller's argument types drive a callee's body
148
- # diagnostics) that the per-file dependency recorder does not yet carry, so a cached file whose parameter
149
- # seeds changed would serve a stale result. Refuse with a message naming the ADR-46 gap rather than
150
- # silently producing unsound incremental diagnostics; the edge wiring is the named follow-up. Returns the
151
- # usage exit code on a conflict, or nil to proceed. `--verify-incremental` (the acceptance gate that runs
152
- # incremental analysis) is caught here too.
153
- def parameter_inference_incremental_conflict(configuration, options)
154
- return nil unless configuration.parameter_inference
155
- return nil unless options.fetch(:incremental) || options.fetch(:verify_incremental)
156
-
157
- flag = options.fetch(:incremental) ? "--incremental" : "--verify-incremental"
158
- @err.puts("rigor: parameter_inference: cannot combine with #{flag} — the call-site parameter table " \
159
- "introduces cross-file caller→callee edges the ADR-46 incremental dependency graph does not " \
160
- "yet record, so a cached file could serve a stale diagnostic. Run a full check (drop " \
161
- "#{flag}), or disable parameter_inference: for this run.")
162
- CLI::EXIT_USAGE
163
- end
164
-
165
143
  # ADR-46 — the two incremental-analysis check modes both fully handle the run and return an exit code (so `run`
166
144
  # short-circuits); returns nil for an ordinary check.
167
- def dispatch_special_check_mode(configuration, options, cache_root)
168
- return run_verify_incremental(configuration, options, cache_root) if options.fetch(:verify_incremental)
169
- return run_incremental_check(configuration, options, cache_root) if options.fetch(:incremental)
145
+ # A nil return means "not a special mode" — `run` continues with the ordinary check. Editor mode option B
146
+ # also returns nil when it declines (no reusable snapshot), so the run falls back to single-file scope.
147
+ def dispatch_special_check_mode(configuration, options, cache_root, buffer)
148
+ if options.fetch(:verify_incremental)
149
+ # The gate compares an incremental recheck against a full-run oracle, and a buffer makes the two
150
+ # disagree by construction (the oracle reads the file on disk). Refusing beats the silent wrong
151
+ # answer both incremental modes gave a buffer before #146.
152
+ if buffer
153
+ @err.puts("rigor: --verify-incremental cannot run against an editor buffer " \
154
+ "(--tmp-file / --instead-of); it compares against a full analysis of the files on disk.")
155
+ return CLI::EXIT_USAGE
156
+ end
157
+
158
+ return run_verify_incremental(configuration, options, cache_root)
159
+ end
160
+ return run_incremental_check(configuration, options, cache_root, buffer) if options.fetch(:incremental)
170
161
 
171
162
  nil
172
163
  end
@@ -212,7 +203,7 @@ module Rigor
212
203
  # run (plus their dependents), serving the rest from the snapshot; on a miss runs a full baseline. Persists the
213
204
  # updated snapshot for the next invocation. Diagnostics are identical to a full run (the `--verify-incremental`
214
205
  # gate enforces this); the win is skipping per-file inference for unchanged files.
215
- def run_incremental_check(configuration, options, cache_root)
206
+ def run_incremental_check(configuration, options, cache_root, buffer = nil)
216
207
  require_relative "check_runner_factory"
217
208
  paths = @argv.empty? ? nil : @argv
218
209
  fingerprint = Cache::IncrementalSnapshot.fingerprint(
@@ -226,9 +217,12 @@ module Rigor
226
217
  # ADR-46 — thread the same worker-count precedence the standard `check` path uses
227
218
  # (CLI `--workers` > `RIGOR_RACTOR_WORKERS` > `parallel.workers:` > 0) so the recheck's closure
228
219
  # re-analysis parallelises; the fork pool marshals dependency records back so the graph is sound.
229
- workers: CheckRunnerFactory.resolve_workers(options, configuration)
220
+ workers: CheckRunnerFactory.resolve_workers(options, configuration),
221
+ buffer: buffer
230
222
  )
231
223
 
224
+ return run_editor_mode_option_b(session, snapshot, fingerprint, configuration, options) if buffer
225
+
232
226
  diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
233
227
  # The banner's file count comes from the session's analyzed set (cold analyses all; a warm recheck's
234
228
  # `@analyzed` advances to the current file set), so a dedicated probe Runner + `Dir.glob` is no longer
@@ -244,6 +238,33 @@ module Rigor
244
238
  result.success? ? 0 : 1
245
239
  end
246
240
 
241
+ # Editor mode option B (#146) — `--incremental` plus an editor buffer. The whole project is in scope with
242
+ # the buffer substituted for one file: the buffer's logical path and its dependents re-analyse, every
243
+ # other file is served from the snapshot. Before this, the two flags together silently ignored the
244
+ # buffer and analysed the file on disk, which is a wrong answer rather than a missing feature.
245
+ #
246
+ # The session never saves — the buffer's bytes exist only in the editor — so there is no warm state to
247
+ # build up. That is why a snapshot it cannot reuse means falling back to option A (single-file scope,
248
+ # the pre-#146 behaviour) instead of running a baseline that would repeat on the next keystroke. The
249
+ # note tells the user how to get option B: warm the snapshot with a plain `rigor check --incremental`.
250
+ def run_editor_mode_option_b(session, snapshot, fingerprint, configuration, options)
251
+ result = session.run_buffer_recheck(snapshot: snapshot, fingerprint: fingerprint)
252
+ if result.nil?
253
+ @err.puts("rigor: --incremental has no reusable snapshot for this project; analysing the buffer " \
254
+ "alone (run `rigor check --incremental` once to enable whole-project editor mode).")
255
+ return nil
256
+ end
257
+
258
+ @err.puts("rigor: --incremental editor mode — re-analysed #{result.affected.size} file(s), " \
259
+ "#{result.reused.size} served from cache")
260
+ emit_incremental_fact_surface_notes(session)
261
+ filtered = apply_baseline_filter(
262
+ Analysis::Result.new(diagnostics: result.diagnostics, stats: nil), configuration, options
263
+ )
264
+ write_result(filtered, options.fetch(:format))
265
+ filtered.success? ? 0 : 1
266
+ end
267
+
247
268
  # ADR-88 WD1 — a one-line stderr note when the plugin fact surface (an ADR-9 fact, an ADR-60 producer
248
269
  # value, or an `incremental_state_fingerprint` hook) forced a full run: either it CHANGED since the
249
270
  # snapshot (a Sorbet sig edit, a schema change) or a contributing plugin declares NO fingerprint surface
@@ -27,6 +27,7 @@ require_relative "fused_protection_report"
27
27
  require_relative "fused_protection_renderer"
28
28
  require_relative "coverage_mutation"
29
29
  require_relative "protection_fork_scan"
30
+ require_relative "mutation_fork_scan"
30
31
  require_relative "check_runner_factory"
31
32
  require_relative "command"
32
33
 
@@ -90,7 +91,7 @@ module Rigor
90
91
  def parse_options
91
92
  options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false,
92
93
  with_tests: false, test_command: DEFAULT_TEST_COMMAND, include_dynamic: false,
93
- limit: nil, seed: 1, workers: nil }
94
+ limit: nil, seed: 1, workers: nil, no_cache: false }
94
95
  OptionParser.new { |opts| define_options(opts, options) }.parse!(@argv)
95
96
  options
96
97
  end
@@ -103,7 +104,8 @@ module Rigor
103
104
  options[:protection] = true
104
105
  end
105
106
  define_mutation_options(opts, options)
106
- opts.on("--workers=N", Integer, "With --protection: fork N workers over the scanned files " \
107
+ opts.on("--workers=N", Integer, "With --protection (with or without --mutation, but not --with-tests, " \
108
+ "which must stay sequential): fork N workers over the scanned files " \
107
109
  "(default: config parallel.workers / RIGOR_RACTOR_WORKERS / 0)") do |v|
108
110
  options[:workers] = v
109
111
  end
@@ -135,6 +137,12 @@ module Rigor
135
137
  options[:limit] = v
136
138
  end
137
139
  opts.on("--seed=N", Integer, "RNG seed for --limit sampling (default 1)") { |v| options[:seed] = v }
140
+ opts.on("--no-cache", "With --mutation: measure every file from scratch, neither reading nor writing " \
141
+ "the per-file mutation-result cache (#134 slice 2). The cold arm of the " \
142
+ "warm==cold gate; unrelated caches (RBS environment, plugin producers) are " \
143
+ "untouched.") do
144
+ options[:no_cache] = true
145
+ end
138
146
  end
139
147
 
140
148
  def mutation_misuse_error
@@ -204,6 +212,11 @@ module Rigor
204
212
  #
205
213
  # Both span the scanned `paths` only (no whole-project pre-pass) — a site that gains neither is classified exactly
206
214
  # as before.
215
+ #
216
+ # Tier 2's analogue is {Protection::DiscoverySeed} (#253, #260): the same idea over a *fuller* table set,
217
+ # because Tier 2 must also let its kill oracle resolve the method on the receiver, not merely name its
218
+ # class. Tier 1 seeds unconditionally because it only reclassifies sites it already counted; Tier 2 adds
219
+ # sites to a denominator `--threshold` gates CI on, which is why only the latter is gated on a feature id.
207
220
  def scope_with_inferred_params(paths, configuration, environment, workers)
208
221
  base = Scope.empty(environment: environment)
209
222
  seed = {}