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
@@ -19,7 +19,14 @@ module Rigor
19
19
  # Slice 2 ships a minimal surface:
20
20
  #
21
21
  # - `#read_file(path)` — validates against the policy, returns the file's contents, and adds a
22
- # digest-keyed {Cache::Descriptor::FileEntry} to the boundary's accumulated descriptor.
22
+ # digest-keyed {Cache::Descriptor::FileEntry} to the boundary's accumulated descriptor. A read that
23
+ # fails because the path does not exist adds an ABSENCE row instead (ADR-45 WD1, #577), so a result
24
+ # computed on the file being missing invalidates once it appears.
25
+ # - `#file?(path)` / `#directory?(path)` — the existence PROBE (ADR-45 WD1b, #613): the same answer
26
+ # `File.file?` / `File.directory?` gives, plus the existence row `#read_file` would have recorded.
27
+ # A plugin that gates its read or its glob on one of these shapes its result on the answer, so the
28
+ # answer is a dependency; probing through `File` directly leaves that dependency unrecorded and the
29
+ # warm run serves the pre-appearance result.
23
30
  # - `#open_url(url)` — fetches the URL when the policy permits it (`network_policy: :allowlist` plus an
24
31
  # `allowed_url_hosts` match) and raises {AccessDeniedError} otherwise. v0.1.2 ships the allowlist
25
32
  # surface; the default project policy still has `network_policy: :disabled` so plugins that want network
@@ -46,8 +53,18 @@ module Rigor
46
53
  end
47
54
 
48
55
  # Reads the file at `path` after validating it against the policy. Raises {AccessDeniedError} when the
49
- # path is outside every allowed read root. Records a `:digest` {FileEntry} so the resulting cache slice
50
- # invalidates on content change.
56
+ # path is outside every allowed read root. Records a `:stat` {Cache::Descriptor::FileEntry} so the
57
+ # resulting cache slice invalidates on content change.
58
+ #
59
+ # ADR-45 WD1 (#577) — a read that fails because the path does NOT exist (`Errno::ENOENT`, or
60
+ # `Errno::ENOTDIR` when a parent component is a regular file) is a deliberate existence probe: the
61
+ # plugin asked for the file and shaped its answer on the file being absent (rigor-activerecord's
62
+ # reduced mode on a missing `db/schema.rb`). That is a dependency too — "the analysis depended on X
63
+ # being absent" — so the boundary records an ABSENCE row ({Cache::Descriptor::FileEntry.absent})
64
+ # before re-raising, and every cache built on this boundary's descriptor (the run-result entry, the
65
+ # plugin-producer entries) goes stale the moment the path comes into existence. Only the not-there
66
+ # outcome is recorded: a path that exists but cannot be read (`EISDIR`, `EACCES`) is a failure the
67
+ # plugin reports, not a probe of absence, and records nothing — as before.
51
68
  def read_file(path)
52
69
  absolute = File.expand_path(path.to_s)
53
70
  unless @policy.allow_read?(absolute)
@@ -59,11 +76,39 @@ module Rigor
59
76
  )
60
77
  end
61
78
 
62
- contents = File.binread(absolute)
79
+ contents = begin
80
+ File.binread(absolute)
81
+ rescue Errno::ENOENT, Errno::ENOTDIR
82
+ record_absence_entry(absolute)
83
+ raise
84
+ end
63
85
  record_file_entry(absolute, contents)
64
86
  contents
65
87
  end
66
88
 
