rigortype 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # Issues #665 / #674 / #683 / #686 / #696 — the ONE definition of "an internal failure made this run
6
+ # report less than it should have".
7
+ #
8
+ # Every member of that family shares a defect: the run still exits 0 and its diagnostic list still
9
+ # looks like an answer, so an absence assertion (`not_to include(...)`, `be_empty`, `all(eq(...))`)
10
+ # holds on it and a comparison of two such runs (`baseline == mutant`, `recheck == full_run`) reports
11
+ # "no difference" — because both sides carry the same synthetic row, not because the code agreed. Four
12
+ # separate consumers needed to recognise these shapes, and before this module each recognised them by
13
+ # matching a diagnostic MESSAGE with its own string literal. A fourth copy is what this exists to stop:
14
+ # a reworded message silently disarms whichever copies were not updated, and nothing goes red.
15
+ #
16
+ # ## The three shapes
17
+ #
18
+ # - `:check_rule` — message begins `internal analyzer error`, `rule: nil`. Built by
19
+ # `Runner#analyze_file_body` / `WorkerSession#analyze_body`'s `rescue StandardError`.
20
+ # - `:plugin` — `severity: :error`, `source_family: :plugin_loader`, `rule: "runtime-error"`. Built by
21
+ # `Runner#collect_plugin_diagnostics` / `Runner::ProjectPrePasses#invoke_plugin_prepare`.
22
+ # - `:rbs_build` — `rule` in {RBS_BUILD_FAILURE_RULES}. Recorded by `Environment::RbsLoader`'s
23
+ # env-build and definition-build rescues, surfaced by {Runner::DiagnosticAggregator}.
24
+ #
25
+ # The `:plugin` shape is keyed on the structured `(severity, source_family, rule)` triple rather than on
26
+ # the bare rule name: a plugin is free to define its OWN `"runtime-error"` (or `"load-error"`) under its
27
+ # own `source_family: "plugin.<id>"`, and several specs legitimately assert on one. Only the
28
+ # `:plugin_loader` family paired with `"runtime-error"` is the isolation envelope's own row.
29
+ #
30
+ # ## Only ONE shape discards a file's analysis
31
+ #
32
+ # {.discards_file_analysis?} answers true for `:check_rule` alone, and the distinction is not pedantry —
33
+ # it decides whether a consumer may still read the run's diagnostics.
34
+ #
35
+ # - `:check_rule` — `Runner#analyze_file_body` rescues around the WHOLE per-file body, so what comes
36
+ # back is `[crash_row]` and every diagnostic that file would have produced is gone. Nothing is left to
37
+ # read.
38
+ # - `:plugin` — `collect_plugin_diagnostics` replaces only the raising PLUGIN's contribution;
39
+ # `CheckRules.diagnose` has already returned, and `invoke_plugin_prepare` adds one row at `.rigor.yml`
40
+ # and lets the run proceed. The builtin rules ran, and their diagnostics are all still there. A
41
+ # consumer that refuses the whole run over this throws away a real measurement — and because the
42
+ # prepare row is appended to every sequential run, it would refuse every run for the life of the
43
+ # process.
44
+ # - `:rbs_build` — the analysis ran to completion and every rule fired, over a type universe missing one
45
+ # class (`rbs.coverage.definition-build-failed`), all of them
46
+ # (`rbs.coverage.environment-build-failed`), or only the HKT registrations the `type` aliases and the
47
+ # loaded plugins' manifests would have contributed (`rbs.coverage.hkt-scan-failed`, issues #784 and
48
+ # #791). The first two are degradations
49
+ # the user causes and the diagnostic itself reports; a project can sit on one for a release while it
50
+ # fixes its `sig/`. The third is NOT the user's: post-#783 the scan raising is an analyzer defect, and
51
+ # the overlay stage is a defect in Rigor or in a plugin the user installed, never in their `sig/`. It
52
+ # still belongs here rather than under `:check_rule`, because the question this classification
53
+ # answers is "may a consumer still read the run's diagnostics?" — and it may: every rule fired. What
54
+ # differs is whether the run is a valid measurement OF RIGOR, which is {.analyzer_defect?}'s question.
55
+ #
56
+ # The two consumer tiers therefore differ on purpose. The ADR-69 kill oracles arm `:check_rule` only:
57
+ # refusing a run they could still have measured is the same "manufactures work" error as scoring an
58
+ # unmeasured mutant a survivor, pointed the other way (issue #686 review). The spec harness additionally
59
+ # arms `:plugin`, which is suite POLICY rather than a claim about the diagnostics — no spec has a reason
60
+ # to want a plugin crashing under it, and `allow_plugin_crash:` is the opt-out for the handful whose
61
+ # subject IS the isolation envelope.
62
+ module CrashSignature
63
+ # The `rescue StandardError` in `Runner#analyze_file_body` / `WorkerSession#analyze_body` folds a
64
+ # raising check rule (or a plugin's node-rule contribution) into ONE diagnostic per file with this
65
+ # message prefix. Matched on the prefix alone, exactly as the pre-#696 guards did: adding a `rule.nil?`
66
+ # conjunct would NARROW the match, and the whole point of a guard against a bug is that it stays armed
67
+ # when the shape shifts.
68
+ CHECK_RULE_MESSAGE_PREFIX = "internal analyzer error"
69
+
70
+ PLUGIN_SOURCE_FAMILY = :plugin_loader
71
+ PLUGIN_RULE = "runtime-error"
72
+
73
+ # The `rbs.coverage.*` rules that mean declared types went missing from this run — the implicit-HKT
74
+ # scan over `type` aliases (#784), a per-class definition build (#696), and the env-wide collapse.
75
+ # Ordered widest consequence last, the way the rows sit in `docs/type-specification/diagnostic-policy.md`.
76
+ RBS_BUILD_FAILURE_RULES = %w[
77
+ rbs.coverage.hkt-scan-failed
78
+ rbs.coverage.definition-build-failed
79
+ rbs.coverage.environment-build-failed
80
+ ].freeze
81
+
82
+ # The one reason that means a file's whole diagnostic list was replaced by a crash row. `:plugin` and
83
+ # `:rbs_build` both leave a readable run behind — see the class doc.
84
+ DISCARDS_FILE_ANALYSIS_REASON = :check_rule
85
+
86
+ # The `:rbs_build` rules whose cause is Rigor, not the user's `sig/` (issue #784). Readable for the
87
+ # user — every rule fired — but NOT a valid measurement of Rigor itself, so a harness that scores
88
+ # Rigor's behaviour should treat one of these as a crash finding the way it treats `:check_rule`, or
89
+ # a mutant that re-breaks the HKT scan (#776 was one) scores as a measurement over a silently degraded
90
+ # universe. What consults it today: `tool/mutation`'s fuzz crash detector — on the severity-resolved
91
+ # stream, so a `severity_overrides: rbs: off` hides the row from it. What does NOT yet: the ADR-69
92
+ # kill oracle (`Protection::AnalysisGuard` reads `Result#crashed?`, which excludes every
93
+ # `:rbs_build` row), and both harnesses still reuse one Environment across mutants, which memoises
94
+ # the degraded registry after the first defect. Arming the oracle, inspecting the pre-severity row,
95
+ # and resetting the Environment are issue #790.
96
+ ANALYZER_DEFECT_RULES = %w[
97
+ rbs.coverage.hkt-scan-failed
98
+ ].freeze
99
+
100
+ module_function
101
+
102
+ # The message `Runner#analyze_file_body` / `WorkerSession#analyze_body` fold a raised `StandardError`
103
+ # into. Built here, not at either rescue site, so the two twins cannot drift (issue #665) — and so
104
+ # the appended crash frame is derived identically on the sequential and pooled paths. Keeps the
105
+ # {CHECK_RULE_MESSAGE_PREFIX} prefix every consumer matches on; the frame is a trailing hint.
106
+ #
107
+ # @param error [StandardError]
108
+ # @return [String]
109
+ def check_rule_message(error)
110
+ base = "#{CHECK_RULE_MESSAGE_PREFIX}: #{error.class}: #{error.message}"
111
+ frame = crash_frame(error)
112
+ frame ? "#{base} (#{frame})" : base
113
+ end
114
+
115
+ # The first `lib/rigor/` backtrace frame — the raise site, path made repo-relative so it reads the
116
+ # same whether Rigor runs from a checkout or an installed gem. A LOCATION, not an attribution: a
117
+ # bundled or third-party plugin also lives under `lib/rigor/<plugin>/`, so a frame here says where
118
+ # the raise was, never whose defect it is. Falls back to the raw top frame when the crash is entirely
119
+ # inside a dependency, and to nil when there is no backtrace at all.
120
+ #
121
+ # @param error [Exception]
122
+ # @return [String, nil]
123
+ def crash_frame(error)
124
+ frames = error.backtrace
125
+ return nil if frames.nil? || frames.empty?
126
+
127
+ relativize_frame(frames.find { |f| f.include?("/lib/rigor/") } || frames.first)
128
+ end
129
+
130
+ # The repo-relative half of {.crash_frame}, pulled out on its own so a second caller — the #784
131
+ # `rbs.coverage.hkt-scan-failed` diagnostic, which stores its raw frame across a `Marshal` boundary
132
+ # (the fork pool) rather than deriving it fresh from a live `Exception` — can relativize the frame it
133
+ # already has without re-deriving `.crash_frame`'s "which frame" choice. Nil-safe, and a no-op (`sub`
134
+ # never matches) on a frame with no `lib/rigor/` segment at all.
135
+ #
136
+ # @param frame [String, nil]
137
+ # @return [String, nil]
138
+ def relativize_frame(frame)
139
+ frame&.sub(%r{\A.*/(lib/rigor/)}, '\1')
140
+ end
141
+
142
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
143
+ # @return [Symbol, nil] `:check_rule`, `:plugin`, `:rbs_build`, or nil for an ordinary diagnostic.
144
+ def reason(diagnostic)
145
+ return :check_rule if diagnostic.message.to_s.start_with?(CHECK_RULE_MESSAGE_PREFIX)
146
+ return :plugin if plugin_isolation_row?(diagnostic)
147
+ return :rbs_build if RBS_BUILD_FAILURE_RULES.include?(diagnostic.rule)
148
+
149
+ nil
150
+ end
151
+
152
+ # True when `diagnostic` is the rescue row that REPLACED a file's analysis — the only shape after which
153
+ # the run's diagnostics say nothing about the code. NOT a general "something went wrong" predicate; see
154
+ # the class doc for why `:plugin` and `:rbs_build` are excluded.
155
+ #
156
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
157
+ def discards_file_analysis?(diagnostic)
158
+ reason(diagnostic) == DISCARDS_FILE_ANALYSIS_REASON
159
+ end
160
+
161
+ # True when `diagnostic` reports a failure inside Rigor that left the run readable but invalid as a
162
+ # measurement of Rigor — see {ANALYZER_DEFECT_RULES}. Orthogonal to {.discards_file_analysis?}: the
163
+ # user-facing tier reads the run; the Rigor-measuring tier should refuse it (the mutation fuzz does,
164
+ # the ADR-69 kill oracle does not yet — #790).
165
+ #
166
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
167
+ def analyzer_defect?(diagnostic)
168
+ ANALYZER_DEFECT_RULES.include?(diagnostic.rule)
169
+ end
170
+
171
+ # A one-line "<reason> at <path>:<line>: <message>" for a failure message, so whoever reads the raise
172
+ # sees which shape fired and where without re-deriving it.
173
+ #
174
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
175
+ # @return [String]
176
+ def describe(diagnostic)
177
+ "#{reason(diagnostic) || :unknown} at #{diagnostic.path}:#{diagnostic.line}: #{diagnostic.message}"
178
+ end
179
+
180
+ def plugin_isolation_row?(diagnostic)
181
+ diagnostic.severity == :error &&
182
+ diagnostic.source_family == PLUGIN_SOURCE_FAMILY &&
183
+ diagnostic.rule == PLUGIN_RULE
184
+ end
185
+ private_class_method :plugin_isolation_row?
186
+ end
187
+ end
188
+ end
@@ -168,9 +168,17 @@ module Rigor
168
168
  accumulate_read(accumulator, path, symbol)
