rigortype 0.3.9 → 0.4.0

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 (363) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/enumerable.rbs +51 -0
  4. data/data/core_overlay/enumerator.rbs +84 -0
  5. data/data/core_overlay/hash_rbs3.rbs +41 -0
  6. data/data/core_overlay/process.rbs +40 -0
  7. data/data/core_overlay/string_io.rbs +33 -0
  8. data/data/effects/core.yml +3 -3
  9. data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
  10. data/docs/handbook/03-narrowing.md +95 -10
  11. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  12. data/docs/handbook/07-rbs-and-extended.md +16 -11
  13. data/docs/handbook/10-sorbet.md +9 -10
  14. data/docs/handbook/11-sig-gen.md +454 -13
  15. data/docs/manual/02-cli-reference.md +63 -2
  16. data/docs/manual/03-configuration.md +7 -0
  17. data/docs/manual/04-diagnostics.md +4 -0
  18. data/docs/manual/07-plugins.md +1 -1
  19. data/docs/manual/10-mcp-server.md +3 -2
  20. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  21. data/docs/manual/19-effect-labels.md +10 -0
  22. data/docs/manual/plugins/README.md +7 -0
  23. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  24. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  25. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  26. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  27. data/docs/manual/plugins/rigor-activejob.md +7 -0
  28. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  29. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  30. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  31. data/docs/manual/plugins/rigor-grape.md +106 -0
  32. data/docs/manual/plugins/rigor-graphql.md +23 -2
  33. data/docs/manual/plugins/rigor-pundit.md +8 -1
  34. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  35. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  36. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  37. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  38. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  39. data/exe/rigor +19 -4
  40. data/lib/rigor/analysis/baseline.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  42. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  43. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  49. data/lib/rigor/analysis/check_rules.rb +520 -49
  50. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  51. data/lib/rigor/analysis/fact_store.rb +9 -0
  52. data/lib/rigor/analysis/incremental.rb +26 -0
  53. data/lib/rigor/analysis/incremental_session.rb +36 -4
  54. data/lib/rigor/analysis/project_scan.rb +11 -1
  55. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  56. data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
  57. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  58. data/lib/rigor/analysis/run_cache_key.rb +20 -11
  59. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
  60. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  61. data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
  62. data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
  63. data/lib/rigor/analysis/runner.rb +278 -20
  64. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  65. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  66. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  67. data/lib/rigor/analysis/template_units.rb +399 -0
  68. data/lib/rigor/analysis/worker_session.rb +53 -10
  69. data/lib/rigor/bleeding_edge.rb +0 -2
  70. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  71. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  72. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  73. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  74. data/lib/rigor/cache/descriptor.rb +6 -1
  75. data/lib/rigor/cache/engine_source.rb +29 -1
  76. data/lib/rigor/cache/incremental_snapshot.rb +33 -1
  77. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  78. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  79. data/lib/rigor/cache/store.rb +0 -1
  80. data/lib/rigor/ci_detector.rb +1 -0
  81. data/lib/rigor/cli/doc_links.rb +1 -1
  82. data/lib/rigor/cli/docs_command.rb +4 -4
  83. data/lib/rigor/cli/plugin_command.rb +3 -3
  84. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  85. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  86. data/lib/rigor/cli/skill_command.rb +1 -1
  87. data/lib/rigor/cli/skill_describe.rb +0 -1
  88. data/lib/rigor/cli/type_of_command.rb +19 -5
  89. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  90. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  91. data/lib/rigor/cli.rb +21 -3
  92. data/lib/rigor/configuration/severity_profile.rb +19 -3
  93. data/lib/rigor/configuration.rb +66 -3
  94. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  95. data/lib/rigor/effects/attribution.rb +11 -2
  96. data/lib/rigor/effects/callee_rule.rb +368 -0
  97. data/lib/rigor/effects/catalog.rb +7 -4
  98. data/lib/rigor/effects/collector.rb +11 -5
  99. data/lib/rigor/effects/config_envelopes.rb +9 -2
  100. data/lib/rigor/effects/definition_context.rb +179 -0
  101. data/lib/rigor/effects/effect_table.rb +11 -3
  102. data/lib/rigor/effects/envelope_check.rb +1 -1
  103. data/lib/rigor/effects/envelope_index.rb +15 -0
  104. data/lib/rigor/effects/file_collection.rb +59 -5
  105. data/lib/rigor/effects/framework_units.rb +1 -1
  106. data/lib/rigor/effects/identity.rb +16 -0
  107. data/lib/rigor/effects/local_ownership.rb +38 -12
  108. data/lib/rigor/effects/method_key.rb +21 -0
  109. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  110. data/lib/rigor/effects/plugin_facts.rb +43 -31
  111. data/lib/rigor/effects/propagator.rb +295 -16
  112. data/lib/rigor/effects/registry.rb +1 -1
  113. data/lib/rigor/effects/scanner.rb +121 -72
  114. data/lib/rigor/effects/signature_sources.rb +1 -1
  115. data/lib/rigor/effects/snapshot.rb +2 -1
  116. data/lib/rigor/effects/summary.rb +27 -4
  117. data/lib/rigor/effects/unit_scan.rb +385 -30
  118. data/lib/rigor/effects/visibility.rb +101 -0
  119. data/lib/rigor/environment/lockfile_resolver.rb +17 -0
  120. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  121. data/lib/rigor/environment/member_consistency.rb +298 -0
  122. data/lib/rigor/environment/rbs_loader.rb +399 -133
  123. data/lib/rigor/environment.rb +103 -38
  124. data/lib/rigor/hashing/xxh3.rb +264 -0
  125. data/lib/rigor/inference/acceptance.rb +139 -9
  126. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  127. data/lib/rigor/inference/block_call_timing.rb +338 -0
  128. data/lib/rigor/inference/block_parameter_binder.rb +73 -27
  129. data/lib/rigor/inference/block_repetition.rb +71 -0
  130. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  131. data/lib/rigor/inference/budget_trace.rb +2 -1
  132. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  133. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  134. data/lib/rigor/inference/captured_locals.rb +387 -15
  135. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  136. data/lib/rigor/inference/content_join.rb +200 -27
  137. data/lib/rigor/inference/def_return_typer.rb +11 -7
  138. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  139. data/lib/rigor/inference/element_read_widening.rb +22 -10
  140. data/lib/rigor/inference/error_info.rb +196 -0
  141. data/lib/rigor/inference/expression_typer.rb +1532 -496
  142. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  143. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  144. data/lib/rigor/inference/global_write_census.rb +239 -0
  145. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  146. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  147. data/lib/rigor/inference/index_write_widening.rb +16 -3
  148. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  149. data/lib/rigor/inference/jump_targets.rb +82 -0
  150. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  151. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  152. data/lib/rigor/inference/last_line.rb +340 -0
  153. data/lib/rigor/inference/last_status.rb +144 -0
  154. data/lib/rigor/inference/macro_block_self_type.rb +167 -17
  155. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  156. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  157. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  158. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  159. data/lib/rigor/inference/match_rebinding.rb +392 -0
  160. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  161. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  162. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  163. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  164. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  165. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  166. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  167. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  168. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  169. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
  170. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  171. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  172. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  173. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  174. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  175. data/lib/rigor/inference/multi_target_binder.rb +340 -52
  176. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  177. data/lib/rigor/inference/mutation_widening.rb +70 -48
  178. data/lib/rigor/inference/narrowing.rb +540 -120
  179. data/lib/rigor/inference/operand_effects.rb +167 -0
  180. data/lib/rigor/inference/operand_walk.rb +88 -0
  181. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  182. data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
  183. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  184. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  185. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  186. data/lib/rigor/inference/receiver_alias.rb +90 -1
  187. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  188. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  189. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  190. data/lib/rigor/inference/return_barrier.rb +54 -0
  191. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  192. data/lib/rigor/inference/scope_indexer.rb +4615 -551
  193. data/lib/rigor/inference/statement_evaluator.rb +3176 -490
  194. data/lib/rigor/inference/stored_block_call.rb +54 -0
  195. data/lib/rigor/inference/string_mutation.rb +44 -7
  196. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  197. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  198. data/lib/rigor/language_server/debouncer.rb +0 -1
  199. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  200. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  201. data/lib/rigor/language_server/project_context.rb +5 -3
  202. data/lib/rigor/mcp/server.rb +2 -1
  203. data/lib/rigor/plugin/base.rb +168 -5
  204. data/lib/rigor/plugin/box_probe.rb +91 -0
  205. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  206. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  207. data/lib/rigor/plugin/loader.rb +2 -1
  208. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  209. data/lib/rigor/plugin/manifest.rb +71 -10
  210. data/lib/rigor/plugin/registry.rb +35 -1
  211. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  212. data/lib/rigor/plugin/template_unit.rb +196 -0
  213. data/lib/rigor/plugin.rb +1 -0
  214. data/lib/rigor/protection/discovery_seed.rb +3 -1
  215. data/lib/rigor/protection/kill_signature.rb +0 -1
  216. data/lib/rigor/protection/mutation_cache.rb +1 -2
  217. data/lib/rigor/rbs_extended.rb +27 -0
  218. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  219. data/lib/rigor/reflection/constant_path.rb +19 -6
  220. data/lib/rigor/reflection.rb +72 -105
  221. data/lib/rigor/scope/discovery_index.rb +135 -2
  222. data/lib/rigor/scope.rb +859 -49
  223. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  224. data/lib/rigor/sig_gen/classification.rb +22 -5
  225. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  226. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  227. data/lib/rigor/sig_gen/generator.rb +558 -31
  228. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  229. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  230. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  231. data/lib/rigor/sig_gen/renderer.rb +124 -9
  232. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  233. data/lib/rigor/sig_gen/write_result.rb +19 -3
  234. data/lib/rigor/sig_gen/writer.rb +166 -35
  235. data/lib/rigor/sig_gen.rb +3 -0
  236. data/lib/rigor/signature_path_audit.rb +1 -1
  237. data/lib/rigor/source/node_walker.rb +0 -3
  238. data/lib/rigor/source/parameter_envelope.rb +72 -0
  239. data/lib/rigor/source.rb +1 -0
  240. data/lib/rigor/type/combinator.rb +88 -12
  241. data/lib/rigor/type/difference.rb +1 -0
  242. data/lib/rigor/type/hash_shape.rb +1 -1
  243. data/lib/rigor/type/refined.rb +1 -0
  244. data/lib/rigor/version.rb +1 -1
  245. data/lib/rigor.rb +1 -0
  246. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  247. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  248. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  249. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  250. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  251. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  252. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  253. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  254. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  255. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  256. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  257. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  258. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  259. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  260. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  261. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  262. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  263. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  264. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  265. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  266. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  267. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  268. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  269. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  270. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  271. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  272. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  273. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  274. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  275. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  276. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  277. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  278. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  279. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  280. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  281. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  282. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  283. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  284. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  285. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  286. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  287. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  288. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  289. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  290. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  291. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  292. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  293. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  294. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  295. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  296. data/sig/rigor/analysis/baseline.rbs +69 -7
  297. data/sig/rigor/analysis/fact_store.rbs +1 -1
  298. data/sig/rigor/analysis/project_scan.rbs +74 -0
  299. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  300. data/sig/rigor/effects/effect_table.rbs +60 -0
  301. data/sig/rigor/effects/envelope.rbs +97 -0
  302. data/sig/rigor/effects/envelope_index.rbs +33 -0
  303. data/sig/rigor/effects/file_collection.rbs +81 -0
  304. data/sig/rigor/effects/label.rbs +26 -0
  305. data/sig/rigor/effects/label_set.rbs +46 -0
  306. data/sig/rigor/effects/method_key.rbs +24 -0
  307. data/sig/rigor/effects/origin.rbs +50 -0
  308. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  309. data/sig/rigor/effects/registry.rbs +68 -0
  310. data/sig/rigor/effects/summary.rbs +50 -0
  311. data/sig/rigor/effects/taint_cause.rbs +12 -0
  312. data/sig/rigor/environment.rbs +12 -10
  313. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  314. data/sig/rigor/inference.rbs +6 -4
  315. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  316. data/sig/rigor/plugin/base.rbs +30 -7
  317. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  318. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  319. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  320. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  321. data/sig/rigor/plugin/io_boundary.rbs +1 -1
  322. data/sig/rigor/plugin/loader.rbs +3 -3
  323. data/sig/rigor/plugin/manifest.rbs +50 -11
  324. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  325. data/sig/rigor/plugin/registry.rbs +62 -1
  326. data/sig/rigor/plugin.rbs +1 -1
  327. data/sig/rigor/rbs_extended.rbs +1 -1
  328. data/sig/rigor/reflection.rbs +7 -6
  329. data/sig/rigor/scope.rbs +135 -20
  330. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  331. data/sig/rigor/source.rbs +4 -4
  332. data/sig/rigor/testing.rbs +10 -4
  333. data/sig/rigor/type.rbs +6 -0
  334. data/sig/rigor.rbs +38 -20
  335. data/skills/rigor-ask/SKILL.md +8 -5
  336. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  337. data/skills/rigor-ci-setup/SKILL.md +17 -21
  338. data/skills/rigor-doctor/SKILL.md +24 -22
  339. data/skills/rigor-doctor/references/01-checks.md +97 -33
  340. data/skills/rigor-editor-setup/SKILL.md +6 -4
  341. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  342. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  343. data/skills/rigor-next-steps/SKILL.md +4 -2
  344. data/skills/rigor-plugin-author/SKILL.md +19 -23
  345. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  346. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  347. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  348. data/skills/rigor-plugin-review/SKILL.md +6 -4
  349. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  350. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  351. data/skills/rigor-project-init/SKILL.md +9 -7
  352. data/skills/rigor-project-init/references/01-detect.md +13 -9
  353. data/skills/rigor-project-init/references/02-configure.md +33 -8
  354. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  355. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  356. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  357. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  358. data/skills/rigor-type-oracle/SKILL.md +4 -6
  359. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  360. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  361. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  362. data/skills/rigor-upgrade/SKILL.md +13 -8
  363. metadata +108 -1
