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
@@ -0,0 +1,116 @@
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`) or all of them
46
+ # (`rbs.coverage.environment-build-failed`). A degradation the user causes and the diagnostic itself
47
+ # reports; a project can sit on it for a release while it fixes its `sig/`.
48
+ #
49
+ # The two consumer tiers therefore differ on purpose. The ADR-69 kill oracles arm `:check_rule` only:
50
+ # refusing a run they could still have measured is the same "manufactures work" error as scoring an
51
+ # unmeasured mutant a survivor, pointed the other way (issue #686 review). The spec harness additionally
52
+ # arms `:plugin`, which is suite POLICY rather than a claim about the diagnostics — no spec has a reason
53
+ # to want a plugin crashing under it, and `allow_plugin_crash:` is the opt-out for the handful whose
54
+ # subject IS the isolation envelope.
55
+ module CrashSignature
56
+ # The `rescue StandardError` in `Runner#analyze_file_body` / `WorkerSession#analyze_body` folds a
57
+ # raising check rule (or a plugin's node-rule contribution) into ONE diagnostic per file with this
58
+ # message prefix. Matched on the prefix alone, exactly as the pre-#696 guards did: adding a `rule.nil?`
59
+ # conjunct would NARROW the match, and the whole point of a guard against a bug is that it stays armed
60
+ # when the shape shifts.
61
+ CHECK_RULE_MESSAGE_PREFIX = "internal analyzer error"
62
+
63
+ PLUGIN_SOURCE_FAMILY = :plugin_loader
64
+ PLUGIN_RULE = "runtime-error"
65
+
66
+ # The `rbs.coverage.*` rules that mean declared types went missing from this run — an env-wide
67
+ # collapse and its per-class sibling (#696). Ordered widest consequence last, the way the two rows sit
68
+ # in `docs/type-specification/diagnostic-policy.md`.
69
+ RBS_BUILD_FAILURE_RULES = %w[
70
+ rbs.coverage.definition-build-failed
71
+ rbs.coverage.environment-build-failed
72
+ ].freeze
73
+
74
+ # The one reason that means a file's whole diagnostic list was replaced by a crash row. `:plugin` and
75
+ # `:rbs_build` both leave a readable run behind — see the class doc.
76
+ DISCARDS_FILE_ANALYSIS_REASON = :check_rule
77
+
78
+ module_function
79
+
80
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
81
+ # @return [Symbol, nil] `:check_rule`, `:plugin`, `:rbs_build`, or nil for an ordinary diagnostic.
82
+ def reason(diagnostic)
83
+ return :check_rule if diagnostic.message.to_s.start_with?(CHECK_RULE_MESSAGE_PREFIX)
84
+ return :plugin if plugin_isolation_row?(diagnostic)
85
+ return :rbs_build if RBS_BUILD_FAILURE_RULES.include?(diagnostic.rule)
86
+
87
+ nil
88
+ end
89
+
90
+ # True when `diagnostic` is the rescue row that REPLACED a file's analysis — the only shape after which
91
+ # the run's diagnostics say nothing about the code. NOT a general "something went wrong" predicate; see
92
+ # the class doc for why `:plugin` and `:rbs_build` are excluded.
93
+ #
94
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
95
+ def discards_file_analysis?(diagnostic)
96
+ reason(diagnostic) == DISCARDS_FILE_ANALYSIS_REASON
97
+ end
98
+
99
+ # A one-line "<reason> at <path>:<line>: <message>" for a failure message, so whoever reads the raise
100
+ # sees which shape fired and where without re-deriving it.
101
+ #
102
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
103
+ # @return [String]
104
+ def describe(diagnostic)
105
+ "#{reason(diagnostic) || :unknown} at #{diagnostic.path}:#{diagnostic.line}: #{diagnostic.message}"
106
+ end
107
+
108
+ def plugin_isolation_row?(diagnostic)
109
+ diagnostic.severity == :error &&
110
+ diagnostic.source_family == PLUGIN_SOURCE_FAMILY &&
111
+ diagnostic.rule == PLUGIN_RULE
112
+ end
113
+ private_class_method :plugin_isolation_row?
114
+ end
115
+ end
116
+ 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
@@ -134,14 +134,25 @@ module Rigor
134
134
  end