169
169
  end
170
170
 
171
- # Records a cross-file lookup of `name` (kind `:method` / `:class` / `:const` / …) that resolved to
172
- # nothing a negative dependency.
173
- def read_missing(kind, name)
171
+ # Records a NAME-KEYED cross-file dependency: this consumer's answer depends on what the project makes
172
+ # of `name` under `kind`, so a recheck must re-analyze it when that changes. The recorded key is
173
+ # inverted into `negative_dependents` and matched against the producer for its kind.
174
+ #
175
+ # For `:method` / `:class` / `:toplevel` this is recorded only on a MISS ({read_missing}) — a resolved
176
+ # method or class already carries a positive edge to its definition site, which covers every later
177
+ # change to it. Issue #644's `:constant` kind is recorded on a HIT as well, because a constant's
178
+ # published value is a function of the WHOLE project's write set for the name, not of the one file that
179
+ # currently wins: a second declarer appearing in a file the reader has no edge to must drop the name to
180
+ # `Dynamic` at that reader, and only a name-keyed edge can see it.
181
+ def read_name(kind, name)
174
182
  accumulator = Thread.current[KEY]
175
183
  return if accumulator.nil?
176
184
 
@@ -179,6 +187,13 @@ module Rigor
179
187
  accumulator.missing.add(entry)
