rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -217,24 +217,23 @@ module Rigor
217
217
  # class. Tier 1 seeds unconditionally because it only reclassifies sites it already counted; Tier 2 adds
218
218
  # sites to a denominator `--threshold` gates CI on, which is why only the latter is gated on a feature id.
219
219
  def scope_with_inferred_params(paths, configuration, environment, workers)
220
- base = Scope.empty(environment: environment)
221
- seed = {}
222
-
223
- discovered = Inference::ScopeIndexer.discovered_classes_for_paths(paths)
224
- seed[:discovered_classes] = discovered unless discovered.empty?
225
-
226
- table = Inference::ParameterInferenceCollector.collect(
227
- files: paths, environment: environment, target_ruby: configuration.target_ruby, workers: workers
220
+ CoverageScan.discovery_seeded_scope(
221
+ files: paths, configuration: configuration, environment: environment,
222
+ parameter_inference: true, workers: workers
228
223
  )
229
- seed[:param_inferred_types] = table unless table.empty?
230
-
231
- return base if seed.empty?
232
-
233
- base.with_discovery(base.discovery.with(**seed))
234
224
  end
235
225
 
226
+ # Issue #686 — an unmeasured file fails the build the way a parse error already does, and for the same
227
+ # reason: the command could not measure part of what it was asked about, so its ratio is an answer to
228
+ # a smaller question than the user asked. Deliberately NOT routed through `--threshold`, which the
229
+ # line below applies to a ratio: a crashed file's mutants leave `killed + survived` at zero, the
230
+ # project ratio is then computed over the files that DID work, and a run that measured nothing at all
231
+ # came out at 1.0 and passed every threshold. It is also deliberately not the `harness_errors` count,
232
+ # which #264 keeps out of the exit code on purpose — a few rescued mutants are the transient that
233
+ # bucket exists to make visible, while a wholly unmeasured FILE is not a transient.
236
234
  def determine_protection_exit(report, options)
237
235
  return 1 unless report.parse_errors.empty?
236
+ return 1 if unmeasured_files_in(report).positive?
238
237
 
239
238
  threshold = options[:threshold]
240
239
  return 0 if threshold.nil?
@@ -242,6 +241,12 @@ module Rigor
242
241
  report.ratio < threshold ? 1 : 0
243
242
  end
244
243
 
244
+ # Issue #686 — the one reader of the count, shared with the stderr warning. The Tier-1 protection
245
+ # report has no such notion, so a report without the field reads as zero rather than erroring.
246
+ def unmeasured_files_in(report)
247
+ report.respond_to?(:unmeasured_files) ? report.unmeasured_files : 0
248
+ end
249
+
245
250
  def usage_error
246
251
  @err.puts("coverage: at least one path is required")
247
252
  @err.puts(USAGE)
@@ -254,11 +259,11 @@ module Rigor
254
259
 
255
260
  # The protection scan must see the same receiver types `rigor check` does — including plugin-contributed
256
261
  # `dynamic_return` types (a controller's `params` → `ActionController::Parameters`, a `Model.where` →
257
- # `ActiveRecord::Relation[Model]`). The bare `CoverageScan.project_environment` carries only the RBS environment
258
- # (no plugin registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as
259
- # *unprotected* — a systematic undercount of what Rigor actually types on a plugin-using project.
260
- # `ProjectContext` builds the plugin-aware environment (registry materialised + the per-run prepare pass that
261
- # primes producers like the controller / model index) exactly as the LSP and the runner do.
262
+ # `ActiveRecord::Relation[Model]`). A bare RBS environment carries no plugin registry, so every plugin-typed
263
+ # receiver reads `Dynamic` and its dispatch site is miscounted as *unprotected* — a systematic undercount of
264
+ # what Rigor actually types on a plugin-using project. `ProjectContext` builds the plugin-aware environment
265
+ # (registry materialised + the per-run prepare pass that primes producers like the controller / model index)
266
+ # exactly as the LSP, the runner, and since #513 the precision path all do.
262
267
  def plugin_aware_environment(configuration)
263
268
  LanguageServer::ProjectContext.new(configuration: configuration).environment
264
269
  end
@@ -9,6 +9,8 @@ require_relative "../protection/dependency_closure"
9
9
  require_relative "../protection/discovery_seed"
10
10
  require_relative "../protection/mutation_cache"
11
11
 
12
+ require_relative "measurement_integrity_warning"
13
+
12
14
  module Rigor
13
15
  class CLI
14
16
  # ADR-63 Tier 2 + ADR-70 — the mutation-effectiveness and fused static∪dynamic protection paths, factored out of
@@ -43,17 +45,13 @@ module Rigor
43
45
 
44
46
  private
45
47
 
46
- # @param report [MutationProtectionReport, FusedProtectionReport] both expose `total_harness_errors`.
48
+ # Both measurement-integrity warnings, and the escalation rule between them, live in
49
+ # {MeasurementIntegrityWarning}. Only the floor stays here: it is this command's policy, and
50
+ # `coverage_command_spec` reads it off this module.
51
+ #
52
+ # @param report [MutationProtectionReport, FusedProtectionReport]
47
53
  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
- )
54
+ MeasurementIntegrityWarning.emit(report, err: @err, floor: HARNESS_ERROR_WARN_FLOOR)
57
55
  end