135
135
 
136
136
  # Text rendering for `rigor check`. The qualified rule identifier (per ADR-2 § "Plugin Diagnostic
137
- # Provenance" — `plugin.<id>.<rule>`, `rbs_extended.<rule>`, `generated.<provider>.<rule>`) is appended
138
- # in brackets whenever the diagnostic carries a non-default `source_family`, so plugin / RBS::Extended
139
- # / generated provenance is visible in the standard text output without changing the layout for
140
- # built-in rules. Slice 5 (v0.1.0) wires this surface.
137
+ # Provenance" — `call.undefined-method`, `plugin.<id>.<rule>`, `rbs_extended.<rule>`,
138
+ # `generated.<provider>.<rule>`) is appended in brackets.
139
+ #
140
+ # Slice 5 (v0.1.0) introduced the bracket for non-builtin families only, "without changing the layout
141
+ # for built-in rules" — a layout-conservatism call made when provenance was the point, not a judgment
142
+ # that the identifier is noise. The consequence outlived the reason (#431): the exception covers the
143
+ # rules `docs/manual/04-diagnostics.md` tells the reader to suppress with `# rigor:disable <id>` and
144
+ # to key `severity_profile:` on, so the default output was the one place the identifier could not be
145
+ # read. A run could not be grepped for a rule either — the effect-system walkthrough grepped
146
+ # `effect\.` over a whole Redmine run, got nothing, and concluded the feature was broken.
147
+ #
148
+ # The cost is bounded and was measured before the change: on Redmine every diagnostic carries an
149
+ # identifier, only 13 distinct ones appear across the project, and the suffix adds 14 characters to a
150
+ # 165-character median line.
151
+ #
152
+ # `rule` is nil for diagnostics no rule produced — parse errors, path errors, internal analyzer
153
+ # errors — and those stay unsuffixed, because there is nothing to suppress or configure.
141
154
  def to_s
142
155
  base = "#{path}:#{line}:#{column}: #{severity}: #{message}"
143
- return base if source_family == DEFAULT_SOURCE_FAMILY
144
-
145
156
  qualified = qualified_rule
146
157
  return base if qualified.nil?