180
188
  end
181
189
 
190
+ # The miss-only spelling of {read_name}: a cross-file lookup of `name` (kind `:method` / `:class` /
191
+ # `:toplevel` / …) that resolved to nothing. Kept as the name the internal spec's negative-edge
192
+ # contract uses.
193
+ def read_missing(kind, name)
194
+ read_name(kind, name)
195
+ end
196
+
182
197
  # The positive-edge aggregation shared by {read_site} and {replay}.
183
198
  def accumulate_read(accumulator, path, symbol)
184
199
  accumulator.sources << path
@@ -139,7 +139,8 @@ module Rigor
139
139
  def descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
140
140
  name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
141
141
  if name && node.body
142
- walk_node(node.body, qualified_prefix + [name], in_singleton_class, accumulator, budget)
142
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
143
+ walk_node(node.body, child_prefix, in_singleton_class, accumulator, budget)
143
144
  else
144
145
  walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
145
146
  end
@@ -120,6 +120,34 @@ module Rigor
120
120
  appeared.freeze
121
121
  end
122
122
 
123
+ # Issue #644 — the qualified constant names whose PUBLICATION could have moved in this edit: the names
124
+ # whose per-file census descriptor differs between the before- and after-state of any file in `paths`.
125
+ # The value-constant twin of {appeared_classes}, and deliberately NOT its analogue — a class either
126
+ # exists or does not, whereas a constant's published answer is a function of the whole project's write
127
+ # set for the name, so a *name* diff is the wrong question. All four ways the answer moves are a
128
+ # descriptor change and none of them is an appearance:
129
+ #
130
+ # - the value edited (`[50]` -> `[7]`),
131
+ # - the write becoming unpublishable or publishable again (`[50]` <-> `:unpublishable`),
132
+ # - a SECOND declarer appearing, which drops the name to `Dynamic` (`{}` -> `{NAME => …}` in that file),
133
+ # - a second declarer going away, which restores the precise answer.
134
+ #
135
+ # `paths` MUST therefore include removed files as well as changed and added ones; a removed file's
136
+ # after-state is simply absent, so its whole before-census counts as changed. Returns a frozen Set of
137
+ # qualified-name Strings. `before` / `after` map a path to `{name => descriptor}`.
138
+ def changed_constant_publications(paths, before, after)
139
+ moved = Set.new
140
+ paths.each do |path|
141
+ was = before[path] || {}
142
+ now = after[path] || {}
143
+ # `eql?`, not `==`: `[1] == [1.0]` is TRUE, so `LIMIT = 1` becoming `LIMIT = 1.0` would move the
144
+ # published type (`Constant[1]` -> `Constant[1.0]`) while this diff called it unchanged. The
145
+ # declaration signature backstops that case today; the producer must not depend on it.
146
+ (was.keys | now.keys).each { |name| moved << name unless was[name].eql?(now[name]) }
147
+ end
148
+ moved.freeze
149
+ end
150
+
123
151
  # ADR-46 slice 3 — the consumers to re-check because a name they looked up and *missed* (a negative