58
56
 
59
57
  # The cross-file knowledge Tier 2 measures with — the #253 gate, and the ONLY place in this feature that
@@ -262,13 +260,11 @@ module Rigor
262
260
  # the two sets were assembled.
263
261
  #
264
262
  # 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.
263
+ # between them, deliberately: that scope installs a per-path digest memo. {Protection::ClosureKillOracle}
264
+ # now writes each mutant to a fresh block-scoped path, so a memo spanning the measurement could no
265
+ # longer serve one mutant another's digest but keeping the measurement outside any memo scope stays
266
+ # correct by construction rather than by the oracle's path discipline, and costs only one extra
267
+ # SHA-256 per cached file against a measurement that is hundreds of analyses.
272
268
  def measure_mutation_files(paths, cache:, scanner:, context:, configuration:, workers:)
273
269
  cached = with_digest_run(configuration) { paths.to_h { |path| [path, cache.fetch(path)] }.compact }
274
270
  pending = paths - cached.keys
@@ -4,7 +4,11 @@ require "prism"
4
4
 
5
5
  require_relative "../configuration"
6
6
  require_relative "../environment"
7
+ require_relative "../inference/parameter_inference_collector"
7
8
  require_relative "../inference/precision_scanner"
9
+ require_relative "../inference/scope_indexer"
10
+ require_relative "../language_server/project_context"
11
+ require_relative "../protection/discovery_seed"
8
12
  require_relative "../scope"
9
13
  require_relative "coverage_report"
10
14
 
@@ -20,19 +24,56 @@ module Rigor
20
24
  # @param files [Array<String>] explicit `.rb` file paths to scan.
21
25
  # @param configuration [Rigor::Configuration]
22
26
  # @return [Rigor::CLI::CoverageReport]
27
+ #
28
+ # Issue #513 — the environment is PLUGIN-AWARE (`LanguageServer::ProjectContext`, the same builder
29
+ # `--protection` uses), not the bare RBS environment this path carried until 2026-09-01. Without the
30
+ # plugin registry every plugin-typed receiver read `Dynamic` and the precision ratio understated the
31
+ # engine by ~1.2 points on a Rails target — the defect the protection path documented and fixed for
32
+ # itself on 2026-07-04, never applied here.
23
33
  def precision_report(files:, configuration:)
24
- scope = Scope.empty(environment: project_environment(configuration))
34
+ scope = discovery_seeded_scope(
35
+ files: files,
36
+ configuration: configuration,
37
+ environment: LanguageServer::ProjectContext.new(configuration: configuration).environment,
38
+ parameter_inference: configuration.parameter_inference
39
+ )
25
40
  scanner = Inference::PrecisionScanner.new(scope: scope)