147
158
 
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_expansion"
4
+ require_relative "run_cache_key"
5
+ require_relative "../cache/store"
6
+ require_relative "../cache/file_digest"
7
+ require_relative "../effects/identity"
8
+ require_relative "../effects/plugin_facts"
9
+ require_relative "../effects/registry"
10
+ require_relative "../plugin"
11
+ require_relative "../plugin/loader"
12
+ require_relative "../plugin/services"
13
+ require_relative "../reflection"
14
+ require_relative "../type/combinator"
15
+
16
+ module Rigor
17
+ module Analysis
18
+ # ADR-104 — the boot-slimming probe for the effects surfaces, the shape ADR-87 WD4's
19
+ # {RunCacheProbe} gave `rigor check`.
20
+ #
21
+ # A warm `rigor effects` had almost no work left after #475 and #482 and still cost 2–2.5× a warm
22
+ # `rigor check`, because it loaded the inference engine to compute two cache keys and adopt two
23
+ # cached values. This serves the report from the #482 summary entry — the propagated table and the
24
+ # unit sources — loading configuration, the cache, the plugin loader and the `effects/*` value
25
+ # layer, and never `rigor/inference`.
26
+ #
27
+ # ## What makes an engine-free key possible
28
+ #
29
+ # The effects identity ({Effects::Identity.descriptor}) is the run's diagnostics key descriptor plus
30
+ # the vocabulary version, the catalogue identity, the `effects:` digest and {Effects::PluginFacts}'
31
+ # own digest. The first is what {RunCacheProbe} already computes without a loader; the rest are pure
32
+ # functions of shipped data, configuration, and the plugins' class-level declarations — a plugin's
33
+ # contributions are fixed by `init`, which the loader runs, so `#prepare` (the expensive half) is
34
+ # not needed. `PluginFacts#digest` is computed before the project's superclass table is even
35
+ # assigned to it, so the discovery tables a probe cannot build do not participate.
36
+ #
37
+ # ## Why a decline is always safe
38
+ #
39
+ # Every reason to decline — a key this path cannot reproduce, a miss, a stale dependency — ends in
40
+ # the caller running the full `Analysis::Runner`, which answers exactly as it does today. The one
41
+ # reproducibility gap is deliberate and inherited: a project whose plugins synthesise virtual RBS
42
+ # gets a key without the `rbs.virtual_rbs` slot the runner writes, so it misses here rather than
43
+ # matching something it should not.
44
+ #
45
+ # The rule the surfaces are held to (ADR-104's criterion): a probe may serve a surface only when
46
+ # every answer that surface can give is reproducible from stored values and declarations alone.
47
+ # `rigor effects` and the snapshot verbs qualify because their output is a pure function of the
48
+ # table, the sources, the configuration and the vocabulary — the envelope diagnostics the full path
49
+ # also computes are discarded by every one of them.
50
+ class EffectsCacheProbe
51
+ # What a served run hands back: the two cached tables, plus the vocabulary the caller would
52
+ # otherwise rebuild (the probe loaded the plugins to key the entry, so it already knows it).
53
+ Served = Data.define(:table, :sources, :registry, :plugin_facts)
54
+
55
+ # @param configuration [Rigor::Configuration] with effects already enabled by the caller.
56
+ # @param cache_root [String, nil]
57
+ def initialize(configuration:, cache_root:)
58
+ @configuration = configuration
59
+ @cache_root = cache_root
60
+ end
61
+
62
+ # @param paths [Array<String>] the analysed set — `configuration.paths` unioned with any path
63
+ # arguments, exactly what {CLI::EffectsCommand#analyze} hands the runner, because the analysed
64
+ # set is part of the diagnostics key.
65
+ # @return [Served, nil] nil to decline, on any failure whatsoever.
66
+ def serve(paths)
67
+ return nil if @cache_root.nil? || !@configuration.effects_check?
68
+
69
+ descriptor = effects_descriptor(paths)
70
+ return nil if descriptor.nil?
71
+
72
+ summary = validated_summary(descriptor)
73
+ return nil if summary.nil?
74
+
75
+ table, sources, = summary
76
+ Served.new(table: table, sources: sources, registry: registry, plugin_facts: plugin_facts)
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ private
82
+
83
+ def effects_descriptor(paths)
84
+ files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns)
85
+ base = RunCacheKey.descriptor(
86
+ configuration: @configuration, files: files, explain: false,
87
+ rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
88
+ )
89
+ return nil if base.nil?
90
+
91
+ Effects::Identity.descriptor(base: base, configuration: @configuration, registry: registry,
92
+ plugin_facts: plugin_facts)
93
+ end
94
+
95
+ # The same shape guard the runner's own read half applies, for the same reason: a miss, a stale
96
+ # dependency, a corrupt entry and a value of the wrong shape all mean "run it properly".
97
+ def validated_summary(descriptor)
98
+ store = Cache::Store.new(root: @cache_root, max_bytes: @configuration.cache_max_bytes)
99
+ cached = Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
100
+ store.peek_validated(
101
+ producer_id: RunCacheKey::RUN_EFFECTS_TABLE_PRODUCER_ID, key_descriptor: descriptor
102
+ )
103
+ end
104
+ return nil unless cached.is_a?(Array) && cached.length == 3
105
+ return nil unless cached[0].is_a?(Effects::EffectTable) && cached[1].is_a?(Hash)
106
+
107
+ cached
108
+ end
109
+
110
+ # Loaded once, and shared by the key and the answer. `#prepare` is deliberately not run: a
111
+ # plugin's effect contributions are fixed by `init`, and `#prepare` is the half that parses
112
+ # routes and walks worker trees — the cost this probe exists to skip.
113
+ def plugin_facts
114
+ return @plugin_facts if defined?(@plugin_facts)
115
+
116
+ @plugin_facts = Effects::PluginFacts.build(plugin_registry)
117
+ end
118
+
119
+ def plugin_registry
120
+ services = Plugin::Services.new(reflection: Reflection, type: Type::Combinator,
121
+ configuration: @configuration, cache_store: nil)
122
+ Plugin::Loader.load(configuration: @configuration, services: services)
123
+ rescue StandardError, ScriptError
124
+ nil
125
+ end
126
+
127
+ def registry
128
+ @registry ||= Effects::Registry.for_configuration(@configuration, plugin_facts: plugin_facts)
129
+ end
130
+ end
131
+ end
132
+ 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 = {}
@@ -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,
@@ -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.
@@ -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).
@@ -54,16 +54,25 @@ module Rigor
54
54
  reachable = walk(edges, seeds: production_seeds | test_seeds, roles: %i[production task config test])