89
+ # ADR-45 WD1b (#613) — the existence probe. Returns exactly what `File.file?(path)` returns, and
90
+ # records the answer as a {Cache::Descriptor::FileEntry} `:exists` row so a plugin that gates its read
91
+ # on the probe carries the same dependency a plugin that just called {#read_file} carries.
92
+ # `File.file?` in plugin code records nothing, which is how the #577 staleness class survived at every
93
+ # `return nil unless File.file?(config)` gate: the miss shaped the result and left no edge for the
94
+ # file's later appearance.
95
+ #
96
+ # @param path [String] project path; relative paths expand against the working directory
97
+ # @return [Boolean] `File.file?`'s answer, unchanged
98
+ def file?(path)
99
+ probe(path) { |absolute| File.file?(absolute) }
100
+ end
101
+
102
+ # ADR-45 WD1b (#613) — {#file?} for directories: the answer `File.directory?` gives, plus the
103
+ # existence row. The discovery shape (`next [] unless directory?(root)` before a `Dir.glob`) depends
104
+ # on the root existing exactly as a config read depends on the config file existing.
105
+ #
106
+ # @param path [String] project path; relative paths expand against the working directory
107
+ # @return [Boolean] `File.directory?`'s answer, unchanged
108
+ def directory?(path)
109
+ probe(path) { |absolute| File.directory?(absolute) }
110
+ end
111
+
67
112
  # Fetches the URL when the policy permits it. Returns the response body. Raises {AccessDeniedError} when
68
113
  # the policy is `:disabled`, the URL scheme is not `https`, the host is not on the allowlist, the
69
114
  # response is non-2xx, the body exceeds {URL_MAX_BYTES}, or the request times out
@@ -96,6 +141,36 @@ module Rigor
96
141
 
97
142
  private
98
143
 
144
+ # ADR-45 WD1b (#613) — the shared body of {#file?} / {#directory?}.
145
+ #
146
+ # The probe answers TRUTHFULLY for every path, in or out of the trusted-read scope, and never raises:
147
+ # it replaces a bare `File.file?` in plugin code, and a predicate that raised (or answered `false` for
148
+ # an out-of-scope path that exists) would change what the converted plugin does rather than only what
149
+ # it records. Existence is not content — ADR-2's boundary is not a sandbox, and the plugin could ask
150
+ # `File` directly — so the policy governs RECORDING here, not the answer: an out-of-scope path
151
+ # contributes no dependency row, exactly as an out-of-scope read contributes none.
152
+ #
153
+ # Three outcomes, mirroring {#read_file}'s bounds:
154
+ #
155
+ # - the probe found what it asked for → a PRESENCE row (`:exists` / `"true"`), stale once the path is
156
+ # gone;
157
+ # - the probe found nothing at all → an ABSENCE row, stale once anything appears there;
158
+ # - the path exists but is not what was asked for (a directory where a file was wanted, or the
159
+ # reverse) → NOTHING, the same bound `#read_file` pins for `EISDIR`: the probe observed neither a
160
+ # usable file nor an absence, and either row would misdescribe what the plugin saw.
161
+ def probe(path)
162
+ absolute = File.expand_path(path.to_s)
163
+ answer = yield(absolute)
164
+ return answer unless @policy.allow_read?(absolute)
165
+
166
+ if answer
167
+ record_presence_entry(absolute)
168
+ elsif !File.exist?(absolute)
169
+ record_absence_entry(absolute)
170
+ end
171
+ answer
172
+ end
173
+
99
174
  def record_file_entry(path, contents)
100
175
  # ADR-87 WD1 — the boundary descriptor is validation-only (it never keys a cache), so a plugin-read
101
176
  # file rides the stat-then-digest `:stat` tier: a warm run stat-checks the (on a Rails monorepo,
@@ -103,9 +178,37 @@ module Rigor
103
178
  # we just read; `FileEntry.stat` stats the same path to pack the tuple.
104
179
  digest = Digest::SHA256.hexdigest(contents)
105
180
  entry = Cache::Descriptor::FileEntry.stat(path: path, digest: digest)
181
+ # A content row replaces an earlier existence row for the same path outright: the file appeared and
182
+ # its bytes were consumed, and a content row validates existence as well as content.
106
183
  @mutex.synchronize { @file_entries[path] = entry }
107
184
  end
108
185
 
