rigortype 0.3.5 → 0.3.7

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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  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 +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -3,7 +3,10 @@
3
3
  require "optionparser"
4
4
 
5
5
  require_relative "../configuration"
6
- require_relative "../analysis/runner"
6
+ # ADR-104 — `analysis/runner` is required lazily (in `#analyze`), not here: a warm report served by
7
+ # {Analysis::EffectsCacheProbe} must reach its answer without `rigor/inference` in `$LOADED_FEATURES`,
8
+ # which is the whole of the boot saving. The probe pulls only the cache and the effects value layer.
9
+ require_relative "../analysis/effects_cache_probe"
7
10
  require_relative "../effects/plugin_facts"
8
11
  require_relative "../effects/registry"
9
12
  require_relative "../plugin/loader"
@@ -94,7 +97,7 @@ module Rigor
94
97
  return list_labels(configuration) if options.fetch(:list_labels)
95
98
 
96
99
  scope = @argv.dup
97
- table, sources = analyze(configuration, scope)
100
+ table, sources = resolve_table(configuration, scope)
98
101
  report = EffectsReport.build(
99
102
  table, full: options.fetch(:full), sources: sources, scope: scope,
100
103
  label: options.fetch(:label), pure: options.fetch(:pure), limit: options.fetch(:limit)
@@ -231,6 +234,7 @@ module Rigor
231
234
  # @return [Array(Rigor::Effects::EffectTable, Hash{String=>Array<String>})] the table, and which
232
235
  # file each unit was defined in — the map {EffectsReport} needs to answer a path argument.
233
236
  def analyze(configuration, scope)
237
+ require_relative "../analysis/runner"
234
238
  runner = Analysis::Runner.new(
235
239
  configuration: configuration,
236
240
  cache_store: Cache::Store.new(root: configuration.cache_path),
@@ -240,6 +244,27 @@ module Rigor
240
244
  runner.run((configuration.paths + scope).uniq)
241
245
  [runner.effect_table, runner.effect_sources]
242
246
  end
247
+
248
+ # ADR-104 — the report before the engine. A warm run's whole answer is the #482 summary entry, so
249
+ # {Analysis::EffectsCacheProbe} tries to serve it without loading `rigor/inference`; any decline
250
+ # falls through to the full runner, which behaves exactly as it did.
251
+ #
252
+ # The report is a pure function of the table, the sources and the configuration: the envelope
253
+ # diagnostics the analysing path also computes are discarded here (this method reads the table
254
+ # and the sources and nothing else), which is what makes the served answer identical rather than
255
+ # merely similar.
256
+ def resolve_table(configuration, scope)
257
+ served = probe(configuration, scope)
258
+ return [served.table, served.sources] if served
259
+
260
+ analyze(configuration, scope)
261
+ end
262
+
263
+ def probe(configuration, scope)
264
+ Analysis::EffectsCacheProbe.new(
265
+ configuration: configuration, cache_root: configuration.cache_path
266
+ ).serve((configuration.paths + scope).uniq)
267
+ end
243
268
  end
244
269
  end
245
270
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "../effects/definition_lines"
6
+ require_relative "../effects/snapshot"
5
7
  require_relative "../effects/snapshot_diff"
6
8
  require_relative "renderable"
7
9
 
@@ -16,8 +18,9 @@ module Rigor
16
18
  # the one line the whole workflow turns on: intent is expressed by regenerating and committing the
17
19
  # file, not by annotating the code.
18
20
  #
19
- # It is deliberately not a diagnostic format: no severities, no positions, no exit-code weight of its
20
- # own. The exit code comes from the gate.
21
+ # It is deliberately not a diagnostic format: no severities and no exit-code weight of its own — the
22
+ # exit code comes from the gate. A row carries a position for a reader to jump to, never one a
23
+ # `# rigor:disable` or a baseline could act on.
21
24
  class EffectsDiffRenderer
22
25
  include Renderable
23
26
 
@@ -41,9 +44,23 @@ module Rigor
41
44
  CLOSING_LINE = "Run `rigor effects explain` to see what caused this, and `rigor effects update` to " \
42
45
  "accept it."
43
46
 
44
- def initialize(out:, path:)
47
+ # A regeneration event routes to `update` alone: `explain` answers "what caused this label to
48
+ # appear", and the answer here is "the record was written under different rules", which explain
49
+ # cannot expand and the line above already said.
50
+ REGENERATION_CLOSING_LINE = "Run `rigor effects update` to regenerate the record under the current " \
51
+ "rules."
52
+
53
+ # @param sources [Hash{String=>Array<String>}] `Runner#effect_sources` — where each unit is defined.
54
+ # A drift row names `file:line` so a reviewer does not have to search for the method (#435). The
55
+ # file rides the cached summary entry and is free; the line is resolved by parsing the row's own
56
+ # file ({Effects::DefinitionLines}), which is why a fresh report — no rows — still parses nothing
57
+ # and the whole-project parse ADR-104 removed from this command stays removed.
58
+ # @param lines [Effects::DefinitionLines] seam for the specs; the default parses on demand.
59
+ def initialize(out:, path:, sources: nil, lines: Effects::DefinitionLines.new)
45
60
  @out = out
46
61
  @path = path
62
+ @sources = sources || {}
63
+ @lines = lines
47
64
  end
48
65
 
49
66
  private
@@ -67,10 +84,7 @@ module Rigor
67
84
 
68
85
  @out.puts("")
69
86
  @out.puts("#{section_name(table)}:")
70
- events.each do |event|
71
- suffix = qualified ? " (#{event.table})" : ""
72
- @out.puts(" #{render_event(event)}#{suffix}")
73
- end
87
+ events.each { |event| @out.puts(" #{render_event(event, qualified: qualified)}") }
74
88
  end
75
89
 
76
90
  def section_name(table)
@@ -81,9 +95,38 @@ module Rigor
81
95
  end
82
96
  end
83
97
 
84
- def render_event(event)
98
+ def render_event(event, qualified: false)
85
99
  marker = marker_for(event)
86
- event.symbol.nil? ? marker : "#{event.symbol} #{marker}"
100
+ return marker if event.symbol.nil?
101
+
102
+ "#{event.symbol} #{marker}#{annotation(event, qualified)}"
103
+ end
104
+
105
+ # One parenthetical, never two: where the unit is defined, and — under `tolerated:`, which pools
106
+ # both tables — which table the event came from. A row with no known source under `tolerated:`
107
+ # renders exactly as it did before this suffix existed.
108
+ #
109
+ # A unit defined by a reopening spans several files and the row names them all rather than picking
110
+ # one; which of them a reviewer wants is exactly what the row cannot know.
111
+ def annotation(event, qualified)
112
+ parts = sources_of(event.symbol).map { |path, line| line ? "#{path}:#{line}" : path }
113
+ parts << event.table if qualified
114
+ parts.empty? ? "" : " (#{parts.join(', ')})"
115
+ end
116
+
117
+ # Where a symbol is defined, as the two renderings share it — one implementation, so the text form
118
+ # and the JSON one cannot disagree about a position (WD3's rule).
119
+ #
120
+ # **A removed symbol has no entry here at all**, and cannot: `sources` is the CURRENT run's, and a
121
+ # method that no longer exists was not defined by it. Such a row renders exactly as it did before
122
+ # this suffix existed.
123
+ #
124
+ # @return [Array<Array(String, Integer)>] `[relative path, line]` per file, the line nil when that
125
+ # file spells the key with no `def` — an accessor, or a definition the nesting cannot name.
126
+ def sources_of(symbol)
127
+ Array(@sources[symbol]).map do |path|
128
+ [Effects::Snapshot.relativize(path, Dir.pwd), @lines.for(key: symbol, path: path)]
129
+ end
87
130
  end
88
131
 
89
132
  def marker_for(event)
@@ -99,21 +142,48 @@ module Rigor
99
142
  def render_footer(diff)
100
143
  footer = diff.footer
101
144
  @out.puts("")
145
+ @out.puts(withheld_line(footer)) if diff.regeneration?
102
146
  unless footer[:added_symbols].zero? && footer[:removed_symbols].zero?
103
147
  @out.puts("symbols: +#{footer[:added_symbols]} / -#{footer[:removed_symbols]} " \
104
148
  "(a rename is one of each)")
105
149
  end
106
- @out.puts(CLOSING_LINE)
150
+ @out.puts(diff.regeneration? ? REGENERATION_CLOSING_LINE : CLOSING_LINE)
151
+ end
152
+
153
+ # What a regeneration withheld, said as a count. The counts below it are still printed, so the
154
+ # reader sees the scale of the difference without reading it row by row.
155
+ def withheld_line(footer)
156
+ suppressed = footer[:suppressed]
157
+ return "The two records are not comparable, so no per-method difference is shown." if
158
+ suppressed.zero?
159
+
160
+ noun = suppressed == 1 ? "1 per-method difference is" : "#{suppressed} per-method differences are"
161
+ "The two records were computed under different rules and are not comparable, so #{noun} not shown."
162
+ end
163
+
164
+ # The machine form carries the same position the text one prints (#435): a bot that annotates a pull
165
+ # request is the consumer with the most use for a line and the least ability to find one itself.
166
+ # `sources` is omitted rather than emitted empty when the row names nothing — a removed symbol.
167
+ def event_json(event)
168
+ row = event.to_h
169
+ sources = sources_of(event.symbol)
170
+ return row if sources.empty?
171
+
172
+ row.merge("sources" => sources.map do |path, line|
173
+ line ? { "path" => path, "line" => line } : { "path" => path }
174
+ end)
107
175
  end
108
176
 
109
177
  def render_json(diff)
110
178
  @out.puts(JSON.pretty_generate(
111
179
  "fresh" => diff.fresh?,
112
- "events" => diff.events.map(&:to_h),
180
+ "events" => diff.events.map { |event| event_json(event) },
113
181
  "footer" => {
114
182
  "added_symbols" => diff.footer[:added_symbols],
115
- "removed_symbols" => diff.footer[:removed_symbols]
183
+ "removed_symbols" => diff.footer[:removed_symbols],
184
+ "suppressed" => diff.footer[:suppressed]
116
185
  },
186
+ "regeneration" => diff.regeneration?,
117
187
  "header" => {
118
188
  "snapshot" => @path,
119
189
  "gate" => diff.gate.to_s,
@@ -22,8 +22,9 @@ module Rigor
22
22
  class EffectsExplainRenderer
23
23
  include Renderable
24
24
 
25
- # One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin.
26
- Row = Data.define(:table, :symbol, :label, :path, :origin)
25
+ # One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin;
26
+ # `causes` is non-empty only for an exhaustiveness row, which has neither (#435).
27
+ Row = Data.define(:table, :symbol, :label, :path, :origin, :causes)
27
28
 
28
29
  def initialize(out:)
29
30
  @out = out
@@ -44,6 +45,7 @@ module Rigor
44
45
  end
45
46
 
46
47
  def render_row(row)
48
+ return "#{row.symbol} stopped being exhaustive ← #{row.causes.join(', ')}" unless row.causes.empty?
47
49
  return "#{row.path.join(' → ')} [#{row.label}]" unless row.path.empty?
48
50
 
49
51
  origin = row.origin ? " ← #{row.origin}" : ""
@@ -58,7 +60,8 @@ module Rigor
58
60
  "symbol" => row.symbol,
59
61
  "label" => row.label,
60
62
  "path" => row.path,
61
- "origin" => row.origin
63
+ "origin" => row.origin,
64
+ "causes" => row.causes
62
65
  }
63
66
  end
64
67
  ))
@@ -3,7 +3,9 @@
3
3
  require "did_you_mean"
4
4
  require "optionparser"
5
5
 
6
- require_relative "../analysis/runner"
6
+ # ADR-104 — `analysis/runner` is required lazily (in `#analyze`): a warm snapshot verb served by the
7
+ # probe must reach its answer without `rigor/inference` in `$LOADED_FEATURES`.
8
+ require_relative "../analysis/effects_cache_probe"
7
9
  require_relative "../cache/store"
8
10
  require_relative "../configuration"
9
11
  require_relative "../effects/discharge"
@@ -111,7 +113,8 @@ module Rigor
111
113
  )
112
114
  return explain(diff, options) if @verb == "explain"
113
115
 
114
- EffectsDiffRenderer.new(out: @out, path: path).render(diff, format: options.fetch(:format))
116
+ EffectsDiffRenderer.new(out: @out, path: path, sources: @sources)
117
+ .render(diff, format: options.fetch(:format))
115
118
  @verb == "check" && diff.drift? ? 1 : 0
116
119
  end
117
120
 
@@ -163,6 +166,9 @@ module Rigor
163
166
  # `methods:` change gets the origin in the method's own body.
164
167
  def rows_for_changes(diff)
165
168
  diff.events.flat_map do |event|
169
+ next exhaustiveness_rows(event) if
170
+ event.category == Effects::SnapshotDiff::EXHAUSTIVE_LOST
171
+
166
172
  labels = labels_of(event)
167
173
  next [] if labels.empty?
168
174
 
@@ -178,13 +184,29 @@ module Rigor
178
184
  entry ? entry.proven.to_a : []
179
185
  end
180
186
 
187
+ # `exhaustive → not` is the drift row a reader is least equipped to interpret, and it was the one
188
+ # row `explain` could not expand (#435): it carries no label, so {#labels_of} answered with nothing
189
+ # and the event produced no explanation at all. What explains it is the taint causes — the calls
190
+ # the analyzer could not follow — which is the detail the snapshot stopped recording per row
191
+ # (#434). The two halves meet here: the record keeps the stable count, and this names them.
192
+ def exhaustiveness_rows(event)
193
+ entry = @table[event.symbol]
194
+ return [] if entry.nil?
195
+
196
+ causes = Effects::Snapshot.render_causes(entry.causes)
197
+ return [] if causes.empty?
198
+
199
+ [EffectsExplainRenderer::Row.new(table: event.table, symbol: event.symbol, label: nil,
200
+ path: [].freeze, origin: nil, causes: causes)]
201
+ end
202
+
181
203
  def reach_rows(symbol, labels)
182
204
  labels.filter_map do |label|
183
205
  path = Effects::PathFinder.shortest(@table, symbol: symbol, label: label)
184
206
  next if path.nil?
185
207
 
186
208
  EffectsExplainRenderer::Row.new(table: "reach", symbol: symbol, label: label,
187
- path: path.to_a, origin: path.origin)
209
+ path: path.to_a, origin: path.origin, causes: [].freeze)
188
210
  end
