rigortype 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -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 = {}
@@ -3,6 +3,12 @@
3
3
  require "English"
4
4
  require "prism"
5
5
 
6
+ require_relative "../cache/file_digest"
7
+ require_relative "../protection/closure_kill_oracle"
8
+ require_relative "../protection/dependency_closure"
9
+ require_relative "../protection/discovery_seed"
10
+ require_relative "../protection/mutation_cache"
11
+
6
12
  module Rigor
7
13
  class CLI
8
14
  # ADR-63 Tier 2 + ADR-70 — the mutation-effectiveness and fused static∪dynamic protection paths, factored out of
@@ -10,8 +16,118 @@ module Rigor
10
16
  # (using `@out` / `@err` / `@argv` / `collect_paths` / `determine_protection_exit` and the Protection +
11
17
  # LanguageServer collaborators the command requires).
12
18
  module CoverageMutation
19
+ # ADR-50 § WD2 — the bleeding-edge feature id gating the Tier-2 discovery seed (#253). Named here rather
20
+ # than inlined at the call site: {Configuration#bleeding_edge_active?} raises on an id absent from the
21
+ # registry, so the constant is the single place a rename has to reach.
22
+ DISCOVERY_SEEDED_MUTATION_SITES = "discovery-seeded-mutation-sites"
23
+
24
+ # ADR-50 § WD2 — the bleeding-edge feature id gating the Tier-2 dependent-closure kill oracle (#254).
25
+ # Same reason as above for naming it here rather than inlining the string.
26
+ DEPENDENT_CLOSURE_KILL_ORACLE = "dependent-closure-kill-oracle"
27
+
28
+ # #264 — the "loud" threshold for a rescued-harness-failure count. Below it, a rescued mutant reads as
29
+ # the occasional transient this issue's `harness_errors` bucket exists to make VISIBLE, not to eliminate
30
+ # (see {Protection::MutationScanner#classify}); at or above it, the pattern looks less like noise and
31
+ # more like a harness defect worth stopping to investigate before trusting the ratio. Deliberately NOT
32
+ # the `determine_protection_exit` gate: a `--threshold` build is pinned to the killed/survived ratio
33
+ # today, and turning a harness-side symptom into a new way for that same command to exit non-zero would
34
+ # silently change semantics CI already depends on. A loud stderr warning (plus the unconditional JSON
35
+ # field) is the visibility this issue asks for without redefining what "the build is red" means.
36
+ HARNESS_ERROR_WARN_FLOOR = 3
37
+
38
+ # Issue #134 slice 2 — the bleeding-edge ids whose adoption changes what a Tier-2 measurement REPORTS,
39
+ # and which therefore enter the identity of anything cached about it (#255: a behaviour feature's id is
40
+ # part of the cache identity of everything it changes). Both are named above; this is the ordered set the
41
+ # cache key reads, so a feature added to Tier 2 later has exactly one place to register.
42
+ MUTATION_BEHAVIOUR_FEATURES = [DISCOVERY_SEEDED_MUTATION_SITES, DEPENDENT_CLOSURE_KILL_ORACLE].freeze
43
+
13
44
  private
14
45
 
