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
@@ -15,8 +15,16 @@ module Rigor
15
15
  # The analysis is a conservative ALLOW-LIST, not a deny-list: a local is fold-safe only when *every* read of it is
16
16
  # the receiver of a known-pure read call. Anything the scan does not recognise as a pure read — a setter, an `[]=` /
17
17
  # operator-write, an argument / alias / container store / return (escape), or an unknown method call (which could
18
- # mutate `self` internally) — disqualifies the local. A missed case therefore makes the scan over-conservative (no
19
- # fold), **never unsound** (folding a mutated value) — the false-positive-safe direction.
18
+ # mutate `self` internally) — disqualifies the local.
19
+ #
20
+ # A missed case is USUALLY only over-conservative (no fold), but the direction is not guaranteed, and an earlier
21
+ # version of this header claimed it was. The counting identity below is about the LOCAL; it says nothing about what
22
+ # happens to a member read's RESULT. `s.x << v` mutates the container `s.x` returns while `s.x` itself is a
23
+ # textbook pure read, so the identity held and the local stayed fold-safe while its member's VALUE changed
24
+ # underneath — `s = Point.new([5]); s.x << "a"; s.x.last` folded to `5` and drew `undefined method 'upcase' for 5`
25
+ # on correct code. That is why {#count_uses} disqualifies a local whose member-read result is itself a receiver
26
+ # (see there). Any future extension must ask the same question: does this shape reach the member's value through
27
+ # something other than the local?
20
28
  #
21
29
  # Soundness rests on a counting identity: a local `n` is fold-safe iff *every* `LocalVariableReadNode(n)` is the
22
30
  # receiver of a pure-read call. Equivalently `total_reads(n) == pure_receiver_reads(n)`. Any other occurrence — `n`
@@ -59,10 +67,12 @@ module Rigor
59
67
  collect_struct_locals(root, layout_lookup, members, writes)
60
68
  return EMPTY if members.empty?
61
69
 
62
- total = Hash.new(0)
63
- safe_uses = Hash.new(0)
64
- deferred_setter = {}
65
- count_uses(root, members, total, safe_uses, deferred_setter, false)
70
+ tally = Tally.new(total: Hash.new(0), safe_uses: Hash.new(0), deferred_setter: {}, chained: {})
71
+ count_uses(root, members, tally, false)
72
+ total = tally.total
73
+ safe_uses = tally.safe_uses
74
+ deferred_setter = tally.deferred_setter
75
+ chained = tally.chained
66
76
 
67
77
  # A local is fold-safe iff every read of it is a safe use — a pure read OR (ADR-48 slice 4) a
68
78
  # straight-line member setter (`n.x = v`) that the setter write-back re-types the binding for.
@@ -71,7 +81,7 @@ module Rigor
71
81
  # stale binding for the fold to read).
72
82
  safe = members.each_key.select do |name|
73
83
  writes[name] == 1 && total[name].positive? &&
74
- total[name] == safe_uses[name] && !deferred_setter[name]
84
+ total[name] == safe_uses[name] && !deferred_setter[name] && !chained[name]
75
85
  end
76
86
  safe.empty? ? EMPTY : safe.to_set
77
87
  end
@@ -95,30 +105,63 @@ module Rigor
95
105
  # Pass 2 — count, per recorded struct local, total reads vs. SAFE-use reads (the receiver of a pure-read call,
96
106
  # or of a straight-line member setter). `deferred` is true inside a loop / block / lambda; a member setter seen
97
107
  # there marks the local's `deferred_setter` so it is excluded (its write-back cannot be modelled statically).
98
- def count_uses(node, members, total, safe_uses, deferred_setter, deferred)
99
- return if node.nil?
108
+ # The four per-local accumulators pass 2 fills, bundled so the walk stays one recursive call rather than a
109
+ # seven-argument one.
110
+ Tally = Struct.new(:total, :safe_uses, :deferred_setter, :chained, keyword_init: true)
100
111
 
101
- total[node.name] += 1 if node.is_a?(Prism::LocalVariableReadNode) && members.key?(node.name)
112
+ def count_uses(node, members, tally, deferred)
113
+ return if node.nil?
102
114
 