189
211
  end
190
212
 
@@ -197,7 +219,7 @@ module Rigor
197
219
  next if origin.nil?
198
220
 
199
221
  EffectsExplainRenderer::Row.new(table: "methods", symbol: symbol, label: label,
200
- path: [].freeze, origin: origin.to_s)
222
+ path: [].freeze, origin: origin.to_s, causes: [].freeze)
201
223
  end
202
224
  end
203
225
 
@@ -207,7 +229,20 @@ module Rigor
207
229
  # the ADR-45 whole-run result cache like `rigor check` — the diagnostics entry plus the #382 effects
208
230
  # sidecar keyed beside it, so `rigor effects check` after `rigor check` under a configured `effects:`
209
231
  # block is a warm hit plus the fixpoint.
232
+ # ADR-104 — a snapshot is built from the table, the sources and the vocabulary, all three of which
233
+ # the #482 summary entry (plus the plugins' own declarations) carries, so a warm run takes the
234
+ # probe and never loads the engine. A decline is the runner path below, unchanged.
210
235
  def build_snapshot(configuration, full:)
236
+ served = Analysis::EffectsCacheProbe.new(
237
+ configuration: configuration, cache_root: configuration.cache_path
238
+ ).serve(configuration.paths)
239
+ return snapshot_from(configuration, full, served) if served
240
+
241
+ snapshot_from(configuration, full, analyze(configuration))
242
+ end
243
+
244
+ def analyze(configuration)
245
+ require_relative "../analysis/runner"
211
246
  runner = Analysis::Runner.new(
212
247
  configuration: configuration,
213
248
  cache_store: Cache::Store.new(root: configuration.cache_path),
@@ -215,14 +250,24 @@ module Rigor
215
250
  workers: 0
216
251
  )