46
+ # @param report [MutationProtectionReport, FusedProtectionReport] — both expose `total_harness_errors`.
47
+ def warn_harness_errors(report)
48
+ count = report.total_harness_errors
49
+ return if count < HARNESS_ERROR_WARN_FLOOR
50
+
51
+ @err.puts(
52
+ "coverage: #{count} mutants failed inside the measurement harness (\"harness_errors\", " \
53
+ "at/above the #{HARNESS_ERROR_WARN_FLOOR}-mutant floor) — excluded from the ratio like a " \
54
+ "parse-invalid mutant, but this many suggests a harness defect rather than one-off noise. " \
55
+ "Investigate before trusting --threshold on this run."
56
+ )
57
+ end
58
+
59
+ # The cross-file knowledge Tier 2 measures with — the #253 gate, and the ONLY place in this feature that
60
+ # knows a feature id exists.
61
+ #
62
+ # Returns nil (today's behaviour: an unseeded per-file view on both halves of the measurement) unless the
63
+ # project has adopted `discovery-seeded-mutation-sites`. With it adopted, returns the table set
64
+ # {Protection::DiscoverySeed} builds over the scanned `paths` — class identity so a receiver whose class
65
+ # is declared in a *sibling* file resolves instead of reading `Dynamic`, the def / ancestry index so the
66
+ # method on it resolves too, and `param_inferred_types` (ADR-67 WD3) for an inferred-parameter receiver.
67
+ #
68
+ # One table set, two consumers, deliberately (issue #260): site selection admits a site and the kill
69
+ # oracle can act on it. Seeding only the first admitted sites no mutation could ever break.
70
+ #
71
+ # Why the feature is off by default: an unseeded Tier 2 drops those sites from the denominator entirely,
72
+ # so seeding ADDS sites and the effectiveness ratio moves on unchanged code — and `--threshold=RATIO`
73
+ # exits 1 below a ratio users pin in CI. Turning it on by default would turn their build red with no
74
+ # change on their side.
75
+ #
76
+ # Built ONCE here, on the parent, so {MutationForkScan}'s children copy-on-write inherit it inside the
77
+ # scanner. Nothing crosses the marshal boundary: only the per-file results are marshaled back, and
78
+ # neither a Scope nor a `Prism::Node` is among them.
79
+ def mutation_discovery_seed(paths, configuration, environment, workers)
80
+ return nil unless configuration.bleeding_edge_active?(DISCOVERY_SEEDED_MUTATION_SITES)
81
+
82
+ seed = Protection::DiscoverySeed.build(
83
+ paths: paths, environment: environment, target_ruby: configuration.target_ruby, workers: workers
84
+ )
85
+ seed.empty? ? nil : seed
86
+ end
87
+
88
+ # The kill oracle Tier 2 measures with — the #254 gate, and the only place that feature id is read.
89
+ #
90
+ # Returns nil (today's behaviour: {Protection::DiagnosticOracle}, which re-analyses the mutated file
91
+ # alone) unless the project has adopted `dependent-closure-kill-oracle`. With it adopted, returns the
92
+ # {Protection::ClosureKillOracle}, which counts a kill when a new diagnostic appears anywhere in the
93
+ # mutated file's dependent closure — so a mutation whose damage lands in a CALLER is scored as the
94
+ # catch it is.
95
+ #
96
+ # Why the feature is off by default: it moves the reported effectiveness ratio UP on unchanged code
97
+ # (kills rise, the denominator does not), so a number recorded under it is not comparable with one
98
+ # recorded without it — the ADR-50 WD2/WD7 reason for the overlay, even though the direction cannot
99
+ # turn a `--threshold` build red.
100
+ #
101
+ # Both of its inputs are built HERE, once, on the parent — the ADR-46 dependents map (one recording
102
+ # pass) and the per-file discovery bundles — so {MutationForkScan}'s children copy-on-write inherit
103
+ # them and no per-mutant work is repeated per worker. `seed` composes the two features: it is handed
104
+ # to the closure oracle's delegated {Protection::DiagnosticOracle} verbatim, so the mutated file's
105
+ # verdict stays exactly the verdict the other feature's state produces, and this feature only ever
106
+ # ADDS the kills that land in a dependent.
107
+ def mutation_kill_oracle(paths, configuration, context, seed, workers)
108
+ return nil unless configuration.bleeding_edge_active?(DEPENDENT_CLOSURE_KILL_ORACLE)
109
+
110
+ Protection::ClosureKillOracle.new(
111
+ configuration: configuration, environment: context.environment, project_scan: context.project_scan,
112
+ paths: paths,
113
+ dependents: Protection::DependencyClosure.build(
114
+ paths: paths, configuration: configuration, environment: context.environment,
115
+ cache_store: context.cache_store, workers: workers
116
+ ),
117
+ seed_bundles: Protection::DiscoverySeed.bundles(paths: paths),
118
+ discovery_seed: seed
119
+ )
120
+ end
121
+
122
+ # The scope {Protection::Mutator} judges a mutation site's receiver against, derived from the same seed
123
+ # the oracle gets. nil (the gate off, or an empty seed) keeps the bare `Scope.empty` per file.
124
+ def mutation_base_scope(seed, environment)
125
+ return nil if seed.nil?
126
+
127
+ base = Scope.empty(environment: environment)
128
+ base.with_discovery(base.discovery.with(**seed))
129
+ end
130
+
15
131
  # ADR-63 Tier 2 — the mutation-effectiveness deep dive. Builds the RBS environment + project pre-pass once (the