186
+ # ADR-45 WD1 (#577) — the absence row for a probed-but-missing path. A content row already recorded
187
+ # for the same path is KEPT in preference: two outcomes for one path within one run mean the file
188
+ # moved under the analysis, and of the two rows the content row is the one whose validation covers
189
+ # both the content that was read and the file's existence — so it is the one that reads stale while
190
+ # the file is gone. The reverse order (probed absent, then read once it appeared) is the content
191
+ # row's replacement above.
192
+ #
193
+ # WD1b (#613) — the same `||=` also settles absence against PRESENCE: the FIRST existence row for a
194
+ # path stands. Both orders are then conservative under a mid-run mutation, because the row that
195
+ # stands describes the world the earlier decision was shaped on, and it reads stale the moment the
196
+ # world stops matching it — a recompute, never a wrong hit.
197
+ def record_absence_entry(path)
198
+ entry = Cache::Descriptor::FileEntry.absent(path: path)
199
+ @mutex.synchronize { @file_entries[path] ||= entry }
200
+ end
201
+
202
+ # ADR-45 WD1b (#613) — the presence row: fresh while the probed path exists, stale once it is gone.
203
+ # It is the weakest row the boundary records (a rename that keeps a file at the path leaves it fresh),
204
+ # and it is the honest one for a probe that never read the bytes — a discovery root globbed for `.rb`
205
+ # files, a config file whose mere existence switched a mode. Ranked under a content row, and under an
206
+ # earlier existence row, by the `||=`/`=` split above.
207
+ def record_presence_entry(path)
208
+ entry = Cache::Descriptor::FileEntry.present(path: path)
209
+ @mutex.synchronize { @file_entries[path] ||= entry }
210
+ end
211
+
109
212
  def record_url_entry(url, body)
110
213
  digest = Digest::SHA256.hexdigest(body)
111
214
  key = "url:#{url}"
@@ -76,6 +76,20 @@ module Rigor
76
76
  File.file?(path) ? path : nil
77
77
  end
78
78
 
79
+ # The signature half of {.bundled_plugin_path}: the absolute `sig/` directory of the engine's OWN
80
+ # bundled copy of a plugin gem (`<ENGINE_ROOT>/plugins/<gem>/sig`), or nil when the engine does not
81
+ # bundle it, it ships no signatures, or the packaging carries no `plugins/` tree (ADR-27).
82
+ #
83
+ # Unlike its sibling this is NOT a require anchor — nothing loads Ruby from it. It exists so the
84
+ # engine can recognise its own bundled RBS when a project reaches that directory by a route the
85
+ # plugin registry never sees: a `signature_paths:` entry pointing straight at it (issue #672). The
86
+ # anchor is deliberately the same `ENGINE_ROOT`, so a git checkout and an installed `rigortype` gem
87
+ # answer identically and the two halves of a bundled plugin can never resolve to different copies.
88
+ def self.bundled_plugin_sig_path(gem_name)
89
+ path = File.join(ENGINE_ROOT, "plugins", gem_name, "sig")
90
+ File.directory?(path) ? path : nil
91
+ end
92
+
79
93
  # @param entries [Array<String, Hash>] the raw `plugins:` list from the configuration.
80
94
  # @return [Registry]
81
95
  def load(entries)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analysis/crash_signature"
