rigortype 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # The one place a CLI command turns a loaded {Configuration} into an {Analysis::Result}.
6
+ #
7
+ # {CheckRunnerFactory} builds the runner; this module owns the *invocation* wrapped around it — resolving the
8
+ # analysed paths, running, and handing back both the runner (`rigor check`'s reporting tail still needs its cache
9
+ # store and stats) and the raw result. It exists so a non-`check` command — `rigor doctor`, `rigor skill describe
10
+ # --deep` — reaches a check result through the same code path `rigor check` walks, instead of re-deriving the
11
+ # plumbing (issue #148; ADR-73 § "Field-trial follow-ups" names the shared helper as the prerequisite).
12
+ #
13
+ # Nothing here is required at load time: {CheckRunnerFactory} pulls the inference engine, so both entry points
14
+ # require it lazily. A command that merely *mentions* this module therefore stays engine-free until it actually
15
+ # runs an analysis — the property ADR-87 WD4 protects for `rigor check`'s cache-hit fast path, and the property
16
+ # that keeps an un-flagged `rigor skill describe` from loading the engine at all.
17
+ module CheckInvocation
18
+ # A completed analysis. `runner` is exposed because `rigor check`'s tail reads its `cache_store` (eviction,
19
+ # `--cache-stats`) and feeds it to `--coverage`; `result` is the raw, pre-baseline-filter {Analysis::Result}.
20
+ Invocation = Data.define(:runner, :result)
21
+
22
+ # The outcome of {.attempt} — the best-effort variant. Exactly one of the two is non-nil, so a caller can never
23
+ # read "the check could not run" as "the check ran clean": `result` is nil precisely when `error` is set.
24
+ Outcome = Data.define(:result, :error) do
25
+ # @return [Boolean] true when an analysis actually completed.
26
+ def ran?
27
+ !result.nil?
28
+ end
29
+ end
30
+
31
+ # Options for a caller that wants a check *result* rather than a check *run*: no cache writes, no explain
32
+ # traces, sequential. `stats: true` because the RBS-environment signal (`stats.rbs_classes_total`) lives there.
33
+ # This is `rigor doctor`'s preset — a diagnostic command that must not churn the project's cache.
34
+ READ_ONLY_OPTIONS = { no_cache: true, explain: false, stats: true, workers: 0 }.freeze
35
+
36
+ # Options for an opt-in deep probe (`rigor skill describe --deep`). Unlike {READ_ONLY_OPTIONS} this uses the
37
+ # configured cache and worker count, i.e. it behaves exactly like `rigor check` — fast on a warm cache, and it
38
+ # WRITES `.rigor/cache`. That side effect is the whole reason the flag is opt-in.
39
+ DEEP_OPTIONS = { no_cache: false, explain: false, stats: true, workers: nil }.freeze
40
+
41
+ module_function
42
+
43
+ # Runs the analysis the way `rigor check` does and returns the runner + its raw result.
44
+ #
45
+ # @param configuration [Rigor::Configuration]
46
+ # @param options [Hash] at least `:no_cache`, `:explain`, `:stats`, `:workers` (see {CheckRunnerFactory.build}).
47
+ # @param paths [Array<String>, nil] analysed paths; nil falls back to the configuration's `paths:`.
48
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
49
+ # @param cache_root [String, nil] nil falls back to the configuration's cache path.
50
+ # @return [Invocation]
51
+ def run(configuration:, options:, paths: nil, buffer: nil, cache_root: nil)
52
+ require_relative "check_runner_factory"
53
+ runner = CheckRunnerFactory.build(
54
+ configuration: configuration,
55
+ options: options,
56
+ buffer: buffer,
57
+ cache_root: cache_root || configuration.cache_path
58
+ )
59
+ Invocation.new(runner: runner, result: runner.run(paths || configuration.paths))
60
+ end
61
+
62
+ # Best-effort variant for a *routing* caller: one that wants to sharpen a recommendation with check evidence and
63
+ # has a perfectly good answer when there is none. Loads the configuration itself and converts every way the run
64
+ # can fail outright — an unreadable / invalid config, an unloadable plugin, an engine error — into an {Outcome}
65
+ # carrying a short human-readable `error`. It never raises and never fabricates a clean result.
66
+ #
67
+ # Deliberately broad: the caller's contract is "degrade to the presence-only answer, whatever went wrong". A
68
+ # routing hint is outside the false-positive envelope, but a crash in it is not — `describe` must stay a command
69
+ # an agent can run freely.
70
+ #
71
+ # @param config_path [String, nil] path to the config file; nil uses {Configuration.discover}.
72
+ # @param options [Hash] runner options (defaults to {DEEP_OPTIONS}).
73
+ # @param paths [Array<String>, nil]
74
+ # @return [Outcome]
75
+ def attempt(config_path: nil, options: DEEP_OPTIONS, paths: nil)
76
+ require_relative "../configuration"
77
+ configuration = Configuration.load(config_path)
78
+ Outcome.new(result: run(configuration: configuration, options: options, paths: paths).result, error: nil)
79
+ rescue StandardError, LoadError => e
80
+ Outcome.new(result: nil, error: "#{e.class}: #{e.message.to_s.lines.first.to_s.strip}")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -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.
@@ -12,7 +12,7 @@ require_relative "../plugin/loader"
12
12
  require_relative "../plugin/services"
13
13
  require_relative "../reflection"
14
14
  require_relative "../type/combinator"
15
- require_relative "check_runner_factory"
15
+ require_relative "check_invocation"
16
16
  require_relative "command"
17
17
  require_relative "options"
18
18
 
@@ -53,14 +53,12 @@ module Rigor
53
53
  findings.concat(audit_config(configuration))
54
54
 
55
55
  # 2. Run a scoped analysis to gather stats + diagnostics for the
56
- # deeper checks. Use no-cache so the probe doesn't churn disk.
57
- runner = CheckRunnerFactory.build(
56
+ # deeper checks, through the shared check-invocation entry point (#148).
57
+ # `READ_ONLY_OPTIONS` is no-cache so the probe doesn't churn disk.
58
+ result = CheckInvocation.run(
58
59
  configuration: configuration,
59
- options: { no_cache: true, explain: false, stats: true, workers: 0 },
60
- buffer: nil,
61
- cache_root: configuration.cache_path
62
- )
63
- result = runner.run(configuration.paths)
60
+ options: CheckInvocation::READ_ONLY_OPTIONS
61
+ ).result
64
62
 
65
63
  # 3. RBS environment check.
66
64
  findings.concat(check_rbs_environment(result))
@@ -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?