@@ -9,6 +9,12 @@ require_relative "../environment"
9
9
  require_relative "../rbs_extended"
10
10
  require_relative "../analysis/fact_store"
11
11
  require_relative "../builtins/regex_refinement"
12
+ require_relative "guard_rebinding"
13
+ require_relative "last_line"
14
+ require_relative "operand_effects"
15
+ require_relative "optimistic_origin"
16
+ require_relative "project_method_ownership"
17
+ require_relative "receiver_alias"
12
18
 
13
19
  module Rigor
14
20
  module Inference
@@ -50,8 +56,27 @@ module Rigor
50
56
  # a value-pinned `Constant[Regexp]` share this carrier so the participation walk and the
51
57
  # extended-mode bail read one shape.
52
58
  RegexMatchPattern = Data.define(:source, :extended)
59
+ # Issue #1429 — a predicate receiver whose binding an edge can narrow ({.receiver_slot}): a local, an instance
60
+ # variable or a global (`name` its Symbol), or a constant reference (`name` its {.constant_key}). `current` is
61
+ # the type the receiver reads before the predicate, a global's read type when the scope does not bind it.
62
+ ReceiverSlot = Data.define(:kind, :name, :current)
63
+ # The globals a guard narrows without a record ({Scope#with_guarded_global}): `$_` and `$~` live in the frame's
64
+ # special-variable slot, which a called method does not reach and their own machinery forgets
65
+ # (`Scope#forget_last_line`, `Scope#forget_match_globals`), and `$!` / `$@` read the rescue clause that is
66
+ # running, which a call that returns leaves as it was.
67
+ UNRECORDED_GUARD_GLOBALS = %i[$_ $~ $! $@].freeze
68
+ # Issue #1429 — the value-pinned classes: a `Nominal` of one of them is the single value itself, which no
69
+ # program replaces with another class's instance, so it counts with the literal carriers
70
+ # ({.disjoint_nominal_guard?}, `respond_to?`'s edge).
71
+ VALUE_PINNED_CLASSES = %w[NilClass TrueClass FalseClass].freeze
53
72
  private_constant :TRUSTED_EQUALITY_LITERAL_CLASSES, :SINGLETON_LITERAL_CLASSES, :ClassNarrowingContext,
54
- :RegexMatchPattern
73
+ :RegexMatchPattern, :ReceiverSlot, :UNRECORDED_GUARD_GLOBALS, :VALUE_PINNED_CLASSES
74
+
75
+ # Issue #1017 — the tallest stack of conditionals a condition may carry and still contribute facts. A
76
+ # conditional guard analyses its predicate and both arms, so each level can triple the analysis; past this
77
+ # height the guard declines to "no narrowing". Height counts conditionals reached through the shapes the
78
+ # analyser walks (parentheses, a body's last statement, `&&` / `||`, `!`, a predicate, an arm).
79
+ CONDITIONAL_GUARD_DEPTH = 2
55
80
 
56
81
  module_function
57
82
 
@@ -120,10 +145,9 @@ module Rigor
120
145
  # falsey fragments above: `:truthy` when no inhabitant is falsey (the falsey fragment is
121
146
  # `Bot`), `:falsey` when no inhabitant is truthy, nil when both fragments are inhabited —
122
147
  # or when the type itself is nil / `Bot` (dead code is not a certainty claim). This is the