26
41
  accumulator = CoverageAccumulator.new
27
42
  files.each { |path| scan_into(path, scanner, accumulator, configuration) }
28
43
  accumulator.to_report(files, {})
29
44
  end
30
45
 
31
- def project_environment(configuration)
32
- Environment.for_project(
33
- libraries: configuration.libraries,
34
- signature_paths: configuration.signature_paths
35
- )
46
+ # The cross-file facts a scan must see to report the types the engine actually infers, rather than the types a
47
+ # file-at-a-time walk can reach on its own.
48
+ #
49
+ # Issue #513 — the seed is the FULL check-walk bundle (`Protection::DiscoverySeed.discovery_tables`: classes,
50
+ # def nodes and sources for both kinds, superclasses, includes, visibilities, methods, and the Data / Struct
51
+ # member layouts), not just `discovered_classes`. The two-table seed #505 introduced closed the biggest part of
52
+ # the gap and left the rest: without the def-node / ancestry tables every cross-file call to a source-inferred
53
+ # project method measured as unresolved while `rigor check` resolves it — +0.27pp on redmine, +0.77pp on
54
+ # mastodon from the tables alone.
55
+ #
56
+ # `parameter_inference` is a parameter rather than a config read because the two callers want different answers:
57
+ # the precision lens must mirror the walk it describes (`check` leaves the table empty unless
58
+ # `parameter_inference:` is on, ADR-67 WD6a), while `--protection` seeds it unconditionally by ADR-67's own
59
+ # wiring — it only ever reclassifies sites it already counted.
60
+ #
61
+ # @return [Rigor::Scope]
62
+ def discovery_seeded_scope(files:, configuration:, environment:, parameter_inference:, workers: nil)
63
+ base = Scope.empty(environment: environment)
64
+ seed = Protection::DiscoverySeed.discovery_tables(files).dup
65
+
66
+ if parameter_inference
67
+ table = Inference::ParameterInferenceCollector.collect(
68
+ files: files, environment: environment, target_ruby: configuration.target_ruby,
69
+ workers: workers || configuration.parallel_workers
70
+ )
71
+ seed[:param_inferred_types] = table unless table.empty?
72
+ end
73
+
74
+ return base if seed.empty?
75
+
76
+ base.with_discovery(base.discovery.with(**seed))
36
77
  end
37
78
 
38
79
  # Parses one file and feeds the scan result (or a parse-error record) into `accumulator`. `scanner` /
@@ -27,17 +27,34 @@ module Rigor
27
27
  end
28
28
 
29
29
  def render_text(report)
30
- pct = (report.ratio * 100).round(1)
30
+ pct = report.ratio ? (report.ratio * 100).round(1) : nil
31
31
  @out.puts "Fused protection (static type ∪ dynamic test)"
32
- @out.puts " protected: #{report.protected_total} / #{report.grand_total} (#{pct}%)"
32
+ @out.puts " protected: #{report.protected_total} / #{report.grand_total}#{ratio_suffix(pct)}"
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_unmeasured_files(report)
36
37
  render_harness_errors(report)
37
38
  render_unprotected(report)
38
39
  render_files(report)
39
40
  end
40
41
 
42
+ # Issue #686 — see {MutationProtectionRenderer#ratio_suffix}.
43
+ def ratio_suffix(pct)
44
+ return " (not measured)" if pct.nil?
45
+
46
+ " (#{pct}%)"
47
+ end
48
+
49
+ # Issue #686 — see {MutationProtectionRenderer#render_unmeasured_files}.
50
+ def render_unmeasured_files(report)
51
+ count = report.unmeasured_files
52
+ return if count.zero?
53
+
54
+ @out.puts " unmeasured files: #{count} — every mutation of them failed inside the measurement " \
55
+ "harness, so they are not in the ratio above"
56
+ end
57
+
41
58
  # #264 — see {MutationProtectionRenderer#render_harness_errors}; surfaced only when non-zero.