124
152
  # dependency) now resolves. `keys` is the set of negative-dependency keys (`"toplevel:foo"` /
125
153
  # `"method:C#m"`) the appeared symbols would satisfy; `negative_dependents` maps each key to the Set of
@@ -98,6 +98,10 @@ module Rigor
98
98
  @missing = {} # consumer => Set<"kind:name"> it looked up and missed
99
99
  @negative_dependents = {} # "kind:name" => Set<consumer> (inverted @missing)
100
100
  @class_decls = {} # path => Set<qualified class name declared in the file>
101
+ # Issue #644 — the value-constant twin: path => { qualified constant name => publication descriptor }.
102
+ # Diffed on a recheck to find every constant whose PUBLISHED answer could have moved, which satisfies
103
+ # the `constant:` edges a reader records at every reference, resolved or not.
104
+ @constant_decls = {}
101
105
  # ADR-89 WD2 — per-def observed-key return summaries: [path, symbol] => { keys:, returns:, effects: }.
102
106
  # Harvested from the ADR-84 return memo after each run; drives the behavioural-stability gate.
103
107
  @return_summaries = {}
@@ -168,7 +172,7 @@ module Rigor
168
172
  # ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
169
173
  @effect_collections = runner.effect_collections_by_path
170
174
  @effects_identity = current_effects_identity
171
- @cache = per_file(diagnostics)
175
+ @cache = per_file(runner.per_file_diagnostics)
172
176
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
173
177
  diagnostics
174
178
  end
@@ -198,7 +202,7 @@ module Rigor
198
202
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
199
203
  reused = (current & previous) - affected.to_a
200
204
  merged = fresh + reused.flat_map { |path| @cache[path] || [] }
201
- absorb(runner, fresh, current, analyze_set, removed)
205
+ absorb(runner, current, analyze_set, removed)
202
206
  @param_table = fresh_params
203
207
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
204
208
  removed: removed.to_set, affected: affected, reused: reused.to_set)
@@ -259,6 +263,7 @@ module Rigor
259
263
  declaration_signatures = (summary && summary[:declaration_signatures]) || {}
260
264
  new_fps = symbol_fingerprints_from_index(scan_index)
261
265
  new_class_decls = class_declarations_from_index(scan_index)
266
+ new_constant_decls = constant_declarations_from_index(scan_index)
262
267
  changed_pairs = Incremental.changed_symbol_pairs(changed, @symbol_fingerprints, new_fps)
263
268
  # ADR-89 WD1 — a changed file whose DECLARATION signature (per-def parameter shape / visibility /
264
269
  # ancestry / member layout / def line, bodies excluded) is byte-identical to the snapshot is
@@ -275,7 +280,8 @@ module Rigor
275
280
  # minus those stable pairs, and only it (not `changed_pairs`) drives the symbol-dependent fan-out.