16
132
  # warm loop), then re-analyses each target file's mutants against its clean baseline. Defaults to the git-changed
17
133
  # `.rb` files; explicit paths override (and enable the whole-project opt-in, which is minutes).
@@ -26,9 +142,12 @@ module Rigor
26
142
  end
27
143
 
28
144
  note_sampling(options)
145
+ # `--with-tests` deliberately does NOT take the fork path below: {Protection::TestSuiteOracle} shells
146
+ # out to the project's test runner, and concurrent suite invocations would race over one working tree.
29
147
  return run_fused_protection(target_files, options) if options[:with_tests]
30
148
 
31
149
  report = scan_mutation_protection(target_files, options)
150
+ warn_harness_errors(report)
32
151
  MutationProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
33
152
  determine_protection_exit(report, options)
34
153
  end
@@ -49,22 +168,47 @@ module Rigor
49
168
  # mutant survived" is meaningless — abort with a clear message if not.
50
169
  def run_fused_protection(paths, options)
51
170
  configuration = Configuration.load(options.fetch(:config))
171
+ warn_workers_ignored_under_tests(options)
52
172
  test_oracle = Protection::TestSuiteOracle.new(command: options.fetch(:test_command))
53
173
  return suite_not_green_error(options) unless test_oracle.green?
54
174
 
55
175
  context = LanguageServer::ProjectContext.new(configuration: configuration)
176
+ # The fused path stays sequential end to end (`--workers` is warned about above), so the seed's own
177
+ # parameter-inference pre-pass runs sequentially too rather than quietly re-enabling the forking the
178
+ # warning just said was ignored. It is otherwise the same seed, threaded to the same two consumers, so
179
+ # `--with-tests` measures the same site set as the plain path.
180
+ seed = mutation_discovery_seed(paths, configuration, context.environment, 0)
56
181
  scanner = Protection::MutationScanner.new(
57
182
  configuration: configuration, environment: context.environment, project_scan: context.project_scan,
58
183
  limit: options[:limit], seed: options[:seed],
59
- site_selector: options[:include_dynamic] ? :all : :biteable
184
+ site_selector: options[:include_dynamic] ? :all : :biteable,
185
+ base_scope: mutation_base_scope(seed, context.environment), discovery_seed: seed,
186
+ # #254 — the type half of the fused measurement is the same measurement, so the closure oracle
187
+ # applies here too when adopted: a `--with-tests` run must not disagree with the plain run about
188
+ # which mutants the TYPE axis caught, or the "add a type OR a test" verdict would depend on which
189
+ # command you ran. Sequential like the rest of this path (`--workers` is warned about above).
190
+ oracle: mutation_kill_oracle(paths, configuration, context, seed, 0)
60
191
  )
61
192
  accumulator = FusedProtectionAccumulator.new
62
193
  paths.each { |path| scan_fused_one(path, scanner, accumulator, test_oracle, configuration) }
63
194
  report = accumulator.to_report
195
+ warn_harness_errors(report)
64
196
  FusedProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
65
197
  determine_protection_exit(report, options)
66
198
  end
67
199
 
200
+ # An explicit `--workers=N` on the fused path cannot be honoured, so say so rather than repeat the bug
201
+ # this slice fixed (a flag accepted and silently dropped). Only the explicit flag warns — a project-wide
202
+ # `parallel.workers:` or `RIGOR_RACTOR_WORKERS` default is not a request about *this* run.
203
+ def warn_workers_ignored_under_tests(options)
204
+ return unless options[:workers].to_i > 1
205
+
206
+ @err.puts(
207
+ "coverage: --workers is ignored with --with-tests — the test-suite oracle shells out to " \
208
+ "#{options.fetch(:test_command).join(' ')}, and parallel runs would race."
209
+ )
210
+ end
211
+
68
212
  def scan_fused_one(path, scanner, accumulator, test_oracle, configuration)