4
+
5
+ module Rigor
6
+ module Protection
7
+ # Issue #686 — raised when an oracle's re-analysis came back from a crashed run, so its diagnostics say
8
+ # nothing about the code that was analysed.
9
+ #
10
+ # A named class, not a bare RuntimeError: {MutationScanner#classify} already folds a per-mutant
11
+ # `StandardError` into the `:harness_error` bucket, and the scanner's file-level rescue must catch THIS
12
+ # and nothing else, so the two failure modes stay distinguishable at every site that handles either.
13
+ class AnalyzerCrashed < StandardError; end
14
+
15
+ # The kill oracles' guard against judging a mutant by a run that never happened.
16
+ #
17
+ # A kill oracle answers one question: did re-analysing the mutant produce a diagnostic the clean
18
+ # baseline did not carry? When a check rule raises, `Runner#analyze_file_body` rescues it into ONE
19
+ # `internal analyzer error` diagnostic for the whole file and discards the rest — deterministically, on
20
+ # the SAME file, for the SAME reason. So the baseline and the mutant come back carrying the identical
21
+ # synthetic row, the set difference is empty, and the oracle reports the mutant SURVIVED. Not "we could
22
+ # not tell": survived. Every mutant in the affected file scores the same way, which inflates the
23
+ # survivor count — the mutation harness's headline signal — in the direction that manufactures work,
24
+ # and the harness has no way to notice.
25
+ #
26
+ # An indeterminate run must not be scored as either killed or survived, so the oracles raise here and
27
+ # let {MutationScanner} put the mutant in the `harness_errors` bucket (#264), which is already excluded
28
+ # from `killed + survived` and already surfaced by the CLI. A crash then READS as a crash.
29
+ #
30
+ # Only {Analysis::Result#crashed?} is armed — the check-rule rescue, the one shape that REPLACES a
31
+ # file's whole diagnostic list. Refusing more than that is the same error pointed the other way, and it
32
+ # was measured: on a project whose plugin `prepare` raises, refusing the `:plugin` row took a file from
33
+ # `killed=1 survived=6` to `killed=0 survived=0 harness_errors=7` — the builtin rules had run, their
34
+ # diagnostics were all present, and a real measurement was thrown away. `invoke_plugin_prepare` appends
35
+ # its row to every sequential run, so arming it would have refused every run for the life of the
36
+ # process. `:rbs_build` is excluded for the same reason plus one more: the site filter that admits
37
+ # mutations already drops receivers whose type did not resolve, so a class whose definition failed to
38
+ # build contributes no measured sites to begin with.
39
+ module AnalysisGuard
40
+ module_function
41
+
42
+ # Takes the whole {Analysis::Result} rather than its diagnostics so the question is asked through
43
+ # {Analysis::Result#crashed?} — the same predicate the spec-side guard reads, off the same
44
+ # {Analysis::CrashSignature} table. Hands back the diagnostics, which is all a kill comparison wants.
45
+ #
46
+ # @param result [Rigor::Analysis::Result] one analysis run.
47
+ # @param context [String] which oracle call produced it, so the raise points at the right seam.
48
+ # @return [Array<Rigor::Analysis::Diagnostic>] the run's diagnostics, when the run was healthy.
49
+ # @raise [AnalyzerCrashed]
50
+ def checked(result, context:)
51
+ return result.diagnostics unless result.crashed?
52
+
53
+ raise AnalyzerCrashed,
54
+ "#{context}: the analyzer crashed, so this run's diagnostics say nothing about the code " \
55
+ "(#{Analysis::CrashSignature.describe(result.crash_diagnostics.first)}). A kill comparison " \
56
+ "against it would score the mutant a survivor it was never measured against. See issue #686."
57
+ end
58
+ end
59
+ end
60
+ end
@@ -5,6 +5,7 @@ require "tempfile"
5
5
  require_relative "../analysis/buffer_binding"
6
6
  require_relative "../analysis/runner"
7
7
  require_relative "../inference/fork_map"
8
+ require_relative "analysis_guard"
8
9
  require_relative "diagnostic_oracle"
9
10
  require_relative "discovery_seed"
10
11
  require_relative "kill_signature"
@@ -29,7 +30,7 @@ module Rigor
29
30
  # closure, and the two effects were indistinguishable in the total.
30
31
  #
31
32
  # **The mutant's bytes are never on the measured file's disk.** For the closure half they are written to a
32
- # process-private temp file and bound to the measured path through {Analysis::BufferBinding} — the #146
33
+ # block-scoped temp file and bound to the measured path through {Analysis::BufferBinding} — the #146
33
34
  # editor seam, whose whole purpose is "analyse THESE bytes at THAT logical path". The binding reaches three
34
35
  # places that would otherwise read the file as it sits on disk:
35
36
  #
@@ -112,18 +113,6 @@ module Rigor
112
113
  [path, *(@dependents[path] || [])]