276
281
  symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index) | param_pairs
277
282
  base = dependents_base(unstable, symbol_pairs)
278
- closure = base | changed.to_set | added.to_set | negative_affected(scan, new_fps, new_class_decls)
283
+ closure = base | changed.to_set | added.to_set |
284
+ negative_affected(scan, removed, new_fps, new_class_decls, new_constant_decls)
279
285
  closure = param_seed_closure(closure, param_files)
280
286
  removed.each { |path| closure |= @dependents[path] || Set.new }
281
287
  closure.freeze
@@ -550,12 +556,7 @@ module Rigor
550
556
  @symbol_sources = payload.symbol_sources || {}
551
557
  @ancestry_sources = payload.ancestry_sources || {}
552
558
  @symbol_fingerprints = payload.symbol_fingerprints || {}
553
- # ADR-46 slice 3 — restore negative edges if present (absent in pre-slice-3 snapshots → empty, which
554
- # only loses the appeared-symbol re-check refinement; such a snapshot is never loaded by a slice-3+
555
- # engine because the engine version + schema is part of the snapshot fingerprint, and
556
- # `--verify-incremental` backstops any residual under-capture, so it is never unsound).
557
- @missing = payload.missing || {}
558
- @class_decls = payload.class_decls || {}
559
+ restore_structural_declarations(payload)
559
560
  @return_summaries = payload.return_summaries || {}
560
561
  @param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
561
562
  # ADR-103 WD13 / #382 — the effects sidecar rides its own identity, so a snapshot whose summaries
@@ -569,12 +570,24 @@ module Rigor
569
570
  @negative_dependents = Incremental.invert(@missing)
570
571
  end
571
572
 
573
+ # ADR-46 slice 3 / issue #644 — the structural tier's negative-edge state: the per-consumer missed
574
+ # lookups plus the two per-file DECLARATION sets (`class_decls`, `constant_decls`) whose diff says what
575
+ # APPEARED in an edit. Absent in an older snapshot → empty, which only loses the appeared-symbol
576
+ # refinement; such a snapshot is never loaded by a newer engine (the version + SCHEMA are part of the
577
+ # fingerprint) and `--verify-incremental` backstops any residual under-capture. Extracted to keep
578
+ # {#restore} under its ABC budget.
579
+ def restore_structural_declarations(payload)
580
+ @missing = payload.missing || {}
581
+ @class_decls = payload.class_decls || {}
582
+ @constant_decls = payload.constant_decls || {}
583
+ end
584
+
572
585
  def to_payload
573
586
  Cache::IncrementalSnapshot::Payload.new(
574
587
  cache: @cache, sources: @sources, digests: @digests, analyzed: @analyzed,
575
588
  symbol_sources: @symbol_sources, ancestry_sources: @ancestry_sources,
576
589
  symbol_fingerprints: @symbol_fingerprints, missing: @missing,
577
- class_decls: @class_decls, seed_bundles: @seed_bundles,
590
+ class_decls: @class_decls, constant_decls: @constant_decls, seed_bundles: @seed_bundles,
578
591
  plugin_fact_digest: @plugin_fact_digest,
579
592
  return_summaries: marshal_safe_return_summaries,
580
593
  param_table: marshal_safe_param_table,
@@ -671,13 +684,13 @@ module Rigor
671
684
  # Fold a #recheck's fresh results back into the cache + graph so the session is correct across
672
685
  # multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
673
686
  # are evicted from every map, and the analyzed-file list advances to `current`.
674
- def absorb(runner, fresh, current, analyze_set, removed)
687
+ def absorb(runner, current, analyze_set, removed)
675
688
  removed.each { |path| forget(path) }
676
689
  @analyzed = current
677
690
  # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
678
691
  # re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale.
679
692
  @seed_bundles = runner.seed_bundles
680
- fresh_by_file = per_file(fresh)
693
+ fresh_by_file = per_file(runner.per_file_diagnostics)
681
694
  analyze_set.each do |path|
682
695
  @cache[path] = fresh_by_file[path] || []
683
696
  @digests[path] = pack_digest(path)
@@ -754,6 +767,7 @@ module Rigor
754
767
  # Wholesale replace (the subset runner's pre-pass is complete): a file that lost its last class must
755
768
  # drop out of the map so a later re-add registers as an appearance.
756
769
  @class_decls = runner.class_declarations
770
+ @constant_decls = runner.constant_declarations
757
771
  end
758
772
 
759
773
  # Per-symbol body fingerprints from the pre-parsed `index` (the shared scan-summary def-index — Prism
@@ -792,11 +806,18 @@ module Rigor
792
806
  # a prior missed lookup. Maps each appeared `"ClassName#method"` to the negative-dependency key it
793
807
  # would satisfy (`toplevel:foo` for a top-level def, `method:C#m` otherwise), then unions the recorded
794
808
  # negative-dependents of those keys.
795
- def negative_affected(changed, new_fingerprints, new_class_decls)
809
+ def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls)
796
810
  appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