69
213
  source = File.read(path)
70
214
  parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
@@ -89,28 +233,115 @@ module Rigor
89
233
  1
90
234
  end
91
235
 
236
+ # Builds the RBS environment + whole-project pre-pass ONCE (≈6% of a 45-file run), then fork-maps the
237
+ # per-file measurement — the ≈94% that is `Σ(1 + N_f)` single-file analyses — across the resolved worker
238
+ # count (#134 slice 1). {MutationForkScan} returns `{path => result}` and the parent absorbs in `paths`
239
+ # order, so the report is byte-identical to a sequential run whatever order the workers finished in.
240
+ #
241
+ # #134 slice 2 — the files whose measurement is still valid are served from {Protection::MutationCache}
242
+ # and never reach a worker at all; only the rest are forked over, and their fresh results are written
243
+ # back. The cache read + write both happen on the parent, so the workers stay the pure-read, store-free
244
+ # processes {MutationForkScan} documents.
92
245
  def scan_mutation_protection(paths, options)
93
246
  configuration = Configuration.load(options.fetch(:config))
94
247
  context = LanguageServer::ProjectContext.new(configuration: configuration)
248
+ workers = CheckRunnerFactory.resolve_workers(options, configuration)
249
+ seed = mutation_discovery_seed(paths, configuration, context.environment, workers)
95
250
  scanner = Protection::MutationScanner.new(
96
251
  configuration: configuration, environment: context.environment, project_scan: context.project_scan,
97
- limit: options[:limit], seed: options[:seed]
252
+ limit: options[:limit], seed: options[:seed],
253
+ base_scope: mutation_base_scope(seed, context.environment), discovery_seed: seed,
254
+ oracle: mutation_kill_oracle(paths, configuration, context, seed, workers)
98
255
  )
99
- accumulator = MutationProtectionAccumulator.new
256
+ cache = mutation_result_cache(paths, options, configuration, context, seed)
257
+ measure_mutation_files(paths, cache: cache, scanner: scanner, context: context,
258
+ configuration: configuration, workers: workers)
259
+ end
260
+
261
+ # The cached / freshly-measured split, absorbed in `paths` order so the report is byte-identical however
262
+ # the two sets were assembled.
263
+ #
264
+ # The two cache phases each get their OWN {Cache::FileDigest.with_run} scope and the MEASUREMENT sits
265
+ # between them, deliberately: that scope installs a per-path digest memo, and {Protection::ClosureKillOracle}
266
+ # rewrites one process-private temp file per mutant and digests it through a buffer binding. A memo
267
+ # spanning the measurement would hand every mutant after the first the FIRST one's digest, the mutated
268
+ # file's discovery bundle would never be re-walked, and the run would report zero cross-file kills — a
269
+ # plausible-looking number rather than an error. The oracle's own comment states the same invariant from
270
+ # the other side. The cost of two scopes is one extra SHA-256 per cached file, against a measurement
271
+ # that is hundreds of analyses.
272
+ def measure_mutation_files(paths, cache:, scanner:, context:, configuration:, workers:)
273
+ cached = with_digest_run(configuration) { paths.to_h { |path| [path, cache.fetch(path)] }.compact }
274
+ pending = paths - cached.keys
275
+ fresh = if pending.empty?
276
+ {}
277
+ else
278
+ MutationForkScan.run(paths: pending, scanner: scanner, environment: context.environment,
279
+ configuration: configuration, workers: workers)
280
+ end
281
+ # `fetch`, never `[]`: a worker that died mid-slice must abort the run rather than quietly drop files
282
+ # out of a ratio that `--threshold` gates CI on.
283
+ with_digest_run(configuration) { pending.each { |path| cache.store(path, fresh.fetch(path)) } }
284
+ report_mutation_cache(cache, measured: pending.size, served: cached.size)
285
+ absorb_measured_files(paths, cached, fresh)
286
+ end
100
287
 