42
59
  def render_harness_errors(report)
43
60
  count = report.total_harness_errors
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../protection/measurement_integrity"
4
+
3
5
  module Rigor
4
6
  class CLI
5
7
  # ADR-70 — aggregates per-file {Protection::MutationScanner::FusedFileResult} into a project-level **fused**
@@ -14,6 +16,13 @@ module Rigor
14
16
  def initialize(path:, type_killed:, test_killed:, unprotected:, ratio:, harness_errors: 0)
15
17
  super
16
18
  end
19
+
20
+ # Issue #686 — see {Rigor::CLI::FileEffectiveness#unmeasured?}.
21
+ def unmeasured?
22
+ !Protection::MeasurementIntegrity.measured?(
23
+ total: type_killed + test_killed + unprotected, harness_errors: harness_errors
24
+ )
25
+ end
17
26
  end
18
27
  UnprotectedBreakage = Data.define(:method_name, :count, :examples)
19
28
 
@@ -23,19 +32,31 @@ module Rigor
23
32
  def total_unprotected = files.sum(&:unprotected)
24
33
  def grand_total = total_type_killed + total_test_killed + total_unprotected
25
34
  def protected_total = total_type_killed + total_test_killed
26
- def ratio = grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
35
+
36
+ # Issue #686 review (second pass) — see {MutationProtectionReport#ratio}.
37
+ def ratio
38
+ return nil if Protection::MeasurementIntegrity.ratio_unmeasurable?(
39
+ grand_total: grand_total, unmeasured_files: unmeasured_files
40
+ )
41
+
42
+ grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
43
+ end
27
44
 
28
45
  # #264 — stays OUT of `grand_total`/`ratio`, exactly like the plain mutation report.
29
46
  def total_harness_errors = files.sum(&:harness_errors)
30
47
 
48
+ # Issue #686 — see {Rigor::CLI::MutationProtectionReport#unmeasured_files}.
49
+ def unmeasured_files = files.count(&:unmeasured?)
50
+
31
51
  def to_h