123
- # single owner of the judgment both branch-elision consumers read
124
- # (`ExpressionTyper#elide_or_union` on the value side, `StatementEvaluator#live_branch_for_if`
125
- # on the scope side), so the type a dead branch is elided from and the scope that stops
126
- # flowing through it can never disagree.
148
+ # single owner of the judgment branch elision reads (`StatementEvaluator#live_branch_for_if`,
149
+ # which a value-position conditional reaches too since issue #1003), so the type a dead
150
+ # branch is elided from and the scope that stops flowing through it can never disagree.
127
151
  def predicate_certainty(type)
128
152
  return nil if type.nil? || type.is_a?(Type::Bot)
129
153
 
@@ -320,6 +344,22 @@ module Rigor
320
344
  narrow_class_dispatch(type, class_name, context)
321
345
  end
322
346
 
347
+ # Issue #1429 — true when `type` holds a member whose `Bot` under `is_a?(class_name)` rests only on Rigor's
348
+ # reading of its type: a `Nominal`, other than the value-pinned `nil` / `true` / `false` classes, whose class
349
+ # the environment orders `:disjoint` from `class_name`. A `Nominal` is a type Rigor inferred or read from a
350
+ # signature, and a program can hold another object there: `$stdout` is typed `IO`, and a test runs it with a
351
+ # `StringIO`, which is not an `IO` subclass. The narrowing still reads such a guard's edge as `Bot`, so the
352
+ # arm is not checked; what this answers is whether the `Bot` may stand as a verdict that the code is dead
353
+ # (`flow.unreachable-clause`). The carriers that record what the file literally shows (`Constant`, `Tuple`,
354
+ # `HashShape`, `Singleton`) and the value-pinned classes do not count, so their verdicts stand.
355
+ def disjoint_nominal_guard?(type, class_name, environment: Environment.default)
356
+ members = type.is_a?(Type::Union) ? type.members : [type]
357
+ members.any? do |member|
358
+ member.is_a?(Type::Nominal) && !VALUE_PINNED_CLASSES.include?(member.class_name) &&
359
+ member.class_name != class_name && environment.class_ordering(member.class_name, class_name) == :disjoint
360
+ end
361
+ end
362
+
323
363
  # Mirror of {.narrow_class} for the falsey edge of `is_a?`/`kind_of?`/`instance_of?`.
324
364
  # Inhabitants that DO satisfy the predicate are removed; inhabitants that do not are
325
365
  # preserved. Conservative on Top/Dynamic/Bot (preserved unchanged) because the analyzer
@@ -390,6 +430,16 @@ module Rigor
390
430
  fact.type
391
431
  end
392
432
 
433
+ # Issue #1429 — the key a constant reference's guard narrowing is recorded under ({Scope#constant_narrowing}):
434
+ # its spelling, with a leading `::` kept, since `::Foo` and `Foo` may name two constants. nil for a path on a
435
+ # dynamic base (`expr::Foo`) and for any other node.
436
+ def constant_key(node)
437
+ name = Source::ConstantPath.qualified_name_or_nil(node)
438
+ return nil if name.nil?
439
+
440
+ Source::ConstantPath.rooted?(node) ? "::#{name}" : name
441
+ end
442
+
393
443
  # Public predicate analyser. Returns `[truthy_scope, falsey_scope]`, always; when no
394
444
  # narrowing rule matches the predicate node both entries are the receiver scope unchanged.
395
445
  def predicate_scopes(node, scope)
@@ -399,6 +449,37 @@ module Rigor
399
449
  result || [scope, scope]
400
450
  end
401
451
 
452
+ # Issue #1468 — the scope the arguments and block of a safe-navigation call `recv&.m(…)` run under. Ruby
453
+ # evaluates them only once `recv` is known non-nil, so `klass&.new(klass.flag?)` reads `klass` without its
454
+ # `nil`, as `if klass then klass.new(klass.flag?) end` does. Only `nil` is removed: `false&.m(x)` still calls
455
+ # `m`. The call's own value keeps its skipped-call `nil` (`ExpressionTyper#safe_navigation_call_type`).
456
+ #
457
+ # The receiver is one a guard narrows ({.receiver_slot}: a local, instance variable, global or constant), or a
458
+ # safe-navigation call on such a receiver whose own arguments and block hold no write or jump
459
+ # ({OperandEffects}): `a&.b(a.c)&.d(a.e)` runs `d` only when `a&.b(…)` answered non-nil, which it cannot on a
460
+ # nil `a`, and nothing between the two reads rebinds `a`. `scope` itself when nothing narrows, and for a call
461
+ # that is not `&.`.
462
+ def safe_navigation_scope(call_node, scope)
463
+ return scope unless call_node.safe_navigation?
464
+
465
+ non_nil_receiver_scope(call_node.receiver, scope) || scope
466
+ end
467
+
468
+ # The scope the block of a safe-navigation call is entered from: {.safe_navigation_scope}, unless the narrowing
469
+ # may not hold for the whole body. An argument that writes may rebind the receiver before the block runs
470
+ # (`f&.two(f = nil) { f.run }`), and a block that writes the receiver hands the next iteration what it wrote
471
+ # (`a&.each { a.size; a = nil }`); either keeps `scope`.
472
+ def safe_navigation_block_scope(call_node, scope)
473
+ return scope unless call_node.safe_navigation?
474
+ return scope if OperandEffects.any?(call_node.arguments)
475
+
476
+ root = call_node.receiver
477
+ root = root.receiver while root.is_a?(Prism::CallNode) && root.safe_navigation?
478
+ return scope if root.respond_to?(:name) && OperandEffects.written_variables(call_node.block).include?(root.name)
479
+
480
+ safe_navigation_scope(call_node, scope)
481
+ end
482
+
402
483
  # Slice 7 phase 5 — `case`/`when` narrowing.
403
484
  #
404
485
  # Given the subject of a `case` (the expression after the `case` keyword) and an array of
@@ -416,10 +497,6 @@ module Rigor
416
497
  # scope. The catalogue mirrors {.case_equality_target_class}: static class/module
417
498
  # constants narrow as `is_a?`; integer/float-endpoint ranges narrow to `Numeric`;
418
499
  # string-endpoint ranges and regexp literals narrow to `String`.
419
- #
420
- # @param subject — the `case` subject.
421
- # @param conditions — the `when`
422
- # clause's `conditions` array.
423
500
  def case_when_scopes(subject, conditions, scope)
424
501
  # C1 — `case x when /re/` runs `/re/ === x`, which sets the regex match-data globals
425
502
  # exactly as a successful `=~` does. Narrow `$~`/`$&`/`$1..$N` on the clause body (the
@@ -427,17 +504,17 @@ module Rigor
427
504
  # a different regex. Applied even when the subject is not a narrowable local read.
428
505
  body_scope = apply_when_regex_globals(conditions, scope)
429
506
 
430
- return [body_scope, scope] unless subject.is_a?(Prism::LocalVariableReadNode)
507
+ # Issue #1429 — a global or constant subject (`case $stdout when StringIO`) narrows as a local does.
508
+ slot = receiver_slot(subject, scope, kinds: CASE_SUBJECT_KINDS)
509
+ return [body_scope, scope] if slot.nil?
431
510
 
432
- local_name = subject.name
433
- current = scope.local(local_name)
434
- return [body_scope, scope] if current.nil?
435
-
436
- truthy, = accumulate_case_when_scopes(body_scope, local_name, current, conditions)
437
- _, falsey = accumulate_case_when_scopes(scope, local_name, current, conditions)
438
- [truthy, falsey]
511
+ truthy_type, falsey_type = case_when_types(scope, slot.current, conditions)
512
+ [narrow_slot(body_scope, slot, truthy_type), narrow_slot(scope, slot, falsey_type)]
439
513
  end
440
514
 
515
+ CASE_SUBJECT_KINDS = %i[local global constant].freeze
516
+ private_constant :CASE_SUBJECT_KINDS
517
+
441
518
  # When the clause has exactly one `RegularExpressionNode` literal condition, narrow the
442
519
  # match-data globals on the body edge (same rule as `analyse_regex_match_predicate`'s
443
520
  # truthy edge). With multiple regex conditions (`when /a/, /b/`) the body is reachable
@@ -460,8 +537,29 @@ module Rigor
460
537
 
461
538
  # Internal analyser. Returns `[truthy_scope, falsey_scope]` when the predicate shape is
462
539
  # recognised, or `nil` to signal "no narrowing" so the public surface can fall back to the
463
- # entry scope.
540
+ # entry scope. Issue #1359 — a condition whose value is a `gets`-family reader's, the bare call
541
+ # or a variable written with it (`while gets`, `if (line = gets)`), also binds `$_` on each edge
542
+ # to the line the reader returned ({LastLine.predicate_scopes}).
464
543
  def analyse(node, scope)