797
811
  appeared_classes = Incremental.appeared_classes(changed, @class_decls, new_class_decls)
812
+ # Issue #644 — a constant whose PUBLICATION moved satisfies the `constant:` kind, keyed on the same
813
+ # last segment the consumer recorded. REMOVED files join the diff here and nowhere else: deleting a
814
+ # second declarer restores the precise answer, and no other producer input can see that.
815
+ moved_constants = Incremental.changed_constant_publications(
816
+ changed + removed, @constant_decls, new_constant_decls
817
+ )
798
818
  keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
799
819
  keys.concat(appeared_classes.map { |klass| "class:#{klass.split('::').last}" })
820
+ keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
800
821
  Incremental.negative_closure(keys, @negative_dependents)
801
822
  end
802
823
 
@@ -812,6 +833,20 @@ module Rigor
812
833
  result.transform_values(&:freeze).freeze
813
834
  end
814
835
 
836
+ # Issue #644 — the per-file PUBLICATION CENSUS of the pre-parsed `index`:
837
+ # `{ path => { constant name => descriptor } }`, the twin of {#class_declarations_from_index}. The
838
+ # descriptor is what the diff compares — see {Incremental.changed_constant_publications} for why a bare
839
+ # name set cannot answer the question.
840
+ def constant_declarations_from_index(index)
841
+ return {} if index.nil?
842
+
843
+ result = Hash.new { |hash, key| hash[key] = {} }
844
+ index[:constant_writes].each do |name, by_path|
845
+ by_path.each { |path, descriptor| result[path][name] = descriptor }
846
+ end
847
+ result.transform_values(&:freeze).freeze
848
+ end
849
+
815
850
  # ADR-89 WD2 — the (live, freshly parsed) def node for a `"Class#method"` / `"Class.method"` symbol in
816
851
  # the changed-set scan index, or nil. Instance methods live under `def_nodes`, singleton under
817
852
  # `singleton_def_nodes`, both keyed by class name then method Symbol.
@@ -949,9 +984,21 @@ module Rigor
949
984
  @paths ? runner.run(@paths) : runner.run
950
985
  end
951
986
 
952
- # Group diagnostics by their file path, keeping only those whose path is an analyzed project file —
953
- # run-level streams (the gem-RBS info diagnostic, keyed on `.rigor.yml`) are recomputed fresh every
954
- # run and must not be served from the per-file cache.
987
+ # Group the runner's PER-FILE diagnostics by path, keeping only analyzed project files.
988
+ #
989
+ # The argument is `Runner#per_file_diagnostics` what `PoolCoordinator#analyze_files` returned, already
990
+ # severity-resolved as the run's stream is (this cache serves reused files without re-stamping, so a
991
+ # raw row would resurrect an `:off` rule and lose every override) — never the run's full stream. Every
992
+ # run-level row is regenerated by every run and must not be served
993
+ # from this cache, and "run-level" is a property of where a row was PRODUCED, not of where it is
994
+ # positioned: the `.rigor.yml` rows the slice below drops are the common case, but
995
+ # `effect.annotations-unchecked` sits at the first annotated file and the two `source-rbs-*` rows sit
996
+ # at the source file that carried the annotation, and all three are produced once per run off the
997
+ # environment and its reporters. Slicing the full stream cached them, and once a narrowed run's
998
+ # environment carried the whole project's synthesized RBS (#793) each was regenerated for a file the
999
+ # recheck did not analyse and met its cached twin in the merge — two rows, `--verify-incremental` red,
1000
+ # on the default configuration with the bundled rbs-inline plugin (#788 rounds 5 and 6; a rule-id
1001
+ # denylist missed the second pair). Feeding only the per-file stream makes the split structural.
955
1002
  def per_file(diagnostics)
956
1003
  diagnostics.group_by(&:path).slice(*@analyzed)
957
1004
  end
@@ -151,7 +151,7 @@ module Rigor
151
151
  # value actually moves (a new action, a changed sig), which is what a cached diagnostic can depend on;
152
152
  # a value-preserving edit keeps the recheck incremental. Returns true when the plugin declared at least
153
153
  # one producer (a surface); a producer whose value cannot be digested marks the plugin opaque.
154
- # rubocop:disable Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
154
+ # rubocop:disable-next Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
155
155
  def collect_producer_parts(plugin, id, parts, opaque)
156
156
  producer_ids = plugin.class.producers.keys
157
157
  return false if producer_ids.empty?
@@ -163,7 +163,6 @@ module Rigor
163
163
  end
164
164
  true
165
165
  end
166
- # rubocop:enable Naming/PredicateMethod
167
166
 
168
167
  # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the
169
168
  # plugin defines it (it has a surface).
@@ -81,7 +81,8 @@ module Rigor
81
81
  next if use.name.nil?
82
82
 