32
52
  {
33
53
  "mode" => "protection-fused",
34
54
  "type_killed" => total_type_killed,
35
55
  "test_killed" => total_test_killed,
36
56
  "unprotected" => total_unprotected,
37
- "protected_ratio" => ratio.round(4),
57
+ "protected_ratio" => ratio&.round(4),
38
58
  "harness_errors" => total_harness_errors,
59
+ "unmeasured_files" => unmeasured_files,
39
60
  "files" => files.map do |f|
40
61
  { "path" => f.path, "type_killed" => f.type_killed, "test_killed" => f.test_killed,
41
62
  "unprotected" => f.unprotected, "ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # The stderr half of "did this measurement actually happen" for `rigor coverage --protection --mutation`.
6
+ #
7
+ # Two counts, escalating, and the difference between them is the whole point:
8
+ #
9
+ # - `harness_errors` (#264) counts MUTANTS the harness rescued. Advisory and floored — a few are the
10
+ # transient the bucket exists to make visible, and `determine_protection_exit` deliberately ignores
11
+ # them so a `--threshold` build does not start failing for a reason unrelated to the ratio it pins.
12
+ # - `unmeasured_files` (#686) counts FILES where nothing could be measured at all, because the clean
13
+ # baseline itself came back from a crashed analysis. No floor and not advisory: the ratio then answers
14
+ # a smaller question than the user asked, `determine_protection_exit` fails the run, and this says so.
15
+ # Without it, `killed + survived == 0` borrowed the vacuous-file convention's 1.0 and a run that
16
+ # measured nothing printed 100% and passed every threshold.
17
+ #
18
+ # Extracted from {CoverageMutation} rather than inlined there: that module sits at its length budget, and
19
+ # a warning pair with its own escalation rule is a thing worth naming anyway.
20
+ module MeasurementIntegrityWarning
21
+ module_function
22
+
23
+ # @param report [MutationProtectionReport, FusedProtectionReport] both expose the two counts.
24
+ # @param err [IO] the stream to warn on.
25
+ # @param floor [Integer] {CoverageMutation::HARNESS_ERROR_WARN_FLOOR}.
26
+ # @return [void]
27
+ def emit(report, err:, floor:)
28
+ lines_for(report, floor: floor).each { |line| err.puts(line) }
29
+ end
30
+
31
+ # @return [Array<String>] the warnings this report earns, widest consequence first.
32
+ def lines_for(report, floor:)
33
+ lines = []
34
+ unmeasured = report.respond_to?(:unmeasured_files) ? report.unmeasured_files : 0 # see CoverageCommand
35
+ lines << unmeasured_line(unmeasured) if unmeasured.positive?
36
+ harness_errors = report.total_harness_errors
37
+ lines << harness_error_line(harness_errors, floor) if harness_errors >= floor
38
+ lines
39
+ end
40
+
41
+ def unmeasured_line(count)
42
+ "coverage: #{count} file(s) could not be measured at all — every mutation of them failed inside " \
43
+ "the measurement harness, typically because Rigor's own analysis crashed while re-analysing " \
44
+ "them. They contribute nothing to the ratio, so the effectiveness above is measured over the " \
45
+ "REST of the project. Exiting non-zero: a ratio over part of what you asked about must not read " \
46
+ "as a pass."
47
+ end
48
+
49
+ def harness_error_line(count, floor)
50
+ "coverage: #{count} mutants failed inside the measurement harness (\"harness_errors\", at/above " \
51
+ "the #{floor}-mutant floor) — excluded from the ratio like a parse-invalid mutant, but this many " \
52
+ "suggests a harness defect rather than one-off noise. Investigate before trusting --threshold " \
53
+ "on this run."
54
+ end
55
+ end
56
+ end
57
+ end
@@ -26,15 +26,35 @@ module Rigor
26
26
  end
27
27
 
28
28
  def render_text(report)
29
- pct = (report.ratio * 100).round(1)
29
+ pct = report.ratio ? (report.ratio * 100).round(1) : nil
30
30
  @out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
31
- @out.puts " caught breakages: #{report.total_killed} / #{report.grand_total} (#{pct}%)"
31
+ @out.puts " caught breakages: #{report.total_killed} / #{report.grand_total}#{ratio_suffix(pct)}"
32
32
  @out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
33
+ render_unmeasured_files(report)
33
34
  render_harness_errors(report)
34
35
  render_missed(report)
35
36
  render_files(report)
36
37
  end
37
38
 
39
+ # Issue #686 — `0 / 0` is `ratio` 1.0 by the vacuous-file convention, and printing "100.0%" over a run
40
+ # that measured nothing is the exact shape this issue exists to end. When nothing was measured AND a
41
+ # file went unmeasured, the percentage is withheld rather than invented.
42
+ def ratio_suffix(pct)
43
+ return " (not measured)" if pct.nil?
44
+
45
+ " (#{pct}%)"
46
+ end
47
+
48
+ # Issue #686 — the count that makes the command exit non-zero, on stdout beside the ratio it qualifies
49
+ # (the stderr twin in `CoverageMutation#warn_unmeasured_files` carries the explanation).
50
+ def render_unmeasured_files(report)
51
+ count = report.unmeasured_files
52
+ return if count.zero?
53
+
54
+ @out.puts " unmeasured files: #{count} — every mutation of them failed inside the measurement " \
55
+ "harness, so they are not in the ratio above"
56
+ end
57
+
38
58
  # #264 — surfaced only when non-zero: a harness-level failure is a defect in the measurement itself, not
39
59
  # in the code being measured, and a clean run should not carry a permanent line about a bucket that is
40
60
  # always empty.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../protection/measurement_integrity"
4
+
3
5
  module Rigor
4
6
  class CLI
5
7
  # ADR-63 Tier 2 — aggregates per-file {Protection::MutationScanner} results into a project-level *effectiveness*
@@ -16,6 +18,13 @@ module Rigor
16
18
  def initialize(path:, killed:, survived:, ratio:, harness_errors: 0)
17
19
  super
18
20
  end
21
+
22
+ # Issue #686 — the file measured the HARNESS, not the code: every mutant it had landed in
23
+ # `harness_errors`, so `killed + survived` is zero for a reason that is not "there was nothing to
24
+ # measure". `ratio` is 0.0 here rather than the vacuous 1.0, and this is what the exit gate reads.
25
+ def unmeasured?
26
+ !Protection::MeasurementIntegrity.measured?(total: killed + survived, harness_errors: harness_errors)
27
+ end
19
28
  end
20
29
  MissedBreakage = Data.define(:method_name, :count, :examples)
21
30
 
@@ -23,21 +32,41 @@ module Rigor
23
32
  def total_killed = files.sum(&:killed)
24
33
  def total_survived = files.sum(&:survived)
25
34
  def grand_total = total_killed + total_survived
26
- def ratio = grand_total.zero? ? 1.0 : total_killed.to_f / grand_total
35
+
36
+ # Issue #686 review (second pass) — `nil`, not 1.0, when nothing was measured and something failed
37
+ # trying. The text renderer already withheld the percentage; JSON did not, so a CI gate reading
38
+ # `effectiveness_ratio` still saw 100% on a run that measured nothing — the same defect one channel
39
+ # over. An empty project (nothing to measure, nothing failed) stays vacuously 1.0.
40
+ def ratio
41
+ return nil if Protection::MeasurementIntegrity.ratio_unmeasurable?(
42
+ grand_total: grand_total, unmeasured_files: unmeasured_files
43
+ )
44
+
45
+ grand_total.zero? ? 1.0 : total_killed.to_f / grand_total
46
+ end
27
47
 
28
48
  # #264 — a harness-level failure count, summed across files. Stays OUT of `grand_total`/`ratio` exactly
29
49
  # like a parse error: it is not a measurement of the code.
30
50
  def total_harness_errors = files.sum(&:harness_errors)
31
51
 
52
+ # Issue #686 — files where NOTHING could be measured. Distinct from `total_harness_errors`, which
53
+ # counts mutants and rightly tolerates a few (#264's floor): one wholly unmeasured file already means
54
+ # the ratio is computed over a smaller project than the user asked about, so this gates the exit and
55
+ # the mutant count does not.
56
+ def unmeasured_files = files.count(&:unmeasured?)
57
+
32
58
  def to_h
33
59
  {
34
60
  "mode" => "mutation",
35
61
  "killed" => total_killed,
36
62
  "survived" => total_survived,
37
- "effectiveness_ratio" => ratio.round(4),
63
+ "effectiveness_ratio" => ratio&.round(4),
38
64
  # #264 — unconditional: a JSON consumer (e.g. a CI gate) must be able to check this every run, not
39
65
  # only when a text renderer decided it was worth a line.
40
66
  "harness_errors" => total_harness_errors,
67
+ # Issue #686 — unconditional for the same reason `harness_errors` is: a CI gate reading JSON must
68
+ # be able to see that part of the project was never measured, on every run.
69
+ "unmeasured_files" => unmeasured_files,
41
70
  "files" => files.map do |f|
42
71
  { "path" => f.path, "killed" => f.killed, "survived" => f.survived,
43
72
  "ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
@@ -33,6 +33,12 @@ module Rigor
33
33
  VALID_PARAM_POLICIES = %w[untyped observed observed-strict].freeze
34
34
  VALID_FORMATS = %w[text json].freeze
35
35
 
36
+ # The skip reasons {#report_skipped} counts. The two left out each have a detailed report of their own
37
+ # ({#report_unrenderable}, {#report_unresolvable_superclasses}), so a method never shows up in two tallies.
38
+ SUMMARISED_SKIP_REASONS = (SigGen::Classification::SKIP_DIAGNOSTIC_IDS.keys -
39
+ %i[unrenderable_rbs unresolvable_superclass]).freeze
40
+ private_constant :SUMMARISED_SKIP_REASONS
41
+
36
42
  # @return [Integer] CLI exit status.
37
43
  def run
38
44
  options = parse_options
@@ -54,12 +60,36 @@ module Rigor
54
60
  dispatch_print_or_diff(candidates, mode, options)
55
61
  0
56
62
  end
63
+ report_skipped(candidates, options)
57
64
  report_unrenderable(generator.unrenderable)
65
+ report_unresolvable_superclasses(generator.unresolvable_superclasses)
58
66
  status
59
67
  end
60
68
 
61
69
  private
62
70
 
71
+ # Issue #778 — one stderr line per run saying how many methods the generator declined and why, so a
72
+ # method missing from the output is never a silent absence. Text mode only: under `--format=json` every
73
+ # skipped row is already in the payload with its `skip_reason`, and stderr stays clean for the consumer.
74
+ # Per-method lines would be noise at project scale; the JSON payload is where each one is named.
75
+ def report_skipped(candidates, options)
76
+ return unless options.fetch(:format) == "text"
77
+
78
+ counts = candidates.each_with_object(Hash.new(0)) do |candidate, acc|
79
+ next unless candidate.classification == SigGen::Classification::SKIPPED
80
+ next unless SUMMARISED_SKIP_REASONS.include?(candidate.skip_reason)
81
+
82
+ acc[candidate.skip_reason] += 1
83
+ end
84
+ return if counts.empty?
85
+
86
+ breakdown = counts.map { |reason, n| "#{SigGen::Classification::SKIP_DIAGNOSTIC_IDS.fetch(reason)}: #{n}" }
87
+ @err.puts(
88
+ "rigor sig-gen: skipped #{counts.values.sum} method(s) it could not type or would not overwrite " \
89
+ "(#{breakdown.join(', ')}). Run with --format=json to see each one with its skip_reason."
90
+ )
91
+ end
92
+
63
93
  # A method whose rendered RBS does not parse is a Rigor rendering defect, not a fact about the user's
64
94
  # code — the generator skipped it (so the rest of the signatures are still usable and still valid), but
65
95
  # staying silent would leave the user with a quietly incomplete `sig/` and us with an unreported bug.
@@ -79,6 +109,23 @@ module Rigor
79
109
  end
80
110
  end
81
111
 
112
+ # Issue #735 — unlike {#report_unrenderable} this is a fact about the PROJECT's type universe, not a
113
+ # Rigor defect: the class's superclass is not declared by any RBS the environment loads, so a sidecar
114
+ # declaring it would fail to build and take the class's type coverage with it. Naming the unresolved
115
+ # superclass is the actionable part — it is usually one gem's missing RBS standing between the user
116
+ # and signatures for a whole layer of their app.
117
+ def report_unresolvable_superclasses(unresolvable)
118
+ return if unresolvable.empty?
119
+
120
+ @err.puts(
121
+ "rigor sig-gen: skipped #{unresolvable.size} class(es) whose superclass no loaded RBS declares. " \
122
+ "A signature for them would fail to build (`RBS::NoSuperclassFoundError`) and would leave the " \
123
+ "class LESS typed than it is now. Provide RBS for the superclass — `rbs collection install`, the " \
124
+ "gem's own `sig/`, or a `signature_paths:` entry — and re-run."
125
+ )
126
+ unresolvable.sort.each { |class_name, superclass| @err.puts(" #{class_name} < #{superclass}") }
127
+ end
128
+
82
129
  def dispatch_print_or_diff(candidates, mode, options)
83
130
  SigGen::Renderer.new(out: @out).render(
84
131
  candidates: candidates,