544
+ edges = analyse_shape(node, scope)
545
+ reader = last_line_condition(node)
546
+ reader ? LastLine.predicate_scopes(reader, scope, edges) : edges
547
+ end
548
+
549
+ # The reader call a condition's value is, or nil.
550
+ def last_line_condition(node)
551
+ call =
552
+ case node
553
+ when Prism::CallNode then node
554
+ when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode,
555
+ Prism::GlobalVariableWriteNode
556
+ node.value
557
+ end
558
+ call if call.is_a?(Prism::CallNode) && LastLine::READERS.include?(call.name)
559
+ end
560
+ private_class_method :last_line_condition
561
+
562
+ def analyse_shape(node, scope)
465
563
  case node
466
564
  when Prism::ParenthesesNode
467
565
  analyse_parentheses(node, scope)
@@ -473,20 +571,19 @@ module Rigor
473
571
  analyse_local_write(node, scope)
474
572
  when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
475
573
  analyse_ivar(node, scope)
476
- when Prism::ClassVariableWriteNode
477
- analyse_cvar_write(node, scope)
478
- when Prism::GlobalVariableWriteNode
479
- analyse_global_write(node, scope)
574
+ when Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode
575
+ analyse_cvar_or_global_write(node, scope)
576
+ when Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode
577
+ analyse_slot_read(node, scope)
480
578
  when Prism::CallNode
481
- analyse_call(node, scope)
482
- when Prism::AndNode
483
- analyse_and(node, scope)
484
- when Prism::OrNode
485
- analyse_or(node, scope)
579
+ analyse_call(write_receiver_as_read(node) || node, scope)
580
+ when Prism::AndNode, Prism::OrNode, Prism::IfNode, Prism::UnlessNode
581
+ analyse_composition(node, scope)
486
582
  when Prism::MatchWriteNode
487
583
  analyse_match_write(node, scope)
488
584
  end
489
585
  end
586
+ private_class_method :analyse_shape
490
587
 
491
588
  # rubocop:disable-next Metrics/ClassLength
492
589
  class << self
@@ -843,6 +940,12 @@ module Rigor
843
940
  ]
844
941
  end
845
942
 
943
+ def analyse_cvar_or_global_write(node, scope)
944
+ return analyse_cvar_write(node, scope) if node.is_a?(Prism::ClassVariableWriteNode)
945
+
946
+ analyse_global_write(node, scope)
947
+ end
948
+
846
949
  def analyse_cvar_write(node, scope)
847
950
  current = scope.cvar(node.name)
848
951
  return nil if current.nil?
@@ -863,6 +966,76 @@ module Rigor
863
966
  ]
864
967
  end
865
968
 
969
+ # Issue #1429 — `if $g`, `return unless $g`, `if CONFIG`: the truthiness guard on a global or constant
970
+ # read, the counterpart of {#analyse_local_read} ({#narrow_slot}).
971
+ def analyse_slot_read(node, scope)
972
+ slot = receiver_slot(node, scope, kinds: GLOBAL_AND_CONSTANT)
973
+ return nil if slot.nil?
974
+
975
+ [
976
+ narrow_slot(scope, slot, narrow_truthy(slot.current)),
977
+ narrow_slot(scope, slot, narrow_falsey(slot.current))
978
+ ]
979
+ end
980
+
981
+ GLOBAL_AND_CONSTANT = %i[global constant].freeze
982
+ ALL_SLOT_KINDS = %i[local ivar global constant].freeze
983
+ private_constant :GLOBAL_AND_CONSTANT, :ALL_SLOT_KINDS
984
+
985
+ # The {ReceiverSlot} `node` reads, among `kinds`, or nil. A local or instance variable needs a binding. A
986
+ # global the scope does not bind is read as it reads unbound, and a constant reference as it resolves
987
+ # (through any narrowing already on it), so a guard narrows it from that type.
988
+ def receiver_slot(node, scope, kinds: ALL_SLOT_KINDS)
989
+ case node
990
+ when Prism::LocalVariableReadNode then variable_slot(:local, node.name, scope.local(node.name), kinds)
991
+ when Prism::InstanceVariableReadNode then variable_slot(:ivar, node.name, scope.ivar(node.name), kinds)
992
+ when Prism::GlobalVariableReadNode then global_slot(node, scope, kinds)
993
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then constant_slot(node, scope, kinds)
994
+ end
995
+ end
996
+
997
+ def variable_slot(kind, name, current, kinds)
998
+ return nil if current.nil? || !kinds.include?(kind)
999
+
1000
+ ReceiverSlot.new(kind: kind, name: name, current: current)
1001
+ end
1002
+
1003
+ def global_slot(node, scope, kinds)
1004
+ return nil unless kinds.include?(:global)
1005
+
1006
+ ReceiverSlot.new(kind: :global, name: node.name, current: scope.global(node.name) || scope.type_of(node))
1007
+ end
1008
+
1009
+ def constant_slot(node, scope, kinds)
1010
+ return nil unless kinds.include?(:constant)
1011
+
1012
+ key = constant_key(node)
1013
+ return nil if key.nil?
1014
+
1015
+ ReceiverSlot.new(kind: :constant, name: key, current: scope.type_of(node))
1016
+ end
1017
+
1018
+ # `scope` with `slot`'s receiver read as `type` on one edge. A local or instance variable rebinds as its
1019
+ # analysers always did. A global or constant is left alone when the edge learns nothing (`type` is what
1020
+ # it read), and otherwise narrows with its pre-guard type recorded, so code that may rebind it restores
1021
+ # the binding ({Scope#forget_guard_narrowings}); the frame-local specials skip the record.
1022
+ def narrow_slot(scope, slot, type)
1023
+ case slot.kind
1024
+ when :local then scope.with_local(slot.name, type)
1025
+ when :ivar then scope.with_ivar(slot.name, type)
1026
+ when :global then narrow_global_slot(scope, slot, type)
1027
+ else
1028
+ type == slot.current ? scope : scope.with_constant_narrowing(slot.name, type, slot.current)
1029
+ end
1030
+ end
1031
+
1032
+ def narrow_global_slot(scope, slot, type)
1033
+ return scope if type == slot.current
1034
+
1035
+ record = !UNRECORDED_GUARD_GLOBALS.include?(slot.name)
1036
+ scope.with_guarded_global(slot.name, type, slot.current, record: record)
1037
+ end
1038
+
866
1039
  # `if /(?<x>...)/ =~ str` — Prism wraps the `=~` call in a `MatchWriteNode` listing the
867
1040
  # named-capture targets. The parent `eval_match_write` has already bound each target to
868
1041
  # `String | nil`; in the truthy branch (the regex matched) every named capture is
@@ -918,6 +1091,25 @@ module Rigor
918
1091
  # literals (Slice 6 phase 2 sub-phase 2).
919
1092
  # Anything else returns nil so the surrounding analyser falls through to the
920
1093
  # no-narrowing fallback.
1094
+ # Issue #1223 — `(x = e).nil?`, `(x ||= e).empty?`, `(@x = e).is_a?(C)`. A write's value is the binding
1095
+ # it leaves, and a predicate is narrowed in the scope after its receiver ran, so the call narrows the
1096
+ # variable exactly as the same call on a read of it would. Every analyser keys its receiver on a read
1097
+ # node, so the call is analysed with its receiver replaced by one ({ReceiverAlias.read_of}). Before #1223
1098
+ # the statement evaluator did not thread a receiver's write at all, so the local was unbound and there
1099
+ # was nothing to narrow; nil when the receiver is not such a write, bare or parenthesised on its own.
1100
+ def write_receiver_as_read(node)
1101
+ write = written_receiver(node.receiver)
1102
+ write && node.copy(receiver: ReceiverAlias.read_of(write))
1103
+ end
1104
+
1105
+ def written_receiver(receiver)
1106
+ while receiver.is_a?(Prism::ParenthesesNode) && receiver.body.is_a?(Prism::StatementsNode) &&
1107
+ receiver.body.body.size == 1
1108
+ receiver = receiver.body.body.first
1109
+ end
1110
+ receiver if ReceiverAlias.variable_write?(receiver)
1111
+ end
1112
+
921
1113
  def analyse_call(node, scope)
922
1114
  return nil if node.block
923
1115
 
@@ -1068,11 +1260,19 @@ module Rigor
1068
1260
  # symbol is one of `NilClass`'s own methods (`:to_s`, `:inspect`, `:nil?`, …) — `nil`
1069
1261
  # DOES respond to those, so the truthy edge admits a nil receiver and we narrow nothing.
1070
1262
  #