83
83
  Scan::Reference.new(as_written: use.name.sub(/\A::/, ""), nesting: [].freeze, from: nil,
84
- role: :production, path: use.path, line: use.line)
84
+ role: :production, path: use.path, line: use.line,
85
+ rooted: use.name.start_with?("::"))
85
86
  end
86
87
  end
87
88
 
@@ -148,7 +149,7 @@ module Rigor
148
149
  @references.each do |ref|
149
150
  next unless yield(ref)
150
151
 
151
- target = resolve(ref.as_written, ref.nesting)
152
+ target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
152
153
  set << target if target && @owned.include?(target)
153
154
  end
154
155
  set
@@ -157,7 +158,7 @@ module Rigor
157
158
  # `[from_fqn_or_nil, to_fqn, role]` for every reference that resolves to an owned declaration.
158
159
  def resolved_edges
159
160
  @resolved_edges ||= @references.filter_map do |ref|
160
- target = resolve(ref.as_written, ref.nesting)
161
+ target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
161
162
  next unless target && @owned.include?(target)
162
163
  next if ref.from == target # a declaration referencing itself is not evidence of use
163
164
 
@@ -197,8 +198,16 @@ module Rigor
197
198
  end
198
199
 
199
200
  # Ruby's constant lookup at name granularity: `Module.nesting` innermost first, then the ancestors of
200
- # the innermost cresting scope (#354), then the bare name.
201
- def resolve(as_written, nesting)
201
+ # the innermost cresting scope (#354), then the bare name. When `rooted: true`, lexical nesting and
202
+ # ancestors are skipped, answering the top-level declaration directly (#625).
203
+ def resolve(as_written, nesting, rooted: false)
204
+ if rooted
205
+ return as_written if @by_fqn.key?(as_written)
206
+
207
+ idx = as_written.rindex("::")
208
+ return idx ? resolve(as_written[0, idx], nesting, rooted: true) : nil
209
+ end
210
+
202
211
  walker = nesting.dup
203
212
  until walker.empty?
204
213
  candidate = (walker + [as_written]).join("::")
@@ -28,8 +28,11 @@ module Rigor
28
28
 
29
29
  # One constant reference. `from` is the fully-qualified name of the innermost enclosing declaration, or
30
30
  # nil for a reference written at file level — that is what makes the graph a reachability graph rather
31
- # than a reference count (ADR-102 WD2). `role` is the referring FILE's role (WD8).
32
- Reference = Data.define(:as_written, :nesting, :from, :role, :path, :line)
31
+ # than a reference count (ADR-102 WD2). `role` is the referring FILE's role (WD8). `rooted` is true
32
+ # when the constant was written with a leading `::` (#625).
33
+ Reference = Data.define(:as_written, :nesting, :from, :role, :path, :line, :rooted) do
34
+ def initialize(rooted: false, **) = super
35
+ end
33
36
 
34
37
  # ADR-102 WD4 — a site where a constant is reached by a mechanism the static reading cannot follow.
35
38
  # `name` is the exact constant when the argument is a literal (`"Foo".constantize`), in which case this
@@ -248,7 +251,8 @@ module Rigor
248
251
 
249
252
  @references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze,
250
253
  from: nesting.empty? ? nil : nesting.join("::"),
251
- role: @role, path: @path, line: node.location.start_line)
254
+ role: @role, path: @path, line: node.location.start_line,
255
+ rooted: Source::ConstantPath.rooted?(node))
252
256
  end
253
257
  end
254
258
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "crash_signature"
4
+
3
5
  module Rigor
4
6
  module Analysis
5
7
  class Result
@@ -17,6 +19,36 @@ module Rigor
17
19
  diagnostics.none?(&:error?)
18
20
  end
19
21
 
22
+ # Issue #686 — true when a check rule (or a plugin's node-rule contribution) raised, so
23
+ # `Runner#analyze_file_body`'s rescue REPLACED the affected file's whole diagnostic list with one
24
+ # synthetic row. What comes back is then not a weaker account of the code; it is no account of it.
25
+ #
26
+ # Any consumer that compares two results, or reads an EMPTY result as "clean", has to ask this first.
27
+ # A crashed baseline and a crashed mutant carry the same synthetic row, so `==` reports agreement; an
28
+ # absence assertion holds on the one-diagnostic list the rescue leaves behind. That is how a kill
29
+ # oracle scored survivors it never measured (#686) and how 1,177 spec examples passed with every check
30
+ # rule crashing (#674).
31
+ #
32
+ # Narrower than "the run reported a failure", deliberately. A raising PLUGIN loses only that plugin's
33
+ # contribution and a failed RBS definition build loses only a type — in both cases every builtin rule
34
+ # still ran and its diagnostics are still here, so a consumer that refused those would be discarding a
35
+ # real measurement. {CrashSignature} carries the full table and the reasoning.
36
+ #
37
+ # `success?` is deliberately NOT this question: a crash makes `success?` false (the row is `:error`),
38
+ # which is why the CLI is loud and CI goes red. It is the callers that read the diagnostic LIST rather
39
+ # than the exit code which need the predicate.
40
+ def crashed?
41
+ diagnostics.any? { |diagnostic| CrashSignature.discards_file_analysis?(diagnostic) }
42
+ end
43
+
44
+ # The diagnostics {#crashed?} answers true for, so a caller can name what it saw rather than only that
45
+ # it saw something.
46
+ #
47
+ # @return [Array<Rigor::Analysis::Diagnostic>]
48
+ def crash_diagnostics
49
+ diagnostics.select { |diagnostic| CrashSignature.discards_file_analysis?(diagnostic) }
50
+ end
51
+
20
52
  def error_count