103
- if node.is_a?(Prism::CallNode)
104
- receiver = node.receiver
105
- if receiver.is_a?(Prism::LocalVariableReadNode) && members.key?(receiver.name)
106
- member_set = members[receiver.name]
107
- if pure_read_call?(node, member_set)
108
- safe_uses[receiver.name] += 1
109
- elsif member_setter_call?(node, member_set)
110
- safe_uses[receiver.name] += 1
111
- deferred_setter[receiver.name] = true if deferred
112
- end
113
- end
114
- end
115
+ tally.total[node.name] += 1 if node.is_a?(Prism::LocalVariableReadNode) && members.key?(node.name)
116
+ chained_local_of(node, members)&.then { |name| tally.chained[name] = true }
117
+ count_receiver_use(node, members, tally, deferred)
115
118
 
116
119
  child_deferred = deferred || deferred_boundary?(node)
117
120
  each_local_scope_child(node) do |child|
118
- count_uses(child, members, total, safe_uses, deferred_setter, child_deferred)
121
+ count_uses(child, members, tally, child_deferred)
122
+ end
123
+ end
124
+
125
+ # The `<local>.<call>` arm: a pure read or a member setter counts as a SAFE use of the local, and a setter
126
+ # seen inside a loop / block / lambda additionally marks `deferred_setter`.
127
+ def count_receiver_use(node, members, tally, deferred)
128
+ return unless node.is_a?(Prism::CallNode)
129
+
130
+ receiver = node.receiver
131
+ return unless receiver.is_a?(Prism::LocalVariableReadNode) && members.key?(receiver.name)
132
+
133
+ member_set = members[receiver.name]
134
+ if pure_read_call?(node, member_set)
135
+ tally.safe_uses[receiver.name] += 1
136
+ elsif member_setter_call?(node, member_set)
137
+ tally.safe_uses[receiver.name] += 1
138
+ tally.deferred_setter[receiver.name] = true if deferred
119
139
  end
120
140
  end
121
141
 
142
+ # The tracked local behind `<local>.<member>` when THAT read is itself the receiver of a further call —
143
+ # `s.x << v`, `s.x.push(v)`, `row.cells.size`. The counting identity above cannot see these: `s.x` is a
144
+ # pure read of `s` by every measure it applies, and the mutation lands on the object `s.x` RETURNS. The
145
+ # local stays fold-safe, the fold serves the materialisation value, and a correct program is told its
146
+ # accumulated container still holds only what it was built with.
147
+ #
148
+ # Any call on the result disqualifies, with no allow-list of its own. `s.x.to_s` is provably harmless and
149
+ # loses precision here for nothing — but an allow-list is what produced this bug in the first place, and
150
+ # the failure mode of being too broad is a `Dynamic[top]` where a constant would do. Reads that do NOT
151
+ # chain (`dump_type(p.x)`, `assert_type("1", stored.foo)`, a bare `s.x`) are arguments or values, not
152
+ # receivers, and keep folding.
153
+ def chained_local_of(node, members)
154
+ return nil unless node.is_a?(Prism::CallNode)
155
+
156
+ inner = node.receiver
157
+ return nil unless inner.is_a?(Prism::CallNode)
158
+
159
+ local = inner.receiver
160
+ return nil unless local.is_a?(Prism::LocalVariableReadNode) && members.key?(local.name)
161
+
162
+ local.name
163
+ end
164
+
122
165
  # A call is a pure read of the receiver when its name is a fixed Struct read or one of the receiver's member
123
166
  # readers. Setters (`:x=`), `:[]=`, and any unknown method are excluded.
124
167
  def pure_read_call?(call_node, member_set)
@@ -137,9 +180,123 @@ module Rigor
137
180
  (call_node.arguments&.arguments&.size || 0) == 1
138
181
  end
139
182
 