113
114
  end
114
115
 
115
- # Release the process-private mutant file now, instead of waiting for `Tempfile`'s finalizer at process
116
- # exit. A one-shot `rigor coverage` run does not need this — the process ends and the file goes with it —
117
- # but a caller that outlives the oracle does: the spec suite's `after(:suite)` residue check (issue #330)
118
- # runs while the process is still alive, so an un-released file reads as a leak there even though it
119
- # would have been reclaimed a moment later. Idempotent, and safe on an oracle that never mutated
120
- # anything (the file is created lazily).
121
- def release!
122
- @mutant_file&.close!
123
- @mutant_file = nil
124
- @mutant_pid = nil
125
- end
126
-
127
116
  private
128
117
 
129
118
  # The diagnostic signatures the dependents of `path` report while `source` stands in for it. Empty (and
@@ -138,28 +127,35 @@ module Rigor
138
127
  end
139
128
 
140
129
  # Binds `source` to `path` for the duration of the block, yielding the binding and the seed tables
141
- # rebuilt against it. One temp file per process (never per mutant): the digest that invalidates the
142
- # mutated file's bundle is taken over the file's CONTENT, and the per-run digest memo is not installed
143
- # out here, so rewriting one path in place cannot serve a stale answer.
144
- def with_mutant(source, path)
145
- File.binwrite(mutant_file.path, source)
146
- buffer = Analysis::BufferBinding.new(logical_path: path, physical_path: mutant_file.path)
147
- yield(buffer, seed_for(buffer))
148
- end
149
-
150
- # The process-private mutant file. Created lazily, and re-created after a fork ({CLI::MutationForkScan}
151
- # workers must not share one path), which the pid guard detects.
130
+ # rebuilt against it.
131
+ #
132
+ # The mutant's bytes live in a **block-scoped** temp file: created and unlinked inside this method, so
133
+ # the oracle holds no file between calls and no caller needs a teardown hook to reclaim one. Issue #572
134
+ # — the previous design kept one `Tempfile` per process, which `Tempfile` reclaims only from its
135
+ # finalizer, i.e. at GC time. That made the file's disappearance a timing coin flip: the spec suite's
136
+ # issue-#330 residue check runs while the process is still alive, so it saw a live `rigor-mutant-*.rb`
137
+ # and failed a CI shard that the identical head passed on rerun.
152
138
  #
153
- # A worker's copy goes in {Inference::ForkMap.child_scratch_dir} rather than the system temp dir: the
154
- # worker ends at `exit!`, which runs neither `at_exit` nor `Tempfile`'s finalizer, so a file placed
155
- # anywhere else survives the run with nobody left who knows its name (issue #330). On the parentand
156
- # on the sequential path that reader answers `nil`, which is `Tempfile.new`'s own default, and the
157
- # finalizer reclaims the file at normal exit as before.
158
- def mutant_file
159
- return @mutant_file if @mutant_file && @mutant_pid == Process.pid
160
-
161
- @mutant_pid = Process.pid
162
- @mutant_file = Tempfile.new(["rigor-mutant-", ".rb"], Inference::ForkMap.child_scratch_dir)
139
+ # Per-mutant rather than per-process is safe on both axes the per-process comment defended. Freshness:
140
+ # the digest that invalidates the mutated file's discovery bundle is taken over the file's CONTENT and
141
+ # the bundles are keyed by the LOGICAL path, so nothing depends on the physical name being stablea
142
+ # name that is never reused is strictly further from a stale answer than one rewritten in place. Cost:
143
+ # one `mkstemp` + `unlink` against the whole analysis-per-dependent this call already runs.
144
+ #
145
+ # The directory stays {Inference::ForkMap.child_scratch_dir}, and that is not redundant. A
146
+ # {CLI::MutationForkScan} worker unwinds this block on both paths it can take on its own — a normal
147
+ # return and the `StandardError` {Inference::ForkMap.run_worker} rescues — but a worker killed by a
148
+ # signal unwinds nothing, and its `exit!` runs neither `at_exit` nor a finalizer. The parent's fork
149
+ # tmpdir stays the backstop for exactly that case (issue #330); on the parent and on the sequential
150
+ # path the reader answers `nil`, which is `Tempfile.create`'s own default.
151
+ def with_mutant(source, path)
152
+ Tempfile.create(["rigor-mutant-", ".rb"], Inference::ForkMap.child_scratch_dir) do |file|
153
+ file.binmode
154
+ file.write(source)
155
+ file.flush
156
+ buffer = Analysis::BufferBinding.new(logical_path: path, physical_path: file.path)
157
+ yield(buffer, seed_for(buffer))
158
+ end
163
159
  end
