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.
- checksums.yaml +4 -4
- data/README.md +10 -10
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +5 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- data/docs/manual/02-cli-reference.md +71 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules.rb +45 -6
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/run_cache_key.rb +17 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +78 -7
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- data/lib/rigor/cli/check_command.rb +49 -28
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +28 -13
- data/lib/rigor/inference/expression_typer.rb +15 -0
- data/lib/rigor/inference/fork_map.rb +9 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +58 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +78 -27
- data/lib/rigor/inference/statement_evaluator.rb +40 -2
- data/lib/rigor/language_server/buffer_table.rb +22 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +20 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +52 -8
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +12 -0
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/inference/void_origin.rbs +6 -3
- data/sig/rigor/scope.rbs +8 -0
- 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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|