55
55
  unreached = @owned - reachable
56
56
  namespaces = namespace_only(unreached, reachable)
57
- undecidable = tainted(unreached - namespaces)
57
+ # The data-file demotion applies to BOTH buckets it can speak to, which is what the tier
58
+ # contract says and what the implementation had narrowed (#370). See {#tainted}.
59
+ test_only = reachable - production
60
+ undecidable = tainted(unreached - namespaces).merge(tainted(test_only))
61
+ build_report(edges: edges, reachable: reachable, unreached: unreached, namespaces: namespaces,
62
+ test_only: test_only, undecidable: undecidable)
63
+ end
64
+
65
+ private
66
+
67
+ def build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
68
+ demoted = undecidable.keys.to_set
58
69
  Report.new(declared: @owned.size, reachable: reachable.size,
59
- candidates: rows(unreached - namespaces - undecidable.keys.to_set),
70
+ candidates: rows(unreached - namespaces - demoted),
60
71
  undecidable: undecidable.map { |fqn, reason| undecidable_row(fqn, reason) }.freeze,
61
- test_only: rows(reachable - production),
72
+ test_only: rows(test_only - demoted),
62
73
  namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
63
74
  end
64
75
 
65
- private
66
-
67
76
  # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
68
77
  # unknown. Keeping this distinct from the taint below is what stops the tier being a blanket namespace
69
78
  # poison — Rigor knows the argument's shape, and a type-free indexer does not.
@@ -72,21 +81,32 @@ module Rigor
72
81
  next if use.name.nil?
73
82
 
74
83
  Scan::Reference.new(as_written: use.name.sub(/\A::/, ""), nesting: [].freeze, from: nil,
75
- role: :production, path: use.path, line: use.line)
84
+ role: :production, path: use.path, line: use.line,
85
+ rooted: use.name.start_with?("::"))
76
86
  end
77
87
  end
78
88
 
79
- # `{fqn => reason}` for every unreached declaration a dynamic site could still be naming. A site with a
89
+ # `{fqn => reason}` for every declaration in `fqns` a dynamic site could still be naming. A site with a
80
90
  # literal prefix taints that namespace and everything under it; a site with no prefix at all cannot be
81
91
  # bounded, so it taints nothing rather than everything — poisoning the whole project would empty the
82
92
  # report and teach the reader that the tier means nothing.
83
- def tainted(unreached)
84
- prefixes = @dynamic_uses.filter_map { |use| [use.prefix, use] if use.name.nil? && use.prefix }
93
+ #
94
+ # Asked of the unreached AND of the test-only set (#370). The tier contract says a name appearing in a
95
+ # data file "MUST demote to this tier"; the implementation had asked only about the unreached, so a
96
+ # declaration a spec references AND `config/recurring.yml` names kept its data-file evidence discarded
97
+ # and landed under "live test, dead production path". That heading is an assertion about production,
98
+ # and a scheduler entry is evidence against it — the reported case runs every three minutes.
99
+ #
100
+ # Both buckets ask a different question of the same ambiguity ("is this dead?" against "is the
101
+ # production path dead?"), and the answer for both is that this reading cannot settle it. The reason
102
+ # string is what tells the two apart for a reader, so it names the evidence rather than the bucket.
103
+ def tainted(fqns)
104
+ sites = @dynamic_uses.select { |use| use.name.nil? && use.prefix }
85
105
 
86
- return {} if prefixes.empty?
106
+ return {} if sites.empty?
87
107
 