164
160
 
165
161
  def seed_for(buffer)
@@ -179,11 +175,20 @@ module Rigor
179
175
  # and emits nothing. A closure oracle built that way would report zero cross-file kills and look
180
176
  # entirely plausible doing it. Passing both selects option B (#146), where the closure wins and the
181
177
  # buffer is one member of it.
178
+ #
179
+ # Issue #686 — the guard is on this line for the reason the whole class exists. `killed?` decides by
180
+ # SET DIFFERENCE between the baseline's signatures and the mutant's, and a crashed check rule makes
181
+ # both sides the same deterministic `internal analyzer error` row on the same file: the difference is
182
+ # empty and the mutant is scored a survivor, not "indeterminate". Every mutant in the file scores the
183
+ # same way, which inflates the survivor count the harness reports as its headline signal.
182
184
  def analyse(paths, buffer, seed)
183
- Analysis::Runner.new(
184
- configuration: @configuration, environment: @environment, prebuilt: @project_scan,
185
- cache_store: nil, collect_stats: false, buffer: buffer, discovery_seed: seed, analyze_only: paths
186
- ).run(paths).diagnostics
185
+ AnalysisGuard.checked(
186
+ Analysis::Runner.new(
187
+ configuration: @configuration, environment: @environment, prebuilt: @project_scan,
188
+ cache_store: nil, collect_stats: false, buffer: buffer, discovery_seed: seed, analyze_only: paths
189
+ ).run(paths),
190
+ context: "ClosureKillOracle closure analysis of #{paths.join(', ')}"
191
+ )
187
192
  end
188
193
  end
189
194
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../analysis/runner"
4
+ require_relative "analysis_guard"
4
5
  require_relative "kill_signature"
5
6
 
6
7
  module Rigor
@@ -42,11 +43,18 @@ module Rigor
42
43
 
43
44
  private
44
45
 
46
+ # Issue #686 — the diagnostics of a CRASHED run are not an answer, and this oracle's whole job is to
47
+ # compare two runs' diagnostics. Both sides of that comparison come through here, so refusing once
48
+ # here covers `#baseline` and `#killed?` alike; {MutationScanner} turns the raise into a
49
+ # `harness_errors` count rather than a kill or a survivor.
45
50
  def analyse(source, path)
46
- Rigor::Analysis::Runner.new(
47
- configuration: @configuration, environment: @environment, prebuilt: @project_scan,
48
- cache_store: nil, collect_stats: false, discovery_seed: @discovery_seed
49
- ).run_source(source: source, path: path).diagnostics
51
+ AnalysisGuard.checked(
52
+ Rigor::Analysis::Runner.new(
53
+ configuration: @configuration, environment: @environment, prebuilt: @project_scan,
54
+ cache_store: nil, collect_stats: false, discovery_seed: @discovery_seed
55
+ ).run_source(source: source, path: path),
56
+ context: "DiagnosticOracle re-analysis of #{path}"
57
+ )
50
58
  end
51
59
  end
52
60
  end
@@ -98,7 +98,7 @@ module Rigor
98
98
  def_index = index.fetch(:def_index)
99
99
  tables = { discovered_classes: index.fetch(:classes) }