140
- # Constructs whose body runs zero-or-many times or is deferred (loops, blocks, lambdas): a single static pass
141
- # over the body cannot model a member setter's effect across iterations, so a setter inside one disqualifies the
142
- # local. Straight-line conditionals (`if` / `unless` / `case`) are NOT boundaries their branch scopes join
183
+ # Self-calls that cannot change a member, on top of the receiver's own member READERS. Same list the
184
+ # stored-local scan trusts, so the two halves of the gate cannot drift on what "pure" means. `:class`
185
+ # is added because `self.class.new(...)` is the ordinary way a struct method builds a sibling.
186
+ SELF_PURE_READS = (FIXED_READS + %i[class]).freeze
187
+
188
+ # Issue #525 — whether a method body may be granted the `:self` fold-safety sentinel, i.e. whether the
189
+ # caller's member map is still the struct's state at every member read the body performs.
190
+ #
191
+ # Three shapes refuse, and the third is what makes the grant closed under the calls the body makes:
192
+ #
193
+ # 1. a member setter or `[]=` on self (`self.text = v`) — a later read would fold the STALE
194
+ # construction value, the wrong-type family this gate exists to prevent;
195
+ # 2. a bare `self` anywhere but as a call receiver — passed, stored or returned, `self` reaches code
196
+ # that can mutate it;
197
+ # 3. any other self-call that is not a member reader or a fixed pure read. `def shout; reset!;
198
+ # text.upcase; end` over a sibling `def reset!; self.text = ""; end` has no setter of its OWN, so a
199
+ # guard that only looked for (1) would grant it and fold `text` to the construction value while the
200
+ # runtime read is `""`.
201
+ #
202
+ # (3) is what makes the grant CLOSED under the calls the body makes. A caller that can resolve sibling
203
+ # methods passes a block: it receives each otherwise-unrecognised self-call name and answers whether
204
+ # that sibling is itself self-fold-safe, which is how `def outer; shout; end` keeps the grant while
205
+ # `def go; reset!; text; end` loses it. Without the block — or for a name the block cannot resolve —
206
+ # the call refuses, so the AST-only answer stays the conservative one.
207
+ #
208
+ # Nested `def` / `class` / `module` bodies are skipped: their statements do not run during THIS body's
209
+ # evaluation. Blocks are descended into — they share `self`.
210
+ #
211
+ # @param body [Prism::Node, nil]
212
+ # @param member_names [Enumerable<Symbol>] the receiver carrier's member names.
213
+ # @yieldparam name [Symbol] an unrecognised self-call selector.
214
+ # @yieldreturn [Boolean] whether that sibling method is itself self-fold-safe.
215
+ def self_fold_safe_body?(body, member_names, &sibling_pure)
216
+ return false if body.nil?
217
+
218
+ self_uses_pure?(body, member_names.to_set(&:to_sym), sibling_pure)
219
+ end
220
+
221
+ # Recursive worker for {self_fold_safe_body?}. A `SelfNode` reached here came through a non-receiver
222
+ # edge (a call's own `self` receiver is skipped below), so it is an escape.
223
+ def self_uses_pure?(node, members, sibling_pure)
224
+ return true if node.nil? || scope_boundary?(node)
225
+ return false if node.is_a?(Prism::SelfNode)
226
+ # `super` / `super(...)` runs an ancestor's body against this same `self`, and the resolver
227
+ # `sibling_pure` walks owns-class defs only, so nothing here can vouch for what it does. Latent
228
+ # while struct carriers have no subclass ancestry to reach, closed before they do.
229
+ return false if node.is_a?(Prism::SuperNode) || node.is_a?(Prism::ForwardingSuperNode)
230
+
231
+ if node.is_a?(Prism::CallNode)
232
+ return false unless self_call_pure?(node, members, sibling_pure)
233
+
234
+ return call_children_pure?(node, members, sibling_pure)
235
+ end
236
+
237
+ children_pure?(node, members, sibling_pure)
238
+ end
239
+
240
+ # A call is pure for this scan unless it targets `self` (explicitly or implicitly) under a name that is
241
+ # neither one of the receiver's member readers nor a fixed pure read. `self.text = v` arrives as a
242
+ # `:text=` call and `self[:text] = v` as `:[]=`; neither is in either set, so both refuse — and neither
243
+ # is offered to `sibling_pure`, since a name that WRITES is not made safe by where it is defined.
244
+ def self_call_pure?(call_node, members, sibling_pure)
245
+ receiver = call_node.receiver
246
+ return true unless receiver.nil? || receiver.is_a?(Prism::SelfNode)
247
+
248
+ name = call_node.name
249
+ return true if members.include?(name) || SELF_PURE_READS.include?(name)
250
+ return false if writer_selector?(name)
251
+
252
+ !sibling_pure.nil? && sibling_pure.call(name)
253
+ end
254
+
255
+ # A selector that assigns: `text=`, `[]=`. Never delegated to `sibling_pure` — a writer is unsafe on
256
+ # its face, and asking the resolver about it would let a struct with a hand-written `def text=(v)` that
257
+ # the resolver walks into read as pure.
258
+ def writer_selector?(name)
259
+ text = name.to_s
260
+ text.end_with?("=") && !%w[== != <= >= ===].include?(text)
261
+ end
262
+
263
+ # Children of a call, skipping an explicit `self` RECEIVER — that occurrence is the call's own target
264
+ # and was already judged by {self_call_pure?}, not an escape.
265
+ def call_children_pure?(call_node, members, sibling_pure)
266
+ call_node.rigor_each_child do |child|
267
+ next if child.is_a?(Prism::SelfNode) && child.equal?(call_node.receiver)
268
+ return false unless self_uses_pure?(child, members, sibling_pure)
269
+ end
270
+ true
271
+ end
272
+
273
+ def children_pure?(node, members, sibling_pure)
274
+ node.rigor_each_child do |child|
275
+ return false unless self_uses_pure?(child, members, sibling_pure)
276
+ end
277
+ true
278
+ end
279
+
280
+ # Constructs a member setter's effect cannot reach the continuation soundly, so a setter inside one
281
+ # disqualifies the local.
282
+ #
283
+ # A block or lambda body is evaluated for its own scope and its bindings do NOT leak, so
284
+ # `[1].each { s.x = v }; s.x` would read the construction value — verified unsound on #525.
285
+ #
286
+ # `for` shares the enclosing locals rather than opening a scope, so the block rationale does not apply
287
+ # to it; it is a boundary for the same reason `while` is (below), and this comment previously gave the
288
+ # wrong reason for it.
289
+ #
290
+ # `while` / `until` are boundaries because a single static pass cannot model a member setter across
291
+ # ITERATIONS. Issue #597 tried to lift this: `eval_loop` does join the body's exit scope with the
292
+ # pre-loop scope, which looks like the per-iteration summary the gate stands in for. It is not one.
293
+ # That join is a single unrolling, and it is sound only under four side conditions nothing checks —
294
+ # the setter's RHS must not depend on loop-carried member state (`p.x = p.y; p.y = 5` reads `1 | 2`
295
+ # where the runtime holds 5), the member must be read only AFTER the loop, the loop must exit through
296
+ # its predicate rather than a `break`, and no later setter may overwrite what the join recorded.
297
+ # Details and the probes: ADR-56 § WD2.8.
298
+ #
299
+ # Straight-line conditionals (`if` / `unless` / `case`) are NOT boundaries — their branch scopes join
143
300
  # soundly, so a setter in one branch is fine.