101
- paths.each { |path| scan_mutation_one(path, scanner, accumulator, configuration) }
288
+ def absorb_measured_files(paths, cached, fresh)
289
+ accumulator = MutationProtectionAccumulator.new
290
+ paths.each do |path|
291
+ absorb_mutation_result(accumulator, path, cached.fetch(path) { fresh.fetch(path) })
292
+ end
102
293
  accumulator.to_report
103
294
  end
104
295
 
105
- def scan_mutation_one(path, scanner, accumulator, configuration)
106
- source = File.read(path)
107
- parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
108
- if parse_result.errors.any?
109
- accumulator.record_parse_error(path, parse_result.errors)
110
- return
296
+ # ADR-87 WD1's per-run digest scope, so the cache's own freshness checks honour `cache.validation:`
297
+ # (and `RIGOR_STRICT_VALIDATION`) exactly as every other record-and-validate cache does.
298
+ def with_digest_run(configuration, &)
299
+ Cache::FileDigest.with_run(strict: configuration.cache_validation_strict?, &)
300
+ end
301
+
302
+ # The per-file result cache (#134 slice 2), or a disabled one. Two callers-side bypasses are decided
303
+ # here rather than inside the cache: `--no-cache`, and the `dependent-closure-kill-oracle` overlay —
304
+ # under that oracle a file's verdict depends on its DEPENDENTS' diagnostics, so validity would need the
305
+ # dependencies of every dependent rather than `deps[A]`, and the feature is presumptively non-graduating
306
+ # (#254). A bypass is sound and honest; a key that pretended otherwise would not be.
307
+ def mutation_result_cache(paths, options, configuration, context, seed)
308
+ Protection::MutationCache.build(
309
+ configuration: configuration, roots: @argv, project_scan: context.project_scan,
310
+ sampling: Protection::MutationCache::Sampling.new(
311
+ limit: options[:limit], seed: options[:seed],
312
+ site_selector: options[:include_dynamic] ? :all : :biteable
313
+ ),
314
+ feature_ids: MUTATION_BEHAVIOUR_FEATURES.select { |id| configuration.bleeding_edge_active?(id) },
315
+ seed_inputs: seed.nil? ? nil : paths,
316
+ bypass_reason: mutation_cache_bypass(options, configuration)
317
+ )
318
+ end
319
+
320
+ def mutation_cache_bypass(options, configuration)
321
+ return "--no-cache" if options[:no_cache]
322
+ return DEPENDENT_CLOSURE_KILL_ORACLE if configuration.bleeding_edge_active?(DEPENDENT_CLOSURE_KILL_ORACLE)
323
+
324
+ nil
325
+ end
326
+
327
+ # One stderr line saying what the cache did — stdout stays clean for JSON. Always printed, because "the
328
+ # cache quietly stopped working" and "the cache quietly served a stale number" are indistinguishable
329
+ # from the outside otherwise; the slice-3 gate reads this line to prove itself non-vacuous.
330
+ def report_mutation_cache(cache, measured:, served:)
331
+ if cache.enabled?
332
+ @err.puts("coverage: mutation cache — re-measured #{measured} file(s), #{served} served from cache.")
333
+ else
334
+ @err.puts("coverage: mutation cache disabled (#{cache.reason}) — " \
335
+ "re-measured #{measured} file(s).")
111
336
  end
337
+ end
112
338
 
113
- accumulator.absorb(scanner.scan_file(path, source: source))
339
+ def absorb_mutation_result(accumulator, path, result)
340
+ if result.is_a?(MutationForkScan::ParseError)
341
+ accumulator.record_parse_error_count(path, result.count)
342
+ else
343
+ accumulator.absorb(result)
344
+ end
114
345
  end
115
346
 
116
347
  # The git-changed (modified / added / untracked) `.rb` files that exist on disk — the default Tier 2 scope.
@@ -33,10 +33,20 @@ module Rigor
33
33
  @out.puts " by type: #{report.total_type_killed}"