1263
+ # Issue #1429 — the truthy edge also admits `m` itself. `respond_to?` is a guard written in the code
1264
+ # (ADR-117 Decision point 3), so a member of the receiver's type whose class Rigor knows to lack `m`
1265
+ # is not what the edge holds: members that may respond keep their type, and when no member may, the
1266
+ # receiver reads `Dynamic[top]` ({#respond_to_admitted}). `$stdout.respond_to?(:string) ?
1267
+ # $stdout.string : ""` on an `IO`-typed `$stdout` therefore does not report `undefined method`.
1268
+ # `Dynamic[top]` rather than `Dynamic[IO]`: method availability on a `Dynamic[T]` is checked against
1269
+ # `T` (special-types.md), so only the untyped carrier is sure to keep the guarded call quiet.
1270
+ #
1071
1271
  # Conservative floor: narrow only on a literal `Symbol`/`String` argument resolved
1072
1272
  # against the RBS environment; a non-literal symbol, a missing argument, or a symbol
1073
1273
  # that IS in `NilClass`'s method set declines. The falsey edge is always the no-op
1074
- # ("does not respond" proves little about the receiver's type). Narrowing-only: it
1075
- # removes the `nil` constituent and never promotes a non-nil type.
1274
+ # ("does not respond" proves little about the receiver's type). A local, instance variable,
1275
+ # global or constant receiver narrows ({#receiver_slot}).
1076
1276
  def analyse_respond_to_predicate(node, scope)
1077
1277
  return nil if node.block
1078
1278
  return nil if node.arguments.nil? || node.arguments.arguments.size != 1
@@ -1081,16 +1281,63 @@ module Rigor
1081
1281
  return nil if sym.nil?
1082
1282
  return nil if nilclass_method?(sym, scope)
1083
1283
 
1084
- reader, writer = emptiness_receiver_accessors(node.receiver)
1085
- return nil if reader.nil?
1284
+ slot = receiver_slot(node.receiver, scope)
1285
+ return nil if slot.nil?
1086
1286
 
1087
- current = scope.public_send(reader, node.receiver.name)
1088
- return nil if current.nil?
1287
+ admitted = respond_to_admitted(narrow_non_nil(slot.current), sym.to_sym, scope)
1288
+ return nil if admitted.equal?(slot.current) || admitted == slot.current
1089
1289
 
1090
- non_nil = narrow_non_nil(current)
1091
- return nil if non_nil.equal?(current)
1290
+ [narrow_slot(scope, slot, admitted), scope]
1291
+ end
1292
+
1293
+ # The members of `type` that may respond to `method_name`, or, when Rigor knows every member lacks it
1294
+ # ({#lacks_method?}), `Dynamic[top]`: the members Rigor read the edge from are wrong about the object there,
1295
+ # and no class is named to read it as instead. A member Rigor cannot judge (`Dynamic`, `Bot`, a class no
1296
+ # signature declares) may respond.
1297
+ #
1298
+ # When every member lacking `m` records what the file literally shows (a literal, a `Tuple`, a `HashShape`, a
1299
+ # class object, `nil` / `true` / `false`), the edge is `Bot`. For a literal value that is exact
1300
+ # (`1.respond_to?(:string)` is false in every run); a class object's arm goes unchecked when a gem without a
1301
+ # signature adds the method (`Time.respond_to?(:zone)` under ActiveSupport).
1302
+ def respond_to_admitted(type, method_name, scope)
1303
+ members = type.is_a?(Type::Union) ? type.members : [type]
1304
+ kept = members.reject { |member| lacks_method?(member, method_name, scope) }
1305
+ return type if kept.size == members.size
1306
+ return Type::Combinator.union(*kept) unless kept.empty?
1307
+ return Type::Combinator.bot if members.all? { |member| literal_carrier?(member) }
1092
1308
 
1093
- [scope.public_send(writer, node.receiver.name, non_nil), scope]
1309
+ Type::Combinator.untyped
1310
+ end
1311
+
1312
+ LITERAL_CARRIERS = [Type::Constant, Type::Tuple, Type::HashShape, Type::Singleton].freeze
1313
+ private_constant :LITERAL_CARRIERS
1314
+
1315
+ def literal_carrier?(member)
1316
+ LITERAL_CARRIERS.any? { |carrier| member.is_a?(carrier) } ||
1317
+ (member.is_a?(Type::Nominal) && VALUE_PINNED_CLASSES.include?(member.class_name))
1318
+ end
1319
+
1320
+ # True only when every class `member` dispatches through is declared in RBS, is not a mixin module or
1321
+ # `Object` / `BasicObject` (whose instances may be of any class), is not a plugin's open receiver, and
1322
+ # neither its signature nor the project defines `method_name` on it.
1323
+ def lacks_method?(member, method_name, scope)
1324
+ targets = ProjectMethodOwnership.targets(member)
1325
+ return false if targets.nil? || targets.empty?
1326
+
1327
+ targets.all? { |class_name, kind| class_lacks_method?(class_name, method_name, kind, scope) }
1328
+ end
1329
+
1330
+ OPEN_SURFACE_CLASSES = %w[Object BasicObject].freeze
1331
+ private_constant :OPEN_SURFACE_CLASSES
1332
+
1333
+ def class_lacks_method?(class_name, method_name, kind, scope)
1334
+ return false if kind == :instance && OPEN_SURFACE_CLASSES.include?(class_name)
1335
+ return false unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
1336
+ return false if kind == :instance && scope.environment.rbs_module?(class_name)
1337
+ return false if scope.environment.plugin_registry&.open_receiver?(class_name)
1338
+ return false if ProjectMethodOwnership.defines?(class_name, method_name, kind, scope)
1339
+
1340
+ ExternalAncestorResolution.method_definition(class_name, method_name, kind, scope: scope).nil?
1094
1341
  end
1095
1342
 
1096
1343
  # True when `nil` responds to `sym` — i.e. `NilClass` (own, inherited
@@ -1409,13 +1656,17 @@ module Rigor
1409
1656
  # `String`; every other numbered group present in the pattern stays `String | nil` on
1410
1657
  # both edges (a truthy match leaves an optional group nil at runtime), so we do not
1411
1658
  # narrow it on the truthy edge.
1659
+ #
1660
+ # Both edges start from `scope` with the match globals forgotten (#1385): the match rebinds every one of them,
1661
+ # so a `$N` an earlier match narrowed says nothing here. Without that, a `when /(x)/` arm, or the right operand
1662
+ # of `s =~ /(a)(b)(c)/ && t =~ /(x)/`, kept `$2` and `$3` narrowed to `String` where Ruby reads nil.
1412
1663
  def regex_match_predicate_scopes(scope, unconditional)
1413
1664
  string_t = Type::Combinator.nominal_of("String")
1414
1665
  match_data_t = Type::Combinator.nominal_of("MatchData")
1415
1666
  nil_t = Type::Combinator.constant_of(nil)
1416
1667
 
1417
- truthy = scope
1418
- falsey = scope
1668
+ truthy = scope.forget_match_globals
1669
+ falsey = truthy
1419
1670
  truthy = truthy.with_global(:$~, match_data_t)
1420
1671
  falsey = falsey.with_global(:$~, nil_t)
1421
1672
  REGEX_MATCH_GLOBALS.each do |name|
@@ -1449,8 +1700,10 @@ module Rigor
1449
1700
  # them to non-nil `String` would be unsound. The walker is intentionally light (char
1450
1701
  # scan, not a regex-AST parse): backslash escapes are skipped; `(?:…)`, lookahead
1451
1702
  # `(?=…)`/`(?!…)`, and lookbehind `(?<=…)`/`(?<!…)` do not capture; named groups
1452
- # `(?<name>…)` do. Conservatism is one-directional — when in doubt a group is treated
1453
- # as conditional (dropped from the Set), never the reverse.
1703
+ # `(?<name>…)` / `(?'name'…)` do. Once the pattern has a named group, Ruby numbers only
1704
+ # the named groups and a plain `(…)` does not capture (#1471): `/(?<x>a)(b)/` has one
1705
+ # group, so `$2` is nil. Conservatism is one-directional — when in doubt a group is
1706
+ # treated as conditional (dropped from the Set), never the reverse.
1454
1707
  def unconditional_capture_groups(source)
1455
1708
  # `unconditional` collects every capturing index; a group is later removed (with its
1456
1709
  # whole subtree) when it is optionally quantified, nested under an optional ancestor,
@@ -1460,7 +1713,15 @@ module Rigor
1460
1713
  # mutually exclusive, so its descendant captures may be absent on a successful match;
1461
1714
  # the group itself still participates. Closing a frame rolls its subtree up to the
1462
1715
  # parent so an optional / alternated ancestor disqualifies it.