144
301
  def deferred_boundary?(node)
145
302
  node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode) ||
@@ -171,7 +328,9 @@ module Rigor
171
328
  return nil unless meta_constant?(call_node.receiver, :Struct)
172
329
 
173
330
  args = call_node.arguments&.arguments || []
174
- positional = args.last.is_a?(Prism::KeywordHashNode) ? args[0..-2] : args
331
+ # `[0..-2]` cannot return nil for a start of 0, but `Array#[](Range) -> Array[T]?` says it can;
332
+ # the `|| []` keeps the read well-typed on that worst case.
333
+ positional = args.last.is_a?(Prism::KeywordHashNode) ? (args[0..-2] || []) : args
175
334
  positional = positional[1..] if positional.first.is_a?(Prism::StringNode)
176
335
  return nil if positional.nil? || positional.empty?
177
336
  return nil unless positional.all?(Prism::SymbolNode)
@@ -65,6 +65,13 @@ module Rigor
65
65
  registries = collect_trait_registries(plugin_registry)
66
66
  nested_templates = collect_nested_class_templates(plugin_registry)
67
67
  return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
68
+ # Tier B alone cannot emit without an environment: every trait entry — the direct route and the
69
+ # concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
70
+ # answers `[]` for a nil environment. The production pre-pass passes `environment: nil` (#476),
71
+ # so a project whose only contributing plugin registers trait registries (rigor-devise on a
72
+ # Rails app) would otherwise pay a whole-project parse to build a provably empty index. This
73
+ # gate mirrors that nil guard and MUST be removed in the change that threads a real environment.
74
+ return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
68
75
 