217
252
  runner.run(configuration.paths)
218
- @table = runner.effect_table
219
- Effects::Snapshot.build(
220
- table: @table, configuration: configuration, sources: runner.effect_sources, full: full,
253
+ Analysis::EffectsCacheProbe::Served.new(
254
+ table: runner.effect_table, sources: runner.effect_sources, plugin_facts: nil,
221
255
  registry: Effects::Registry.for_configuration(configuration,
222
256
  plugin_facts: runner.effect_plugin_facts)
223
257
  )
224
258
  end
225
259
 
260
+ # WD3 — one build, whichever lane produced the inputs, so a served snapshot and an analysed one
261
+ # cannot drift in how they are assembled.
262
+ def snapshot_from(configuration, full, served)
263
+ @table = served.table
264
+ @sources = served.sources
265
+ Effects::Snapshot.build(
266
+ table: @table, configuration: configuration, sources: served.sources, full: full,
267
+ registry: served.registry
268
+ )
269
+ end
270
+
226
271
  # ---- options ------------------------------------------------
227
272
 
228
273
  def parse_options
@@ -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 }
@@ -55,6 +55,7 @@ module Rigor
55
55
  0
56
56
  end
57
57
  report_unrenderable(generator.unrenderable)
58
+ report_unresolvable_superclasses(generator.unresolvable_superclasses)
58
59
  status