1463
- state = { unconditional: Set.new, stack: [[nil, [], false]], group_index: 0 }
1716
+ state = { unconditional: Set.new, stack: [[nil, [], false]], group_index: 0, named: named_groups?(source) }
1717
+ each_structural_char(source) { |pos, chr| scan_group_char(source, pos, chr, state) }
1718
+ # Drain the virtual root: a top-level `|` disqualifies all.
1719
+ finalize_frame(state, state[:stack].pop, optional: false)
1720
+ state[:unconditional]
1721
+ end
1722
+
1723
+ # Yields each position of `source` outside a backslash escape and a character class, with its char.
1724
+ def each_structural_char(source)
1464
1725
  pos = 0
1465
1726
  length = source.length
1466
1727
  while pos < length
@@ -1473,12 +1734,13 @@ module Rigor
1473
1734
  pos = skip_char_class(source, pos) + 1
1474
1735
  next
1475
1736
  end
1476
- scan_group_char(source, pos, chr, state)
1737
+ yield pos, chr
1477
1738
  pos += 1
1478
1739
  end
1479
- # Drain the virtual root: a top-level `|` disqualifies all.
1480
- finalize_frame(state, state[:stack].pop, optional: false)
1481
- state[:unconditional]
1740
+ end
1741
+
1742
+ def named_groups?(source)
1743
+ each_structural_char(source) { |pos, chr| break true if chr == "(" && named_group?(source, pos) } == true
1482
1744
  end
1483
1745
 
1484
1746
  # Updates the walk `state` at a group-relevant char during
@@ -1488,7 +1750,7 @@ module Rigor
1488
1750
  case chr
1489
1751
  when "("
1490
1752
  idx = nil
1491
- if capturing_group?(source, pos)
1753
+ if capturing_group?(source, pos, named: state[:named])
1492
1754
  idx = (state[:group_index] += 1)
1493
1755
  state[:unconditional] << idx
1494
1756
  end
@@ -1527,11 +1789,20 @@ module Rigor
1527
1789
  end
1528
1790
  end
1529
1791
 
1530
- def capturing_group?(source, pos)
1531
- return true unless source[pos + 1] == "?"
1792
+ # A plain `(…)` captures unless the pattern has a named group (`named:`); of the `(?…)` forms only a named
1793
+ # group does.
1794
+ def capturing_group?(source, pos, named:)
1795
+ return !named unless source[pos + 1] == "?"
1796
+
1797
+ named_group?(source, pos)
1798
+ end
1799
+
1800
+ # `(?<name>…)` and `(?'name'…)` are named groups; `(?<=…)`/`(?<!…)` (lookbehind) and
1801
+ # `(?:…)`/`(?=…)`/`(?!…)` are not.
1802
+ def named_group?(source, pos)
1803
+ return false unless source[pos + 1] == "?"
1804
+ return true if source[pos + 2] == "'"
1532
1805
 
1533
- # `(?<name>…)` captures; `(?<=…)`/`(?<!…)` (lookbehind) and
1534
- # `(?:…)`/`(?=…)`/`(?!…)` do not.
1535
1806
  source[pos + 2] == "<" && source[pos + 3] != "=" && source[pos + 3] != "!"
1536
1807
  end
1537
1808
 
@@ -1962,9 +2233,30 @@ module Rigor
1962
2233
  analyse_class_predicate_on_local(node, scope, class_name, exact)
1963
2234
  when Prism::CallNode
1964
2235
  analyse_class_predicate_on_chain(node, scope, class_name, exact)
2236
+ else
2237
+ analyse_class_predicate_on_slot(node.receiver, scope, class_name, exact)
1965
2238
  end
1966
2239
  end
1967
2240
 
2241
+ # Issue #1429 — `$stdout.is_a?(StringIO)`, `STDOUT.kind_of?(StringIO)`: a global or constant receiver
2242
+ # narrows as a local does ({#class_predicate_scopes}), with the pre-guard type recorded ({#narrow_slot}).
2243
+ def analyse_class_predicate_on_slot(receiver, scope, class_name, exact)
2244
+ slot = receiver_slot(receiver, scope, kinds: GLOBAL_AND_CONSTANT)
2245
+ return nil if slot.nil?
2246
+
2247
+ class_slot_scopes(scope, slot, class_name, exact: exact)
2248
+ end
2249
+
2250
+ def class_slot_scopes(scope, slot, class_name, exact:)
2251
+ environment = scope.environment
2252
+ truthy = narrow_class(slot.current, class_name, exact: exact, environment: environment, scope: scope)
2253
+ [
2254
+ narrow_slot(scope, slot, truthy),
2255
+ narrow_slot(scope, slot, narrow_not_class(slot.current, class_name, exact: exact,
2256
+ environment: environment, scope: scope))
2257
+ ]
2258
+ end
2259
+
1968
2260
  # The class name a `is_a?` / `kind_of?` / `instance_of?` argument denotes: the top-level
1969
2261
  # `bare_name` when the reference is written `::Foo` (nil when nothing declares it there),
1970
2262
  # the lexical resolution otherwise.
@@ -2096,16 +2388,8 @@ module Rigor
2096
2388
  end
2097
2389
 
2098
2390
  def class_predicate_scopes(scope, name, current, class_name, exact:)
2099
- [
2100
- scope.with_local(
2101
- name,
2102
- narrow_class(current, class_name, exact: exact, environment: scope.environment, scope: scope)
2103
- ),
2104
- scope.with_local(
2105
- name,
2106
- narrow_not_class(current, class_name, exact: exact, environment: scope.environment, scope: scope)
2107
- )
2108
- ]
2391
+ slot = ReceiverSlot.new(kind: :local, name: name, current: current)
2392
+ class_slot_scopes(scope, slot, class_name, exact: exact)
2109
2393
  end
2110
2394
 
2111
2395
  # Slice 7 phase 4 — `===`-narrowing. The case-equality predicate `<receiver> === local`
@@ -2139,9 +2423,23 @@ module Rigor
2139
2423
  analyse_case_equality_receiver(node.receiver, scope, arg.name, current)
2140
2424
  when Prism::CallNode
2141
2425
  analyse_case_equality_on_chain(node.receiver, arg, scope)
2426
+ else
2427
+ analyse_case_equality_on_slot(node.receiver, arg, scope)
2142
2428
  end
2143
2429
  end
2144
2430
 
2431
+ # Issue #1429 — `StringIO === $stdout`, `StringIO === STDOUT`: the class-constant receiver form on a global or
2432
+ # constant argument. The Range / Regexp receivers stay local-only.
2433
+ def analyse_case_equality_on_slot(receiver, arg, scope)
2434
+ class_name = lexical_class_name(receiver, scope)
2435
+ return nil if class_name.nil?
2436
+
2437
+ slot = receiver_slot(arg, scope, kinds: GLOBAL_AND_CONSTANT)
2438
+ return nil if slot.nil?
2439
+
2440
+ class_slot_scopes(scope, slot, class_name, exact: false)
2441
+ end
2442
+
2145
2443
  # `Class === <local/ivar>.<method>` — the case-equality counterpart of
2146
2444
  # {.analyse_class_predicate_on_chain}. The `open3` idiom `if Hash === cmd.last` narrows
2147
2445
  # `cmd.last` to the class inside the branch, recorded as a single-hop method-chain
@@ -2360,6 +2658,10 @@ module Rigor
2360
2658
  # that position, or nil when the overload shape does not allow a precise match.
2361
2659
  def lookup_positional_arg(call_node, method_def, target_name)
2362
2660
  arguments = call_node.arguments&.arguments || []
2661
+ # A `(?)` overload (`RBS::Types::UntypedFunction`, #1430) names no parameter and accepts any argument list,
2662
+ # so a call may reach it with the target's position holding anything: no overload proves the target.
2663
+ return nil unless method_def.method_types.all? { |mt| mt.type.respond_to?(:required_positionals) }
2664
+
2363
2665
  method_def.method_types.each do |mt|
2364
2666
  params = mt.type.required_positionals + mt.type.optional_positionals
2365
2667
  index = params.find_index { |param| param.name == target_name }
@@ -2375,7 +2677,9 @@ module Rigor
2375
2677
  # cannot statically classify are treated as "no narrowing": the body falls back to the
2376
2678
  # union of what we did learn (or the entry type when nothing learned), and the falsey
2377
2679
  # edge is the entry type (because we cannot prove the unknown condition didn't match).
2378
- def accumulate_case_when_scopes(scope, local_name, current, conditions)
2680
+ #
2681
+ # `[body type, next-clause type]` of a `when` clause's conditions over the subject type `current`.
2682
+ def case_when_types(scope, current, conditions)
2379
2683
  truthy_members = []
2380
2684
  falsey_type = current
2381
2685
  fully_narrowable = true