69
76
  asts = parse_paths(paths, buffer: buffer)
70
77
  hierarchy = build_hierarchy(asts)
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/constant_path"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # ADR-47 WD5 — decides the arm of a **version guard**: an `if` / `unless` predicate that compares the
10
+ # Ruby (or a bundled default gem's) version against a literal, both sides readable at analysis time.
11
+ #
12
+ # Multi-version gems select an API generation with these guards routinely:
13
+ #
14
+ # if Gem::Version.new(Psych::VERSION) >= Gem::Version.new("3.1.0.pre1")
15
+ # ::YAML.safe_load(yaml, permitted_classes: permitted_classes)
16
+ # else
17
+ # ::YAML.safe_load(yaml, permitted_classes) # Psych < 3.1 positional form
18
+ # end
19
+ #
20
+ # The dead arm is honest code for an older Ruby, but it never runs on the Ruby the user is checking
21
+ # with — so a diagnostic inside it is a false positive ("the program works" outranks the worst-case
22
+ # static reading, issue #627). {.verdict} answers `:truthy` / `:falsey` for a decidable guard, and
23
+ # `StatementEvaluator#eval_if` / `#eval_unless` then take the *exact* path they already take for
24
+ # `if false`: only the live arm is evaluated, so the dead arm contributes neither diagnostics nor
25
+ # writes to the post-`if` scope.
26
+ #
27
+ # ## The reference Ruby
28
+ #
29
+ # The version compared against is the **analyzer's own** `RUBY_VERSION` / `RUBY_ENGINE`. That is the
30
+ # premise Rigor already runs on: {Builtins::PredefinedConstantRefinements} resolves these constants
31
+ # through the analyzer's runtime, and the core / stdlib RBS the engine reads is the one shipped with
32
+ # the interpreter running `rigor`. `Configuration#target_ruby` is deliberately NOT consulted — it is a
33
+ # Prism *parse* version (it decides which syntax is accepted), it is not threaded to the inference
34
+ # layer, and its default (`"4.0"`) is a default rather than a user statement about the runtime.
35
+ #
36
+ # ## What is folded (and what is deliberately not)
37
+ #
38
+ # * `RUBY_VERSION <cmp> "x.y.z"` — compared with **String** semantics, because that is what runs.
39
+ # Ruby compares strings lexically, so `RUBY_VERSION >= "3.10"` is famously false on 3.10; folding
40
+ # with String semantics reproduces the program's real behaviour rather than an idealised one.
41
+ # * `Gem::Version.new(a) <cmp> Gem::Version.new(b)` — both sides wrapped, compared with
42
+ # `Gem::Version` semantics. A *mixed* comparison (one side wrapped, the other a bare String) is
43
+ # never folded: `Gem::Version#<=>` returns nil for a non-Version operand, so `Comparable` raises at
44
+ # runtime and there is no arm to pick.
45
+ # * `RUBY_ENGINE == / != "…"` — equality only. An *ordering* comparison on an engine name is not a
46
+ # version guard, so it is left alone.
47
+ # * `X::VERSION` — only for the curated {VERSION_CONSTANTS} set: constants of **default gems shipped
48
+ # with the running Ruby**, where "the analyzer's Ruby is the target's Ruby" already covers them.
49
+ # A third-party gem's `VERSION` is not read: its version is the project's `Gemfile.lock`'s to say,
50
+ # and the lockfile is not visible from the inference layer.
51
+ # * `RUBY_PLATFORM` — **never** folded. Every comparison against it is by construction
52
+ # platform-dependent, and the machine running `rigor` need not be the machine running the program.
53
+ # * `<=>` — never folded (it yields -1/0/1, not a branch verdict), and neither are `defined?(Ractor)`
54
+ # style capability probes, `!` / `&&` / `||` compositions, or `case` subjects. Each would need its
55
+ # own argument; an unfoldable guard simply keeps both arms live, which is the pre-existing
56
+ # behaviour.
57
+ # * Two bare String literals (`if "a" < "b"`) — declined. That is a constant comparison, not a version
58
+ # guard; the existing constant-fold tier already answers it, and widening this rule to cover it would
59
+ # buy scope without buying an FP fix. At least one side must be a readable constant or a
60
+ # `Gem::Version`.
61
+ module VersionGuard
62
+ module_function
63
+
64
+ # Comparison operators whose result is a branch verdict. `<=>` is excluded on purpose.
65
+ ORDERING = Set[:<, :<=, :>, :>=].freeze
66
+ EQUALITY = Set[:==, :!=].freeze
67
+ private_constant :ORDERING, :EQUALITY
68
+
69
+ # Predefined constants read from the analyzer's runtime, mapped to the operand kind they produce.
70
+ # `:string` operands accept every comparison; `:engine` operands accept equality only.
71
+ PREDEFINED = { RUBY_VERSION: :string, RUBY_ENGINE: :engine }.freeze
72
+ private_constant :PREDEFINED
73
+
74
+ # Operand kinds that carry a plain Ruby String, i.e. the ones `Gem::Version.new` may wrap and the
75
+ # ones that compare with each other.
76
+ STRING_KINDS = Set[:literal_string, :string, :engine].freeze
77
+ private_constant :STRING_KINDS
78
+
79
+ # Qualified `X::VERSION` constants that may be read from the analyzer's runtime.
80
+ #
81
+ # Admission criterion: the constant belongs to a **default gem of the running Ruby**, so its value
82
+ # is fixed by the same interpreter choice that already fixes `RUBY_VERSION` and the core RBS. Add an
83
+ # entry only when that holds; a gem the project resolves through its own `Gemfile.lock` does not
84
+ # qualify, because the analyzer's copy and the project's can differ.
85
+ VERSION_CONSTANTS = Set["Psych::VERSION"].freeze
86
+ private_constant :VERSION_CONSTANTS
87
+
88
+ GEM_VERSION_PATH = "Gem::Version"
89
+ private_constant :GEM_VERSION_PATH
90
+
91
+ # @param node [Prism::Node, nil] an `if` / `unless` predicate
92
+ # @return [Symbol, nil] `:truthy` / `:falsey` when the guard is decidable on the analyzer's Ruby,
93
+ # otherwise nil (both arms stay live)
94
+ def verdict(node)
95
+ return nil unless node.is_a?(Prism::CallNode)
96
+
97
+ operator = node.name
98
+ return nil unless ORDERING.include?(operator) || EQUALITY.include?(operator)
99
+ return nil if node.block || node.receiver.nil?
100
+
101
+ arguments = node.arguments&.arguments
102
+ return nil unless arguments && arguments.length == 1
103
+
104
+ left = read_operand(node.receiver)
105
+ right = read_operand(arguments.first)
106
+ return nil if left.nil? || right.nil?
107
+
108
+ decide(left, right, operator)
109
+ end
110
+
111
+ # Reads one side of the comparison into `[kind, value]`, or nil when it is not readable.
112
+ #
113
+ # @return [Array(Symbol, Object), nil]
114
+ def read_operand(node)
115
+ case node
116
+ when Prism::StringNode then [:literal_string, node.unescaped]
117
+ when Prism::ConstantReadNode then read_predefined(node)
118
+ when Prism::ConstantPathNode then read_version_constant(node)
119
+ when Prism::CallNode then read_gem_version(node)
120
+ end
121
+ end
122
+ private_class_method :read_operand
123
+
124
+ def read_predefined(node)
125
+ kind = PREDEFINED[node.name]
126
+ return nil unless kind
127
+
128
+ value = runtime_value(node.name.to_s)
129
+ value && [kind, value]
130
+ end
131
+ private_class_method :read_predefined
132
+
133
+ def read_version_constant(node)
134
+ path = Source::ConstantPath.qualified_name_or_nil(node)
135
+ return nil unless path && VERSION_CONSTANTS.include?(path)
136
+
137
+ value = runtime_value(path)
138
+ value && [:string, value]
139
+ end
140
+ private_class_method :read_version_constant
141
+
142
+ # `Gem::Version.new(<readable>)`. The inner operand must be a plain version String — an engine name
143
+ # is not a version, and `Gem::Version.new` raises on anything `Gem::Version.correct?` rejects, so a
144
+ # malformed literal keeps both arms live instead of folding a guard the program cannot even reach.
145
+ def read_gem_version(node)
146
+ return nil unless node.name == :new && node.block.nil?
147
+ return nil unless Source::ConstantPath.qualified_name_or_nil(node.receiver) == GEM_VERSION_PATH
148
+
149
+ arguments = node.arguments&.arguments
150
+ return nil unless arguments && arguments.length == 1
151
+
152
+ inner = read_operand(arguments.first)
153
+ return nil unless inner && STRING_KINDS.include?(inner.first)
154
+ return nil unless ::Gem::Version.correct?(inner.last)
155
+
156
+ [:gem_version, ::Gem::Version.new(inner.last)]
157
+ end
158
+ private_class_method :read_gem_version
159
+
160
+ # Resolves a constant path in the analyzer's own runtime without triggering `const_missing` or an
161
+ # autoload. The path is always one of {PREDEFINED} / {VERSION_CONSTANTS} — Rigor's own source,
162
+ # never a name read out of the analysed program — but the walk is guarded the same way
163
+ # {Builtins::PredefinedConstantRefinements} is, for the same reason: `const_defined?` answers true
164
+ # for a REGISTERED-BUT-NOT-YET-TRIGGERED autoload, so it does not mean "already in memory" and
165
+ # `const_get` would execute the target file inside the analyzer. `Module#autoload?` is what
166
+ # separates the two ([#680](https://github.com/rigortype/rigor/issues/680)).
167
+ #
168
+ # @return [String, nil] the constant's value when it is a non-empty String
169
+ def runtime_value(path)
170
+ mod = ::Object
171
+ path.split("::").each do |part|
172
+ return nil unless mod.is_a?(::Module) && mod.const_defined?(part, false)
173
+ return nil if mod.autoload?(part)
174
+
175
+ mod = mod.const_get(part, false)
176
+ end
177
+
178
+ mod.is_a?(::String) && !mod.empty? ? mod : nil
179
+ rescue ::StandardError, ::ScriptError, ::SystemExit
180
+ # Wider than `NameError` / `TypeError` / `LoadError`: folding a version guard is a precision
181
+ # win, and nothing it can hit justifies stopping the run. `SystemExit` is not a `StandardError`
182
+ # and escaped every rescue in the analyzer when a resolution executed third-party code (#680).
183
+ # `Interrupt`, `SignalException` and `NoMemoryError` stay uncaught on purpose.
184
+ nil
185
+ end
186
+ private_class_method :runtime_value
187
+
188
+ # Applies the operator with the semantics that actually run. Operands must be the same kind, and an
189
+ # engine name only ever answers equality.
190
+ def decide(left, right, operator)
191
+ left_kind, left_value = left
192
+ right_kind, right_value = right
193
+ return nil unless comparable_kinds?(left_kind, right_kind)
194
+ return nil if (left_kind == :engine || right_kind == :engine) && !EQUALITY.include?(operator)
195
+
196
+ result = apply(left_value, right_value, operator)
197
+ return nil if result.nil?
198
+
199
+ result ? :truthy : :falsey
200
+ end
201
+ private_class_method :decide
202
+
203
+ # `:gem_version` only ever compares with another `:gem_version` — `Gem::Version#<=>` answers nil for
204
+ # a String operand, so the mixed spelling raises at runtime and has no live arm to pick. The String
205
+ # kinds compare with each other, EXCEPT two bare literals: that is a constant comparison, not a
206
+ # version guard.
207
+ def comparable_kinds?(left_kind, right_kind)
208
+ return left_kind == right_kind if left_kind == :gem_version || right_kind == :gem_version
209
+
210
+ !(left_kind == :literal_string && right_kind == :literal_string)
211
+ end
212
+ private_class_method :comparable_kinds?
213
+
214
+ def apply(left, right, operator)
215
+ case operator
216
+ when :< then left < right
217
+ when :<= then left <= right
218
+ when :> then left > right
219
+ when :>= then left >= right
220
+ when :== then left == right
221
+ when :!= then left != right
222
+ end
223
+ rescue ::ArgumentError, ::TypeError, ::NoMethodError
224
+ nil
225
+ end
226
+ private_class_method :apply
227
+ end
228
+ end
229
+ end
@@ -73,7 +73,7 @@ module Rigor
73
73
  loc = node.location