34
34
  @out.puts " by test: #{report.total_test_killed} (type-survivors a test caught)"
35
35
  @out.puts " unprotected: #{report.total_unprotected} (neither — add a type or a test)"
36
+ render_harness_errors(report)
36
37
  render_unprotected(report)
37
38
  render_files(report)
38
39
  end
39
40
 
41
+ # #264 — see {MutationProtectionRenderer#render_harness_errors}; surfaced only when non-zero.
42
+ def render_harness_errors(report)
43
+ count = report.total_harness_errors
44
+ return if count.zero?
45
+
46
+ @out.puts " harness errors: #{count} mutant(s) failed inside the measurement harness " \
47
+ "(excluded from the ratio — see --format=json's \"harness_errors\")"
48
+ end
49
+
40
50
  def render_unprotected(report)
41
51
  unprotected = report.unprotected
42
52
  return if unprotected.empty?
@@ -9,7 +9,12 @@ module Rigor
9
9
  #
10
10
  # Framing (ADR-63 / ADR-62 Criterion A, extended): the payload is the **attribution** — which protection axis is
11
11
  # missing — never raw survival. An unprotected site is "add protection here", never "your code is broken".
12
- FusedFileProtection = Data.define(:path, :type_killed, :test_killed, :unprotected, :ratio)
12
+ # `harness_errors` (#264) see {Rigor::CLI::FileEffectiveness}; defaults to 0 for the same reason.
13
+ FusedFileProtection = Data.define(:path, :type_killed, :test_killed, :unprotected, :ratio, :harness_errors) do
14
+ def initialize(path:, type_killed:, test_killed:, unprotected:, ratio:, harness_errors: 0)
15
+ super
16
+ end
17
+ end
13
18
  UnprotectedBreakage = Data.define(:method_name, :count, :examples)
14
19
 
15
20
  FusedProtectionReport = Data.define(:files, :unprotected, :parse_errors) do
@@ -20,6 +25,9 @@ module Rigor
20
25
  def protected_total = total_type_killed + total_test_killed
21
26
  def ratio = grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
22
27
 
28
+ # #264 — stays OUT of `grand_total`/`ratio`, exactly like the plain mutation report.
29
+ def total_harness_errors = files.sum(&:harness_errors)
30
+
23
31
  def to_h