@@ -2392,10 +2696,7 @@ module Rigor
2392
2696
  end
2393
2697
 
2394
2698
  truthy = truthy_members.empty? ? current : Type::Combinator.union(*truthy_members)
2395
- [
2396
- scope.with_local(local_name, truthy),
2397
- scope.with_local(local_name, fully_narrowable ? falsey_type : current)
2398
- ]
2699
+ [truthy, fully_narrowable ? falsey_type : current]
2399
2700
  end
2400
2701
 
2401
2702
  # Per-condition rule. Returns `nil` when the condition shape is not recognised (caller
@@ -2647,9 +2948,12 @@ module Rigor
2647
2948
  # `is_a?(Integer)`) narrow DOWN to `Nominal[class_name]`; otherwise (disjoint
2648
2949
  # hierarchies under `is_a?`, mismatch under `instance_of?`) collapse to `Bot`.
2649
2950
  # Conservative when the analyzer environment cannot resolve either class.
2951
+ #
2952
+ # A disjoint `Bot` narrows the edge but is not always a verdict that the code is dead: see
2953
+ # {.disjoint_nominal_guard?} (issue #1429).
2650
2954
  def narrow_nominal_to_class(nominal, class_name, context)
2651
2955
  return nominal if nominal.class_name == class_name
2652
- return Type::Combinator.bot if context.exact
2956
+ return exact_nominal_to_class(nominal, class_name, context) if context.exact
2653
2957
 
2654
2958
  case class_ordering(nominal.class_name, class_name, context)
2655
2959
  when :superclass then Type::Combinator.nominal_of(class_name)
@@ -2664,6 +2968,17 @@ module Rigor
2664
2968
  end
2665
2969
  end
2666
2970
 
2971
+ # `instance_of?(class_name)` compares the exact class, so its truthy edge holds `class_name` itself. Issue
2972
+ # #1429: when `class_name` is a class below the nominal's (`Numeric` under `instance_of?(Integer)`), that is
2973
+ # the edge's type; the edge was `Bot` before, for every class but the nominal's own. A module is never an
2974
+ # object's class, and any other ordering leaves no class the object can have, so those stay `Bot`.
2975
+ def exact_nominal_to_class(nominal, class_name, context)
2976
+ return Type::Combinator.bot if context.environment.rbs_module?(class_name)
2977
+ return Type::Combinator.bot unless class_ordering(nominal.class_name, class_name, context) == :superclass
2978
+
2979
+ Type::Combinator.nominal_of(class_name)
2980
+ end
2981
+
2667
2982
  def narrow_nominal_not_class(nominal, class_name, context)
2668
2983
  return Type::Combinator.bot if nominal.class_name == class_name
2669
2984
  return nominal if context.exact
@@ -2808,25 +3123,15 @@ module Rigor
2808
3123
  context.environment.class_ordering(lhs, rhs)
2809
3124
  end
2810
3125
 
3126
+ # `x.nil?` on a local, instance variable, global or constant (#1429) read.
2811
3127
  def analyse_nil_predicate(receiver, scope)
2812
- case receiver
2813
- when Prism::LocalVariableReadNode
2814
- current = scope.local(receiver.name)
2815
- return nil if current.nil?
2816
-
2817
- [
2818
- scope.with_local(receiver.name, narrow_nil(current)),
2819
- scope.with_local(receiver.name, narrow_non_nil(current))
2820
- ]
2821
- when Prism::InstanceVariableReadNode
2822
- current = scope.ivar(receiver.name)
2823
- return nil if current.nil?
3128
+ slot = receiver_slot(receiver, scope)
3129
+ return nil if slot.nil?
2824
3130
 
2825
- [
2826
- scope.with_ivar(receiver.name, narrow_nil(current)),
2827
- scope.with_ivar(receiver.name, narrow_non_nil(current))
2828
- ]
2829
- end
3131
+ [
3132
+ narrow_slot(scope, slot, narrow_nil(slot.current)),
3133
+ narrow_slot(scope, slot, narrow_non_nil(slot.current))
3134
+ ]
2830
3135
  end
2831
3136
 
2832
3137
  # The three value-pinned classes a union arm can name whose instances are the single
@@ -2945,21 +3250,39 @@ module Rigor
2945
3250
  def analyse_safe_nav_receiver(node, scope)
2946
3251
  return nil unless node.safe_navigation?
2947
3252
 
2948
- receiver = node.receiver
2949
- reader, writer =
2950
- case receiver
2951
- when Prism::LocalVariableReadNode then %i[local with_local]
2952
- when Prism::InstanceVariableReadNode then %i[ivar with_ivar]
2953
- else return nil
2954
- end
3253
+ narrowed = non_nil_slot_scope(node.receiver, scope)
3254
+ narrowed && [narrowed, scope]
3255
+ end
2955
3256
 
2956
- current = scope.public_send(reader, receiver.name)
2957
- return nil if current.nil?
3257
+ # `scope` with the receiver `node` reads (a local, instance variable, global or constant, #1429) narrowed to
3258
+ # its non-nil fragment, or nil when there is no such receiver or it holds no `nil` to remove.
3259
+ def non_nil_slot_scope(node, scope)
3260
+ slot = receiver_slot(node, scope)
3261
+ return nil if slot.nil?
2958
3262
 
2959
- non_nil = narrow_non_nil(current)
2960
- return nil if non_nil.equal?(current)
3263
+ non_nil = narrow_non_nil(slot.current)
3264
+ return nil if non_nil.equal?(slot.current)
2961
3265
 
2962
- [scope.public_send(writer, receiver.name, non_nil), scope]
3266
+ narrow_slot(scope, slot, non_nil)
3267
+ end
3268
+
3269
+ # The scope {.safe_navigation_scope} answers for a receiver, seen through the `&.` links of a chain whose
3270
+ # operands leave the root's binding alone, or nil. Unlike {.non_nil_slot_scope} it compares the fragment
3271
+ # structurally: `narrow_non_nil` rebuilds a union it removed nothing from, and a receiver that cannot be nil
3272
+ # must leave the call's operands on the scope they are typed from.
3273
+ def non_nil_receiver_scope(node, scope)
3274
+ if node.is_a?(Prism::CallNode)
3275
+ return nil unless node.safe_navigation?
3276
+ return nil if OperandEffects.any?(node.arguments) || OperandEffects.any?(node.block)
3277
+
3278
+ return non_nil_receiver_scope(node.receiver, scope)
3279
+ end
3280
+
3281
+ slot = receiver_slot(node, scope)
3282
+ return nil if slot.nil?
3283
+
3284
+ non_nil = narrow_non_nil(slot.current)
3285
+ non_nil == slot.current ? nil : narrow_slot(scope, slot, non_nil)
2963
3286
  end
2964
3287
 
2965
3288
  # Issue #606 slice 1 — the SAFE-NAV CHAIN forms. {.analyse_safe_nav_receiver} above proves
@@ -3031,12 +3354,17 @@ module Rigor
3031
3354
  return nil if suffixes.empty? && !allow_bare
3032
3355
 
3033
3356
  receiver = current.receiver
3034
- return nil unless receiver.is_a?(Prism::LocalVariableReadNode) ||
3035
- receiver.is_a?(Prism::InstanceVariableReadNode)
3357
+ return nil unless SLOT_READ_NODES.any? { |klass| receiver.is_a?(klass) }
3036
3358
 
3037
3359
  [receiver, suffixes]
3038
3360
  end
3039
3361
 
3362
+ SLOT_READ_NODES = [
3363
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode,
3364
+ Prism::ConstantReadNode, Prism::ConstantPathNode
3365
+ ].freeze
3366
+ private_constant :SLOT_READ_NODES
3367
+
3040
3368
  # Which edge — if either — a chain's outcome narrows on. The inner suffixes (everything
3041
3369
  # below the outermost) only ever have to NOT return, so they are held to the raise test
3042
3370
  # alone; the outermost decides the edge per the table in {NIL_FALSEY_SUFFIXES}'s comment.
@@ -3051,20 +3379,7 @@ module Rigor
3051
3379
  end
3052
3380
 
3053
3381
  def safe_nav_chain_narrowed_scope(receiver, scope)
3054
- reader, writer =
3055
- case receiver
3056
- when Prism::LocalVariableReadNode then %i[local with_local]
3057
- when Prism::InstanceVariableReadNode then %i[ivar with_ivar]
3058
- else return nil
3059
- end
3060
-
3061
- current = scope.public_send(reader, receiver.name)
3062
- return nil if current.nil?
3063
-
3064
- non_nil = narrow_non_nil(current)
3065
- return nil if non_nil.equal?(current)
3066
-
3067
- scope.public_send(writer, receiver.name, non_nil)
3382
+ non_nil_slot_scope(receiver, scope)
3068
3383
  end