74
74
  {
75
75
  start: { line: loc.start_line - 1, character: loc.start_column },
76
- end: { line: loc.end_line - 1, character: loc.end_column }
76
+ end: { line: loc.end_line - 1, character: loc.end_column }
77
77
  }
78
78
  end
79
79
 
@@ -435,11 +435,10 @@ module Rigor
435
435
  # Frozen snapshot of the declared narrowing-facts rules. Memoised for the same reason as
436
436
  # {dynamic_returns} — consulted per plugin per dispatch, over an array fixed at class-definition
437
437
  # time.
438
- # rubocop:disable Naming/MemoizedInstanceVariableName -- see dynamic_returns
438
+ # rubocop:disable-next Naming/MemoizedInstanceVariableName -- see dynamic_returns
439
439
  def narrowing_facts_rules
440
440
  @narrowing_facts_rules_snapshot ||= (@narrowing_facts_rules || []).dup.freeze
441
441
  end
442
- # rubocop:enable Naming/MemoizedInstanceVariableName
443
442
  end
444
443
 
445
444
  attr_reader :services, :config
@@ -841,6 +840,8 @@ module Rigor
841
840
  def dynamic_return_receiver_class_name(receiver_type)
842
841
  case receiver_type
843
842
  when Rigor::Type::Nominal, Rigor::Type::Singleton then receiver_type.class_name