59
60
  end
60
61
 
@@ -79,6 +80,23 @@ module Rigor
79
80
  end
80
81
  end
81
82
 
83
+ # Issue #735 — unlike {#report_unrenderable} this is a fact about the PROJECT's type universe, not a
84
+ # Rigor defect: the class's superclass is not declared by any RBS the environment loads, so a sidecar
85
+ # declaring it would fail to build and take the class's type coverage with it. Naming the unresolved
86
+ # superclass is the actionable part — it is usually one gem's missing RBS standing between the user
87
+ # and signatures for a whole layer of their app.
88
+ def report_unresolvable_superclasses(unresolvable)
89
+ return if unresolvable.empty?
90
+
91
+ @err.puts(
92
+ "rigor sig-gen: skipped #{unresolvable.size} class(es) whose superclass no loaded RBS declares. " \
93
+ "A signature for them would fail to build (`RBS::NoSuperclassFoundError`) and would leave the " \
94
+ "class LESS typed than it is now. Provide RBS for the superclass — `rbs collection install`, the " \
95
+ "gem's own `sig/`, or a `signature_paths:` entry — and re-run."
96
+ )
97
+ unresolvable.sort.each { |class_name, superclass| @err.puts(" #{class_name} < #{superclass}") }
98
+ end
99
+
82
100
  def dispatch_print_or_diff(candidates, mode, options)
83
101
  SigGen::Renderer.new(out: @out).render(
84
102
  candidates: candidates,