21
53
  diagnostics.count(&:error?)
22
54
  end
@@ -14,8 +14,13 @@ end
14
14
  require_relative "../version"
15
15
  require_relative "../cache/descriptor"
16
16
  require_relative "../cache/engine_source"
17
+ require_relative "../cache/file_digest"
17
18
  require_relative "../cache/rbs_descriptor"
18
19
  require_relative "../environment/default_libraries"
20
+ # Both resolvers are leaf files (no requires beyond `yaml`), so the boot-slimming probe reaches the two
21
+ # lockfile paths without loading `rigor/environment` or the RBS machinery.
22
+ require_relative "../environment/lockfile_resolver"
23
+ require_relative "../environment/rbs_collection_discovery"
19
24
 
20
25
  module Rigor
21
26
  module Analysis
@@ -80,7 +85,7 @@ module Rigor
80
85
  def descriptor(configuration:, files:, explain:, rbs_config_entries:)
81
86
  Cache::Descriptor.new(
82
87
  gems: [Cache::RbsDescriptor.rbs_gem_entry],
83
- configs: rbs_config_entries + engine_source_entries + [
88
+ configs: rbs_config_entries + engine_source_entries + lockfile_entries(configuration) + [
84
89
  config_entry("configuration", Marshal.dump(configuration.to_h)),
85
90
  config_entry("engine",
86
91
  "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{explain}"),
@@ -106,6 +111,41 @@ module Rigor
106
111
  [config_entry("engine-source", identity)]
107
112
  end
108
113
 
114
+ # Issue #564 — the two dependency lockfiles, BY CONTENT. Both are inputs to the run's diagnostics and
115
+ # neither was represented in the key: the locked gem set decides which bundle-shipped `sig/` dirs and
116
+ # which `rbs_collection` dirs load, which ADR-72 gem overlays apply, what the ADR-82 WD9 missing-gem
117
+ # constant index owns, and whether `rbs.coverage.missing-gem` fires at all. Editing a lockfile touches
118
+ # no analyzed source and no `.rbs` file, so the `paths` slot, the `configuration` slot (which carries
119
+ # the lockfile PATH, never its bytes) and the recorded dependency descriptor were all unchanged by a
120
+ # `bundle add` — and a warm run replayed the pre-edit diagnostics in BOTH directions: an
121
+ # `undefined-method` on `3.minutes` surviving the `bundle add activesupport` that licenses the overlay,
122
+ # and the same call staying silent after the `bundle remove` that revokes it.
123
+ #
124
+ # A KEY slot rather than a dependency entry, because a dependency descriptor can only say "a file I
125
+ # recorded changed": it cannot express a lockfile that did not exist when the entry was written and
126
+ # does now, which is the `bundle init` / first-`bundle install` case.
127
+ #
128
+ # Content-only — the resolved PATH is deliberately not in the payload, so a checkout that moves (a CI
129
+ # runner's workspace, a renamed directory) still hits with an identical lockfile.
130
+ def lockfile_entries(configuration)
131
+ bundler = Environment::LockfileResolver.resolve_lockfile_path(
132
+ lockfile_path: configuration.bundler_lockfile, auto_detect: configuration.bundler_auto_detect
133
+ )
134
+ collection = Environment::RbsCollectionDiscovery.resolve_lockfile_path(
135
+ lockfile_path: configuration.rbs_collection_lockfile,
136
+ auto_detect: configuration.rbs_collection_auto_detect
137
+ )
138
+ [lockfile_entry("bundler.lockfile", bundler), lockfile_entry("rbs_collection.lockfile", collection)]
139
+ end
140
+
141
+ # `nil` — no lockfile resolves — is itself part of the identity, so it carries its own sentinel rather
142
+ # than dropping the slot: "absent" and "present but empty" must not key the same.
143
+ ABSENT_LOCKFILE = "\0absent"
144
+
145
+ def lockfile_entry(key, path)
146
+ config_entry(key, path.nil? ? ABSENT_LOCKFILE : Cache::FileDigest.hexdigest(path.to_s))
147
+ end
148
+
109
149
  def config_entry(key, payload)
110
150
  Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
111
151
  end