843
+ when Rigor::Type::Result then "Dry::Monads::Result"
844
+ when Rigor::Type::Maybe then "Dry::Monads::Maybe"
844
845
  end
845
846
  end
846
847
 
@@ -32,9 +32,17 @@ module Rigor
32
32
  # § "safety harness"). The set is fixed and greppable — never a dynamic `public_send`.
33
33
  #
34
34
  # `tableize` is deliberately NOT delegated: `ActiveSupport::Inflector.tableize("Admin::User")` returns
35
- # `"admin/users"`, but ActiveRecord's *actual* table name flattens the namespace to `"admin_users"` (the
36
- # table-name computation does more than the pure `tableize` string method). So {.tableize} composes the
37
- # AS-backed `underscore` / `pluralize` with the `::`→`_` flattening AR really uses.
35
+ # `"admin/users"`, a slash-separated path, never a valid SQL table name. {.tableize} composes the
36
+ # AS-backed `underscore` / `pluralize` with a `::`→`_` flatten instead, so a caller already holding a
37
+ # BARE (non-namespaced) name gets a plain identifier back rather than a path.
38
+ #
39
+ # This is NOT ActiveRecord's actual table-name algorithm for a namespaced model — Rails demodulizes
40
+ # (drops the enclosing module entirely) rather than flattening it into the name, and prepends /
41
+ # appends `table_name_prefix` / `table_name_suffix` only when the enclosing module declares one.
42
+ # `rigor-activerecord`'s `ModelIndex.inflected_table_name` does that demodulize-then-decorate
43
+ # sequence and calls here only with the already-demodulized (so already namespace-free) local class
44
+ # name — the `::`→`_` flatten below never actually fires from that caller (#623 fixed a version of
45
+ # this method that WAS called with the full namespaced path).
38
46
  ALLOWED_METHODS = %i[underscore camelize singularize pluralize classify].freeze
39
47
 
40
48
  # The target library + the constant the allow-listed methods are called on. Passed to {Isolation} so the
@@ -81,8 +89,9 @@ module Rigor
81
89
  invoke(:classify, table_name)
82
90
  end
83
91
 
84
- # `BlogPost` → `blog_posts`; `Admin::User` → `admin_users`. Composed (not delegated) so the namespace
85
- # flattens with `_` the way ActiveRecord's table naming does see {ALLOWED_METHODS}.
92
+ # `BlogPost` → `blog_posts`; `Admin::User` → `admin_users`. Composed (not delegated) see
93
+ # {ALLOWED_METHODS} to flatten a namespaced String into a plain SQL identifier. This is NOT what
94
+ # ActiveRecord's table-name computation does with a namespace: see {ALLOWED_METHODS}'s comment above.
86
95
  def tableize(class_name)
87
96
  underscored = underscore(class_name.to_s.gsub("::", "/")).tr("/", "_")
88
97
  pluralize(underscored)