24
32
  {
25
33
  "mode" => "protection-fused",
@@ -27,9 +35,10 @@ module Rigor
27
35
  "test_killed" => total_test_killed,
28
36
  "unprotected" => total_unprotected,
29
37
  "protected_ratio" => ratio.round(4),
38
+ "harness_errors" => total_harness_errors,
30
39
  "files" => files.map do |f|
31
40
  { "path" => f.path, "type_killed" => f.type_killed, "test_killed" => f.test_killed,
32
- "unprotected" => f.unprotected, "ratio" => f.ratio.round(4) }
41
+ "unprotected" => f.unprotected, "ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
33
42
  end,
34
43
  "add_protection_here" => unprotected.map do |m|
35
44
  { "method" => m.method_name, "count" => m.count, "examples" => m.examples }
@@ -50,7 +59,7 @@ module Rigor
50
59
  @files << FusedFileProtection.new(
51
60
  path: file_result.path, type_killed: file_result.type_killed,
52
61
  test_killed: file_result.test_killed, unprotected: file_result.unprotected,
53
- ratio: file_result.ratio
62
+ ratio: file_result.ratio, harness_errors: file_result.harness_errors
54
63
  )
55
64
  file_result.sites.each do |site|
56
65
  bucket = @unprotected[site.method_name]
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../inference/fork_map"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # Fork-pool for the `rigor coverage --protection --mutation` scan (#134 slice 1) — the Tier-2 analogue of
10
+ # {ProtectionForkScan}. The expensive shared state is built ONCE on the parent (the RBS environment, the
11
+ # plugin registry, and the whole-project pre-pass {Analysis::ProjectScan}, all held by the
12
+ # {Protection::MutationScanner}'s oracle) and children copy-on-write inherit it via {Inference::ForkMap},
13
+ # each measuring a contiguous slice of paths.
14
+ #
15
+ # Why this is safe to parallelise where Tier 1 already is: a Tier-2 per-file measurement is pure-read
16
+ # against that shared state — every mutant is analysed through `Runner.new(prebuilt:)#run_source`, an
17
+ # in-memory overlay that writes nothing to disk and, because `prebuilt:` disables the run-result cache, has
18
+ # no cache store to race on. The returned {Protection::MutationScanner::FileResult} is a Data of primitives
19
+ # (and `SurvivingSite` likewise), so it marshals back verbatim.
20
+ #
21
+ # Determinism: {MutationProtectionAccumulator#absorb} is order-dependent (the per-file list and the
22
+ # "add a type here" example paths follow absorption order), so the parent MUST iterate the original `paths`
23
+ # and `fetch` each result rather than consume completion order. A missing key then raises instead of
24
+ # silently under-reporting an effectiveness ratio a `--threshold` gate reads.
25
+ #
26
+ # NOT used by the fused `--with-tests` path: {Protection::TestSuiteOracle} shells out to the project's test
27
+ # runner, and concurrent suite invocations would race over the same working tree.
28
+ module MutationForkScan
29
+ module_function
30
+
31
+ # A parse failure for one file, carrying only the marshalable error count (Prism error objects are not
32
+ # reliably marshalable, and the accumulator only needs the count).
33
+ ParseError = Data.define(:count)
34
+
35
+ # @param paths [Array<String>] the files to measure, in caller order.
36
+ # @param scanner [Protection::MutationScanner] built on the parent; COW-inherited by workers.
37
+ # @param environment [Rigor::Environment] the scanner's environment, prewarmed here before forking.
38
+ # @param configuration [Rigor::Configuration] for the Prism `target_ruby` version.
39
+ # @param workers [Integer] resolved worker count (≤1 → sequential).
40
+ # @return [Hash{String => Protection::MutationScanner::FileResult, ParseError}] one entry per path.
41
+ def run(paths:, scanner:, environment:, configuration:, workers:)
42
+ # Force the full RBS load on the parent so children copy-on-write inherit a warm environment rather
43
+ # than each rebuilding it after the fork. A no-op on the sequential path but cheap.
44
+ environment.rbs_loader&.prewarm if Inference::ForkMap.parallel?([workers, paths.size].min)
45
+
46
+ payloads = Inference::ForkMap.call(items: paths, workers: workers) do |slice|
47
+ slice.to_h { |path| [path, scan_path(path, scanner, configuration)] }
48
+ end
49
+ payloads.each_with_object({}) { |slice_results, merged| merged.merge!(slice_results) }
50
+ end
51
+
52
+ # Measures one file and returns the scanner's {Protection::MutationScanner::FileResult}, or a
53
+ # {ParseError} when the source does not parse — the same read → parse → guard → scan order the sequential
54
+ # loop used, so a broken file is reported identically either side of the fork.
55
+ def scan_path(path, scanner, configuration)
56
+ source = File.read(path)
57
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
58
+ return ParseError.new(count: parse_result.errors.size) if parse_result.errors.any?
59
+
60
+ scanner.scan_file(path, source: source)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -30,10 +30,22 @@ module Rigor
30
30
  @out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
31
31
  @out.puts " caught breakages: #{report.total_killed} / #{report.grand_total} (#{pct}%)"
32
32
  @out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
33
+ render_harness_errors(report)
33
34
  render_missed(report)
34
35
  render_files(report)
35
36
  end
36
37
 
38
+ # #264 — surfaced only when non-zero: a harness-level failure is a defect in the measurement itself, not
39
+ # in the code being measured, and a clean run should not carry a permanent line about a bucket that is
40
+ # always empty.
41
+ def render_harness_errors(report)
42
+ count = report.total_harness_errors
43
+ return if count.zero?
44
+
45
+ @out.puts " harness errors: #{count} mutant(s) failed inside the measurement harness " \
46
+ "(excluded from the ratio — see --format=json's \"harness_errors\")"
47
+ end
48
+
37
49
  def render_missed(report)
38
50
  missed = report.missed
39
51
  return if missed.empty?