100
100
  %i[
101
- def_nodes singleton_def_nodes def_sources singleton_def_sources superclasses includes
101
+ def_nodes def_nestings singleton_def_nodes def_sources singleton_def_sources superclasses includes
102
102
  method_visibilities methods data_member_layouts struct_member_layouts
103
103
  ].each do |slot|
104
104
  tables[seed_key(slot)] = def_index.fetch(slot)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Protection
5
+ # Issue #686 — the single definition of "was this file measured at all", and the one place the two
6
+ # counts' escalation rule is written down rather than restated.
7
+ #
8
+ # `killed + survived == 0` means two different things, and telling them apart is the whole point:
9
+ #
10
+ # - with no `harness_errors`, the file had no type-relevant mutation, and the vacuous-file convention
11
+ # scores it fully effective — there was no breakage available to miss;
12
+ # - with `harness_errors`, every mutant it had failed inside the harness, so the file measured the
13
+ # HARNESS and says nothing about the code. Borrowing the first's 1.0 made a wholly crashed file report
14
+ # 100% effective and pass `--threshold`.
15
+ #
16
+ # The predicate lives here because four call sites need the same answer — the per-file results, the exit
17
+ # gate, the stderr warning and both renderers — and four independent spellings of
18
+ # `harness_errors.positive? && total.zero?` agree only by coincidence (issue #696 review, second pass).
19
+ module MeasurementIntegrity
20
+ module_function
21
+
22
+ # @return [Boolean] false only when nothing could be measured AND something failed trying.
23
+ def measured?(total:, harness_errors:)
24
+ !(total.zero? && harness_errors.positive?)
25
+ end
26
+
27
+ # The report-level companion: a project ratio computed over nothing, where something went wrong.
28
+ # Distinct from an empty project, which is legitimately vacuous.
29
+ #
30
+ # @return [Boolean]
31
+ def ratio_unmeasurable?(grand_total:, unmeasured_files:)
32
+ grand_total.zero? && unmeasured_files.positive?
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "analysis_guard"
6
+ require_relative "measurement_integrity"
5
7
  require_relative "mutator"
6
8
  require_relative "diagnostic_oracle"
7
9
 
@@ -38,9 +40,21 @@ module Rigor
38
40
  # harness-level failure measures the harness, not the code, exactly like a parse-invalid mutant).
39
41
  def total = killed + survived
40
42
 
41
- # Effectiveness ratio; a file with no type-relevant mutation is vacuously fully effective (no breakage
42
- # was available to miss).
43
- def ratio = total.zero? ? 1.0 : killed.to_f / total
43
+ # Issue #686 "nothing was measured here" is NOT the same as "there was nothing to measure", and
44
+ # `total.zero?` cannot tell them apart on its own. A file with no type-relevant mutation is
45
+ # vacuously fully effective; a file whose every mutant landed in `harness_errors` measured the
46
+ # harness and says nothing about the code. Before this, the second borrowed the first's 1.0 and a
47
+ # wholly crashed file reported 100% effective.
48
+ def measured? = MeasurementIntegrity.measured?(total: total, harness_errors: harness_errors)
49
+
50
+ # Effectiveness ratio. 0.0 rather than 1.0 for an unmeasured file — both are wrong as a measurement,
51
+ # and only one of them turns a red `--threshold` gate green. Read it next to {#measured?}, which is
52
+ # what the renderer and the exit gate consult.
53
+ def ratio
54
+ return 0.0 unless measured?
55
+
56
+ total.zero? ? 1.0 : killed.to_f / total
57
+ end
44
58
  end
45
59
 
46
60
  # ADR-70 — one type-survivor classified by the dynamic (test) axis. `protection` is `:test` (a test caught
@@ -62,8 +76,15 @@ module Rigor
62
76
  def unprotected = sites.size
63
77
  def total = type_killed + test_killed + unprotected
64
78
 
79
+ # Issue #686 — see {FileResult#measured?}.
80
+ def measured? = MeasurementIntegrity.measured?(total: total, harness_errors: harness_errors)
81
+
65
82
  # Fused protected ratio — caught by *either* axis.