3069
3384
 
3070
3385
  # Issue #606 slice 1, second form — a safe-nav chain COMPARED to a value that cannot be
@@ -3174,14 +3489,23 @@ module Rigor
3174
3489
  return edges unless node.safe_navigation? && edges
3175
3490
 
3176
3491
  truthy, falsey = edges
3177
- safe = analyse_safe_nav_receiver(node, scope)
3178
- return edges unless safe
3492
+ slot = receiver_slot(node.receiver, scope)
3493
+ return edges if slot.nil?
3179
3494
 
3180
- receiver = node.receiver
3181
- reader = receiver.is_a?(Prism::InstanceVariableReadNode) ? :ivar : :local
3182
- non_nil = safe.first.public_send(reader, receiver.name)
3183
- writer = receiver.is_a?(Prism::InstanceVariableReadNode) ? :with_ivar : :with_local
3184
- [truthy.public_send(writer, receiver.name, non_nil), falsey]
3495
+ non_nil = narrow_non_nil(slot.current)
3496
+ return edges if non_nil.equal?(slot.current)
3497
+
3498
+ [narrow_slot(truthy, slot, non_nil), falsey]
3499
+ end
3500
+
3501
+ # The predicates composed from other predicates, whose edges are built from their
3502
+ # operands' edges.
3503
+ def analyse_composition(node, scope)
3504
+ case node
3505
+ when Prism::AndNode then analyse_and(node, scope)
3506
+ when Prism::OrNode then analyse_or(node, scope)
3507
+ else analyse_conditional(node, scope)
3508
+ end
3185
3509
  end
3186
3510
 
3187
3511
  # `a && b` short-circuits: the truthy edge is the truthy edge of `b` evaluated under
@@ -3191,7 +3515,7 @@ module Rigor
3191
3515
  # output scopes.
3192
3516
  def analyse_and(node, scope)
3193
3517
  truthy_a, falsey_a = analyse(node.left, scope) || [scope, scope]
3194
- truthy_b, falsey_b = analyse(node.right, truthy_a) || [truthy_a, truthy_a]
3518
+ truthy_b, falsey_b = operand_edges(node.right, truthy_a)
3195
3519
  [truthy_b, falsey_a.join(falsey_b)]
3196
3520
  end
3197
3521
 
@@ -3200,9 +3524,105 @@ module Rigor
3200
3524
  # falsey scope evaluated under `a`'s falsey scope.
3201
3525
  def analyse_or(node, scope)
3202
3526
  truthy_a, falsey_a = analyse(node.left, scope) || [scope, scope]
3203
- truthy_b, falsey_b = analyse(node.right, falsey_a) || [falsey_a, falsey_a]
3527
+ truthy_b, falsey_b = operand_edges(node.right, falsey_a)
3204
3528
  [truthy_a.join(truthy_b), falsey_b]
3205
3529
  end
3530
+
3531
+ # The edges of an operand that runs only on one edge of an earlier operand, which is the
3532
+ # step `&&` and `||` both take for their right operand. Issue #1359 — the operand runs after
3533
+ # that edge, so a `$_` the earlier operand narrowed is forgotten when the operand may set it
3534
+ # (`gets && log(items.each { gets })`), before the operand's own edges narrow it again.
3535
+ # Issue #1429 — likewise a guard's narrowing of a global or constant the operand may rebind
3536
+ # (`$sep && reset_sep && $sep.length`).
3537
+ def operand_edges(node, edge_scope)
3538
+ edge_scope = LastLine.forget_if_set(edge_scope, node)
3539
+ if edge_scope.guard_narrowed? && GuardRebinding.may_rebind?(node, edge_scope)
3540
+ edge_scope = edge_scope.forget_guard_narrowings
3541
+ end
3542
+ analyse(node, edge_scope) || [edge_scope, edge_scope]
3543
+ end
3544
+
3545
+ # Issue #1017 — `p ? q : r` (and `if` / `unless` in condition position) is truthy through
3546
+ # `p ∧ q` or `¬p ∧ r` and falsey through `p ∧ ¬q` or `¬p ∧ ¬r`. Each conjunction is the
3547
+ # `&&` step — the arm's edges under the predicate's edge ({#operand_edges}) — and each
3548
+ # disjunction is the `||` merge (`Scope#join`), so `p ? false : q` narrows as `!p && q`
3549
+ # does without a literal-arm rule: an arm whose value is settled contributes only its
3550
+ # live edge ({#settled_edges}). Beyond {CONDITIONAL_GUARD_DEPTH} the guard declines.
3551
+ #
3552
+ # The predicate's own edges are never dropped as settled. `scope` is the scope after the
3553
+ # whole conditional ran, so it has already joined the arms, and a carrier every arm
3554
+ # narrowed has lost its optimistic mark there (`Scope#with_local` drops it): a
3555
+ # `missable.nil?` predicate reads as a proof-looking `false` it is not. An arm is judged
3556
+ # under the predicate's edge instead, where a settled value follows from that edge.
3557
+ def analyse_conditional(node, scope)
3558
+ return nil if conditional_guard_height(node) > CONDITIONAL_GUARD_DEPTH
3559
+
3560
+ predicate, then_arm, else_arm = conditional_parts(node)
3561
+ truthy_p, falsey_p = operand_edges(predicate, scope)
3562
+ truthy_q, falsey_q = settled_edges(then_arm, truthy_p)
3563
+ truthy_r, falsey_r = settled_edges(else_arm, falsey_p)
3564
+ truthy = join_live_edges(truthy_q, truthy_r)
3565
+ falsey = join_live_edges(falsey_q, falsey_r)
3566
+ return nil if truthy.nil? && falsey.nil?
3567
+
3568
+ [truthy || scope, falsey || scope]
3569
+ end
3570
+
3571
+ # `[predicate, arm run when it is truthy, arm run when it is falsey]`. An arm is a body or
3572
+ # `nil` for an absent / empty one; an `elsif` is the nested conditional itself.
3573
+ def conditional_parts(node)
3574
+ if node.is_a?(Prism::UnlessNode)
3575
+ [node.predicate, else_body(node.else_clause), node.statements]
3576
+ else
3577
+ subsequent = node.subsequent
3578
+ [node.predicate, node.statements, subsequent.is_a?(Prism::ElseNode) ? else_body(subsequent) : subsequent]
3579
+ end
3580
+ end
3581
+
3582
+ def else_body(else_node)
3583
+ else_node&.statements
3584
+ end
3585
+
3586
+ # An operand's `[truthy, falsey]` edges with a dead edge as `nil`. An absent arm evaluates
3587
+ # to `nil`, so only its falsey edge is live. Otherwise the edge the operand's type proves
3588
+ # unreachable is dropped — unless that proof rests on an optimistically nil-free lookup
3589
+ # (ADR-101, issue #313), which `branch_certainty` refuses to elide an arm on as well.
3590
+ def settled_edges(node, scope)
3591
+ return [nil, scope] if node.nil?
3592
+
3593
+ truthy, falsey = operand_edges(node, scope)
3594
+ case predicate_certainty(scope.type_of(node))
3595
+ when :truthy then optimistic_operand?(node, scope) ? [truthy, falsey] : [truthy, nil]
3596
+ when :falsey then optimistic_operand?(node, scope) ? [truthy, falsey] : [nil, falsey]
3597
+ else [truthy, falsey]
3598
+ end
3599
+ end
3600
+
3601
+ def optimistic_operand?(node, scope)
3602
+ node = node.body.last while node.is_a?(Prism::StatementsNode) && !node.body.empty?
3603
+ !OptimisticOrigin.resolve(node, scope).nil?
3604
+ end
3605
+
3606
+ def join_live_edges(left, right)
3607
+ return right if left.nil?
3608
+ return left if right.nil?
3609
+
3610
+ left.join(right)
3611
+ end
3612
+
3613
+ # The number of conditionals stacked in `node` along the shapes {.analyse} walks.
3614
+ def conditional_guard_height(node)
3615
+ case node
3616
+ when Prism::IfNode, Prism::UnlessNode
3617
+ 1 + conditional_parts(node).map { |part| conditional_guard_height(part) }.max
3618
+ when Prism::ParenthesesNode then conditional_guard_height(node.body)
3619
+ when Prism::StatementsNode then conditional_guard_height(node.body.last)
3620
+ when Prism::AndNode, Prism::OrNode
3621
+ [conditional_guard_height(node.left), conditional_guard_height(node.right)].max
3622
+ when Prism::CallNode then node.name == :! ? conditional_guard_height(node.receiver) : 0
3623
+ else 0
3624
+ end
3625
+ end
3206
3626
  end
3207
3627
  end
3208
3628
  end