88
- unreached.each_with_object({}) do |fqn, out|
89
- _, use = prefixes.find { |prefix, _| fqn == prefix || fqn.start_with?("#{prefix}::") }
108
+ fqns.each_with_object({}) do |fqn, out|
109
+ use = sites.find { |site| site.taints?(fqn) }
90
110
  out[fqn] = use.site.nil? ? use.reason : "#{use.reason} (#{use.site})" if use
91
111
  end
92
112
  end
@@ -129,7 +149,7 @@ module Rigor
129
149
  @references.each do |ref|
130
150
  next unless yield(ref)
131
151
 
132
- target = resolve(ref.as_written, ref.nesting)
152
+ target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
133
153
  set << target if target && @owned.include?(target)
134
154
  end
135
155
  set
@@ -138,7 +158,7 @@ module Rigor
138
158
  # `[from_fqn_or_nil, to_fqn, role]` for every reference that resolves to an owned declaration.
139
159
  def resolved_edges
140
160
  @resolved_edges ||= @references.filter_map do |ref|
141
- target = resolve(ref.as_written, ref.nesting)
161
+ target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
142
162
  next unless target && @owned.include?(target)
143
163
  next if ref.from == target # a declaration referencing itself is not evidence of use
144
164
 
@@ -178,8 +198,16 @@ module Rigor
178
198
  end
179
199
 
180
200
  # Ruby's constant lookup at name granularity: `Module.nesting` innermost first, then the ancestors of
181
- # the innermost cresting scope (#354), then the bare name.
182
- 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
+
183
211
  walker = nesting.dup
184
212
  until walker.empty?
185
213
  candidate = (walker + [as_written]).join("::")
@@ -86,12 +86,16 @@ module Rigor
86
86
  # @param configuration [Rigor::Configuration] the loaded project configuration.
87
87
  # @param plugin_requirer [#call] how a plugin gem is brought into the process. The same seam
88
88
  # `Analysis::Runner` exposes, so a spec can register a plugin class without publishing a gem.
89
+ # @param cache_store [Rigor::Cache::Store, nil] when given, each plugin's `#prepare` producers read
90
+ # and write the same ADR-60 record-and-validate slots they use under `rigor check`, instead of
91
+ # recomputing from scratch — a routes parse or a factory discovery is a validated cache read on
92
+ # every invocation after the first. Nil keeps the historical recompute-always behaviour.
89
93
  # @return [Contribution] sorted and de-duplicated. Empty whenever the project declares no plugins, no
90
94
  # plugin contributes anything, or anything at all goes wrong.
91
- def collect(configuration:, plugin_requirer: ->(name) { require name })
95
+ def collect(configuration:, plugin_requirer: ->(name) { require name }, cache_store: nil)
92
96
  return Contribution.empty if configuration.plugins.empty?
93
97
 
94
- services = build_services(configuration)
98
+ services = build_services(configuration, cache_store)
95
99
  registry = Plugin::Loader.load(configuration: configuration, services: services,
96
100
  requirer: plugin_requirer)
97
101
  return Contribution.empty if registry.nil? || registry.empty?
@@ -102,16 +106,15 @@ module Rigor
102
106
  Contribution.empty
103
107
  end
104
108
 
105
- # Mirrors `CLI::ProbeEnvironment.load_plugin_registry`: a `Plugin::Services` with no cache store
106
- # (this is a one-shot report, so a producer recomputes rather than reading and writing cache slots)
107
- # driving `Plugin::Loader.load`. Holding the `Services` is what gives access to the fact store the
108
- # loaded plugins share — the loader hands the same instance to every plugin.
109
- def build_services(configuration)
109
+ # Mirrors `Analysis::WorkerSession`'s services: the shared fact store plus whatever cache store the
110
+ # caller holds. Holding the `Services` is what gives access to the fact store the loaded plugins
111
+ # share the loader hands the same instance to every plugin.
112
+ def build_services(configuration, cache_store)
110
113
  Plugin::Services.new(
111
114
  reflection: Reflection,
112
115
  type: Type::Combinator,
113
116
  configuration: configuration,
114
- cache_store: nil
117
+ cache_store: cache_store
115
118
  )
116
119
  end
117
120