66
- def ratio = total.zero? ? 1.0 : (type_killed + test_killed).to_f / total
83
+ def ratio
84
+ return 0.0 unless measured?
85
+
86
+ total.zero? ? 1.0 : (type_killed + test_killed).to_f / total
87
+ end
67
88
  end
68
89
 
69
90
  # @param configuration [Rigor::Configuration]
@@ -114,7 +135,9 @@ module Rigor
114
135
  kept = kept_mutations(source, path)
115
136
  return FileResult.new(path: path, killed: 0, survived: 0, sites: []) if kept.empty?
116
137
 
117
- baseline = @oracle.baseline(source: source, path: path)
138
+ baseline = clean_baseline(source, path)
139
+ return crashed_baseline_result(path, kept.size) if baseline.nil?
140
+
118
141
  killed = 0
119
142
  harness_errors = 0
120
143
  sites = []
@@ -140,7 +163,12 @@ module Rigor
140
163
  kept = kept_mutations(source, path)
141
164
  return FusedFileResult.new(path: path, type_killed: 0, test_killed: 0, sites: []) if kept.empty?
142
165
 
143
- baseline = @oracle.baseline(source: source, path: path)
166
+ baseline = clean_baseline(source, path)
167
+ if baseline.nil?
168
+ return FusedFileResult.new(path: path, type_killed: 0, test_killed: 0, sites: [],
169
+ harness_errors: kept.size)
170
+ end
171
+
144
172
  type_killed = 0
145
173
  test_killed = 0
146
174
  harness_errors = 0
@@ -185,6 +213,12 @@ module Rigor
185
213
  # only the latter is a harness defect worth counting and surfacing. Both stay OUT of `killed + survived`
186
214
  # exactly as before (containment is unchanged) — #264 is about visibility, not about admitting either
187
215
  # bucket into the denominator.
216
+ #
217
+ # #686 — a {AnalysisGuard::AnalyzerCrashed} from the oracle's re-analysis arrives here as a
218
+ # `StandardError` and lands in the same bucket, which is exactly right: an indeterminate mutant must
219
+ # be scored neither killed nor survived. Before the oracle refused, a crashed re-analysis produced the
220
+ # same synthetic diagnostic on both sides of the kill comparison, so the mutant was scored a SURVIVOR
221
+ # and the harness reported a hole it had never measured.
188
222
  def classify(source, path, mut, baseline)
189
223
  mutant_source = mut.apply(source)
190
224
  return :invalid unless Prism.parse(mutant_source).success?
@@ -196,6 +230,27 @@ module Rigor
196
230
  :harness_error
197
231
  end
198
232
 
233
+ # The clean per-file baseline, or nil when the analyzer crashed while computing it (#686).
234
+ #
235
+ # The baseline is computed ONCE per file, outside {#classify}'s per-mutant rescue, so a crash here is
236
+ # not one mutant's problem: every mutant of this file would be judged against a baseline that knows
237
+ # nothing, and each would come back a survivor. Reported as `harness_errors` — one per mutant that
238
+ # would have been measured — rather than raised, so one bad file does not abort a whole sweep, and
239
+ # rather than silently skipped, so the count the CLI already warns about accounts for it.
240
+ #
241
+ # Deliberately narrow: only {AnalyzerCrashed}. Any other exception out of an oracle's baseline
242
+ # propagates exactly as it did before, because widening that rescue would hide unrelated harness bugs
243
+ # behind the very bucket this issue exists to make honest.
244
+ def clean_baseline(source, path)
245
+ @oracle.baseline(source: source, path: path)
246
+ rescue AnalyzerCrashed
247
+ nil
248
+ end
249
+
250
+ def crashed_baseline_result(path, mutant_count)
251
+ FileResult.new(path: path, killed: 0, survived: 0, sites: [], harness_errors: mutant_count)
252
+ end
253
+
199
254
  def sample(mutations)
200
255
  return mutations unless @limit
201
256