rigortype 0.3.9 → 0.4.1

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 (407) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -4
  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/08-understanding-errors.md +3 -2
  14. data/docs/handbook/10-sorbet.md +9 -10
  15. data/docs/handbook/11-sig-gen.md +454 -13
  16. data/docs/install.md +87 -15
  17. data/docs/manual/02-cli-reference.md +65 -4
  18. data/docs/manual/03-configuration.md +12 -0
  19. data/docs/manual/04-diagnostics.md +4 -0
  20. data/docs/manual/07-plugins.md +1 -1
  21. data/docs/manual/08-skills.md +4 -3
  22. data/docs/manual/10-mcp-server.md +3 -2
  23. data/docs/manual/12-caching.md +43 -4
  24. data/docs/manual/14-rails-quickstart.md +19 -11
  25. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  26. data/docs/manual/19-effect-labels.md +10 -0
  27. data/docs/manual/plugins/README.md +7 -0
  28. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  29. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  30. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  31. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  32. data/docs/manual/plugins/rigor-activejob.md +7 -0
  33. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  34. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  35. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  36. data/docs/manual/plugins/rigor-grape.md +106 -0
  37. data/docs/manual/plugins/rigor-graphql.md +23 -2
  38. data/docs/manual/plugins/rigor-pundit.md +8 -1
  39. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  40. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  41. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  42. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  43. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  44. data/exe/rigor +19 -4
  45. data/lib/rigor/analysis/baseline.rb +1 -1
  46. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  47. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  48. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  49. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  50. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/source_arity.rb +427 -0
  52. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  53. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  54. data/lib/rigor/analysis/check_rules.rb +638 -136
  55. data/lib/rigor/analysis/dependency_recorder.rb +222 -17
  56. data/lib/rigor/analysis/fact_store.rb +9 -0
  57. data/lib/rigor/analysis/incremental.rb +26 -0
  58. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  59. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  60. data/lib/rigor/analysis/incremental_session.rb +309 -53
  61. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  62. data/lib/rigor/analysis/project_scan.rb +11 -1
  63. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  64. data/lib/rigor/analysis/reachability/scan_cache.rb +3 -1
  65. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  66. data/lib/rigor/analysis/run_cache_key.rb +46 -18
  67. data/lib/rigor/analysis/run_stats.rb +2 -2
  68. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +168 -12
  69. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  70. data/lib/rigor/analysis/runner/pool_coordinator.rb +109 -17
  71. data/lib/rigor/analysis/runner/project_pre_passes.rb +44 -17
  72. data/lib/rigor/analysis/runner.rb +603 -54
  73. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  74. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  75. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  76. data/lib/rigor/analysis/template_unit_paths.rb +93 -0
  77. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  78. data/lib/rigor/analysis/template_units.rb +409 -0
  79. data/lib/rigor/analysis/worker_session.rb +57 -13
  80. data/lib/rigor/bleeding_edge.rb +0 -2
  81. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  82. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  83. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  84. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  85. data/lib/rigor/cache/descriptor.rb +45 -1
  86. data/lib/rigor/cache/engine_source.rb +29 -1
  87. data/lib/rigor/cache/file_digest.rb +28 -0
  88. data/lib/rigor/cache/incremental_snapshot.rb +102 -8
  89. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  90. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  91. data/lib/rigor/cache/store.rb +63 -4
  92. data/lib/rigor/cache/value_digest.rb +325 -0
  93. data/lib/rigor/ci_detector.rb +1 -0
  94. data/lib/rigor/cli/check_command.rb +83 -13
  95. data/lib/rigor/cli/doc_links.rb +1 -1
  96. data/lib/rigor/cli/docs_command.rb +4 -4
  97. data/lib/rigor/cli/doctor_command.rb +1 -1
  98. data/lib/rigor/cli/effects_report.rb +2 -2
  99. data/lib/rigor/cli/plugin_command.rb +3 -3
  100. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  101. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  102. data/lib/rigor/cli/skill_command.rb +1 -1
  103. data/lib/rigor/cli/skill_describe.rb +0 -1
  104. data/lib/rigor/cli/type_of_command.rb +19 -5
  105. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  106. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  107. data/lib/rigor/cli/unused_command.rb +3 -3
  108. data/lib/rigor/cli.rb +21 -3
  109. data/lib/rigor/config_audit.rb +3 -2
  110. data/lib/rigor/config_path.rb +29 -0
  111. data/lib/rigor/configuration/severity_profile.rb +24 -3
  112. data/lib/rigor/configuration.rb +71 -7
  113. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  114. data/lib/rigor/effects/attribution.rb +11 -2
  115. data/lib/rigor/effects/callee_rule.rb +368 -0
  116. data/lib/rigor/effects/catalog.rb +7 -4
  117. data/lib/rigor/effects/collector.rb +11 -5
  118. data/lib/rigor/effects/config_envelopes.rb +9 -2
  119. data/lib/rigor/effects/definition_context.rb +179 -0
  120. data/lib/rigor/effects/effect_table.rb +11 -3
  121. data/lib/rigor/effects/envelope_check.rb +1 -1
  122. data/lib/rigor/effects/envelope_index.rb +15 -0
  123. data/lib/rigor/effects/file_collection.rb +59 -5
  124. data/lib/rigor/effects/framework_units.rb +1 -1
  125. data/lib/rigor/effects/identity.rb +16 -0
  126. data/lib/rigor/effects/local_ownership.rb +38 -12
  127. data/lib/rigor/effects/method_key.rb +21 -0
  128. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  129. data/lib/rigor/effects/plugin_facts.rb +49 -37
  130. data/lib/rigor/effects/propagator.rb +295 -16
  131. data/lib/rigor/effects/registry.rb +1 -1
  132. data/lib/rigor/effects/scanner.rb +121 -72
  133. data/lib/rigor/effects/signature_sources.rb +1 -1
  134. data/lib/rigor/effects/snapshot.rb +2 -1
  135. data/lib/rigor/effects/summary.rb +27 -4
  136. data/lib/rigor/effects/unit_scan.rb +385 -30
  137. data/lib/rigor/effects/visibility.rb +101 -0
  138. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  139. data/lib/rigor/environment/lockfile_resolver.rb +27 -1
  140. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  141. data/lib/rigor/environment/member_consistency.rb +298 -0
  142. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  143. data/lib/rigor/environment/rbs_loader.rb +400 -134
  144. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  145. data/lib/rigor/environment.rb +104 -96
  146. data/lib/rigor/hashing/xxh3.rb +264 -0
  147. data/lib/rigor/inference/acceptance.rb +139 -9
  148. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  149. data/lib/rigor/inference/block_call_timing.rb +338 -0
  150. data/lib/rigor/inference/block_parameter_binder.rb +88 -26
  151. data/lib/rigor/inference/block_repetition.rb +71 -0
  152. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  153. data/lib/rigor/inference/budget_trace.rb +2 -1
  154. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  155. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  156. data/lib/rigor/inference/captured_locals.rb +393 -15
  157. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  158. data/lib/rigor/inference/content_join.rb +200 -27
  159. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  160. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  161. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  162. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  163. data/lib/rigor/inference/declaration_walk.rb +7 -0
  164. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  165. data/lib/rigor/inference/def_return_typer.rb +11 -7
  166. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  167. data/lib/rigor/inference/definer_resolution.rb +266 -0
  168. data/lib/rigor/inference/element_read_widening.rb +22 -10
  169. data/lib/rigor/inference/error_info.rb +196 -0
  170. data/lib/rigor/inference/expression_typer.rb +1665 -538
  171. data/lib/rigor/inference/external_ancestor_resolution.rb +264 -0
  172. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  173. data/lib/rigor/inference/global_write_census.rb +239 -0
  174. data/lib/rigor/inference/guard_rebinding.rb +488 -0
  175. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  176. data/lib/rigor/inference/index_write_widening.rb +16 -3
  177. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  178. data/lib/rigor/inference/jump_targets.rb +82 -0
  179. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  180. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  181. data/lib/rigor/inference/last_line.rb +340 -0
  182. data/lib/rigor/inference/last_status.rb +144 -0
  183. data/lib/rigor/inference/macro_block_self_type.rb +179 -17
  184. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  185. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  186. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  187. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  188. data/lib/rigor/inference/match_rebinding.rb +392 -0
  189. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  190. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  191. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  192. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  193. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  194. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  195. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  196. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  197. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  198. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +901 -38
  199. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  200. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  201. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  202. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  203. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  204. data/lib/rigor/inference/module_function_state.rb +213 -0
  205. data/lib/rigor/inference/multi_target_binder.rb +345 -52
  206. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  207. data/lib/rigor/inference/mutation_widening.rb +70 -48
  208. data/lib/rigor/inference/narrowing.rb +674 -124
  209. data/lib/rigor/inference/operand_effects.rb +167 -0
  210. data/lib/rigor/inference/operand_walk.rb +88 -0
  211. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  212. data/lib/rigor/inference/parameter_inference_collector.rb +13 -11
  213. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  214. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  215. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  216. data/lib/rigor/inference/receiver_alias.rb +90 -1
  217. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  218. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  219. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  220. data/lib/rigor/inference/return_barrier.rb +54 -0
  221. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  222. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  223. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  224. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  225. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  226. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  227. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  228. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  229. data/lib/rigor/inference/scope_indexer.rb +5913 -701
  230. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  231. data/lib/rigor/inference/statement_evaluator.rb +3249 -497
  232. data/lib/rigor/inference/stored_block_call.rb +54 -0
  233. data/lib/rigor/inference/string_mutation.rb +44 -7
  234. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  235. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  236. data/lib/rigor/language_server/debouncer.rb +0 -1
  237. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  238. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  239. data/lib/rigor/language_server/project_context.rb +5 -3
  240. data/lib/rigor/mcp/server.rb +2 -1
  241. data/lib/rigor/plugin/base.rb +196 -13
  242. data/lib/rigor/plugin/box_probe.rb +91 -0
  243. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  244. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  245. data/lib/rigor/plugin/io_boundary.rb +82 -4
  246. data/lib/rigor/plugin/isolation.rb +1 -1
  247. data/lib/rigor/plugin/loader.rb +2 -1
  248. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  249. data/lib/rigor/plugin/manifest.rb +71 -10
  250. data/lib/rigor/plugin/registry.rb +35 -1
  251. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  252. data/lib/rigor/plugin/template_unit.rb +196 -0
  253. data/lib/rigor/plugin/trust_policy.rb +3 -3
  254. data/lib/rigor/plugin.rb +1 -0
  255. data/lib/rigor/protection/discovery_seed.rb +20 -9
  256. data/lib/rigor/protection/kill_signature.rb +0 -1
  257. data/lib/rigor/protection/mutation_cache.rb +4 -5
  258. data/lib/rigor/rbs_extended.rb +27 -0
  259. data/lib/rigor/reflection/constant_ancestors.rb +146 -0
  260. data/lib/rigor/reflection/constant_path.rb +41 -13
  261. data/lib/rigor/reflection.rb +73 -107
  262. data/lib/rigor/scope/discovery_index.rb +351 -2
  263. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  264. data/lib/rigor/scope/resolution_chain.rb +884 -0
  265. data/lib/rigor/scope.rb +981 -198
  266. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  267. data/lib/rigor/sig_gen/classification.rb +22 -5
  268. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  269. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  270. data/lib/rigor/sig_gen/generator.rb +580 -47
  271. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  272. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  273. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  274. data/lib/rigor/sig_gen/renderer.rb +124 -9
  275. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  276. data/lib/rigor/sig_gen/write_result.rb +19 -3
  277. data/lib/rigor/sig_gen/writer.rb +166 -35
  278. data/lib/rigor/sig_gen.rb +3 -0
  279. data/lib/rigor/signature_path_audit.rb +1 -1
  280. data/lib/rigor/source/node_walker.rb +0 -3
  281. data/lib/rigor/source/parameter_envelope.rb +72 -0
  282. data/lib/rigor/source.rb +1 -0
  283. data/lib/rigor/type/combinator.rb +112 -12
  284. data/lib/rigor/type/difference.rb +1 -0
  285. data/lib/rigor/type/hash_shape.rb +1 -1
  286. data/lib/rigor/type/refined.rb +1 -0
  287. data/lib/rigor/version.rb +1 -1
  288. data/lib/rigor.rb +1 -0
  289. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  290. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  291. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  292. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  293. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  294. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  295. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  296. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  297. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  298. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  299. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  300. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  301. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  302. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  303. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  304. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  305. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  306. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  307. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  308. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  309. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  310. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  311. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  312. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  313. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  314. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  315. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  316. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  317. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  318. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  319. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  320. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  321. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  322. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  323. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  324. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  325. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  326. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  327. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  328. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  329. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  330. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  331. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  332. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  333. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  334. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  335. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  336. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  337. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  338. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  339. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  340. data/sig/rigor/analysis/baseline.rbs +69 -7
  341. data/sig/rigor/analysis/fact_store.rbs +1 -1
  342. data/sig/rigor/analysis/project_scan.rbs +74 -0
  343. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  344. data/sig/rigor/effects/effect_table.rbs +60 -0
  345. data/sig/rigor/effects/envelope.rbs +97 -0
  346. data/sig/rigor/effects/envelope_index.rbs +33 -0
  347. data/sig/rigor/effects/file_collection.rbs +81 -0
  348. data/sig/rigor/effects/label.rbs +26 -0
  349. data/sig/rigor/effects/label_set.rbs +46 -0
  350. data/sig/rigor/effects/method_key.rbs +24 -0
  351. data/sig/rigor/effects/origin.rbs +50 -0
  352. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  353. data/sig/rigor/effects/registry.rbs +68 -0
  354. data/sig/rigor/effects/summary.rbs +50 -0
  355. data/sig/rigor/effects/taint_cause.rbs +12 -0
  356. data/sig/rigor/environment.rbs +14 -10
  357. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  358. data/sig/rigor/inference.rbs +6 -4
  359. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  360. data/sig/rigor/plugin/base.rbs +30 -7
  361. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  362. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  363. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  364. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  365. data/sig/rigor/plugin/io_boundary.rbs +2 -1
  366. data/sig/rigor/plugin/loader.rbs +3 -3
  367. data/sig/rigor/plugin/manifest.rbs +50 -11
  368. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  369. data/sig/rigor/plugin/registry.rbs +62 -1
  370. data/sig/rigor/plugin.rbs +1 -1
  371. data/sig/rigor/rbs_extended.rbs +1 -1
  372. data/sig/rigor/reflection.rbs +7 -6
  373. data/sig/rigor/scope.rbs +177 -20
  374. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  375. data/sig/rigor/source.rbs +4 -4
  376. data/sig/rigor/testing.rbs +10 -4
  377. data/sig/rigor/type.rbs +6 -0
  378. data/sig/rigor.rbs +38 -20
  379. data/skills/rigor-ask/SKILL.md +8 -5
  380. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  381. data/skills/rigor-ci-setup/SKILL.md +17 -21
  382. data/skills/rigor-doctor/SKILL.md +24 -22
  383. data/skills/rigor-doctor/references/01-checks.md +97 -33
  384. data/skills/rigor-editor-setup/SKILL.md +6 -4
  385. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  386. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  387. data/skills/rigor-next-steps/SKILL.md +4 -2
  388. data/skills/rigor-plugin-author/SKILL.md +19 -23
  389. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  390. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  391. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  392. data/skills/rigor-plugin-review/SKILL.md +6 -4
  393. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  394. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  395. data/skills/rigor-project-init/SKILL.md +46 -18
  396. data/skills/rigor-project-init/references/01-detect.md +13 -9
  397. data/skills/rigor-project-init/references/02-configure.md +40 -10
  398. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  399. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  400. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  401. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  402. data/skills/rigor-type-oracle/SKILL.md +4 -6
  403. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  404. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  405. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  406. data/skills/rigor-upgrade/SKILL.md +13 -8
  407. metadata +132 -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,30 @@ 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
+
443
+ # Issue #1446 — the classes a class guard narrowed the receiver `node` to where the guard left it `bot`
444
+ # ({Scope#bot_guard_classes_for}), or nil: a node that is not a local, instance-variable, global or constant
445
+ # read, or one no such guard narrowed.
446
+ def bot_guard_classes_of(node, scope)
447
+ case node
448
+ when Prism::LocalVariableReadNode then scope.bot_guard_classes_for(:local, node.name)
449
+ when Prism::InstanceVariableReadNode then scope.bot_guard_classes_for(:ivar, node.name)
450
+ when Prism::GlobalVariableReadNode then scope.bot_guard_classes_for(:global, node.name)
451
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
452
+ key = constant_key(node)
453
+ key && scope.bot_guard_classes_for(:constant, key)
454
+ end
455
+ end
456
+
393
457
  # Public predicate analyser. Returns `[truthy_scope, falsey_scope]`, always; when no
394
458
  # narrowing rule matches the predicate node both entries are the receiver scope unchanged.
395
459
  def predicate_scopes(node, scope)
@@ -399,6 +463,37 @@ module Rigor
399
463
  result || [scope, scope]
400
464
  end
401
465
 
466
+ # Issue #1468 — the scope the arguments and block of a safe-navigation call `recv&.m(…)` run under. Ruby
467
+ # evaluates them only once `recv` is known non-nil, so `klass&.new(klass.flag?)` reads `klass` without its
468
+ # `nil`, as `if klass then klass.new(klass.flag?) end` does. Only `nil` is removed: `false&.m(x)` still calls
469
+ # `m`. The call's own value keeps its skipped-call `nil` (`ExpressionTyper#safe_navigation_call_type`).
470
+ #
471
+ # The receiver is one a guard narrows ({.receiver_slot}: a local, instance variable, global or constant), or a
472
+ # safe-navigation call on such a receiver whose own arguments and block hold no write or jump
473
+ # ({OperandEffects}): `a&.b(a.c)&.d(a.e)` runs `d` only when `a&.b(…)` answered non-nil, which it cannot on a
474
+ # nil `a`, and nothing between the two reads rebinds `a`. `scope` itself when nothing narrows, and for a call
475
+ # that is not `&.`.
476
+ def safe_navigation_scope(call_node, scope)
477
+ return scope unless call_node.safe_navigation?
478
+
479
+ non_nil_receiver_scope(call_node.receiver, scope) || scope
480
+ end
481
+
482
+ # The scope the block of a safe-navigation call is entered from: {.safe_navigation_scope}, unless the narrowing
483
+ # may not hold for the whole body. An argument that writes may rebind the receiver before the block runs
484
+ # (`f&.two(f = nil) { f.run }`), and a block that writes the receiver hands the next iteration what it wrote
485
+ # (`a&.each { a.size; a = nil }`); either keeps `scope`.
486
+ def safe_navigation_block_scope(call_node, scope)
487
+ return scope unless call_node.safe_navigation?
488
+ return scope if OperandEffects.any?(call_node.arguments)
489
+
490
+ root = call_node.receiver
491
+ root = root.receiver while root.is_a?(Prism::CallNode) && root.safe_navigation?
492
+ return scope if root.respond_to?(:name) && OperandEffects.written_variables(call_node.block).include?(root.name)
493
+
494
+ safe_navigation_scope(call_node, scope)
495
+ end
496
+
402
497
  # Slice 7 phase 5 — `case`/`when` narrowing.
403
498
  #
404
499
  # Given the subject of a `case` (the expression after the `case` keyword) and an array of
@@ -416,10 +511,6 @@ module Rigor
416
511
  # scope. The catalogue mirrors {.case_equality_target_class}: static class/module
417
512
  # constants narrow as `is_a?`; integer/float-endpoint ranges narrow to `Numeric`;
418
513
  # 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
514
  def case_when_scopes(subject, conditions, scope)
424
515
  # C1 — `case x when /re/` runs `/re/ === x`, which sets the regex match-data globals
425
516
  # exactly as a successful `=~` does. Narrow `$~`/`$&`/`$1..$N` on the clause body (the
@@ -427,17 +518,22 @@ module Rigor
427
518
  # a different regex. Applied even when the subject is not a narrowable local read.
428
519
  body_scope = apply_when_regex_globals(conditions, scope)
429
520
 
430
- return [body_scope, scope] unless subject.is_a?(Prism::LocalVariableReadNode)
431
-
432
- local_name = subject.name
433
- current = scope.local(local_name)
434
- return [body_scope, scope] if current.nil?
521
+ # Issue #1429 — a global or constant subject (`case $stdout when StringIO`) narrows as a local does, and so
522
+ # does an instance variable (#1446).
523
+ slot = receiver_slot(subject, scope, kinds: CASE_SUBJECT_KINDS)
524
+ return [body_scope, scope] if slot.nil?
435
525
 
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]
526
+ truthy_type, falsey_type = case_when_types(scope, slot.current, conditions)
527
+ classes = conditions.map do |condition|
528
+ lexical_class_name(condition, scope) || case_equality_target_class(condition)
529
+ end
530
+ body_scope = record_bot_guard(narrow_guarded_slot(body_scope, slot, truthy_type), slot, truthy_type, classes)
531
+ [body_scope, narrow_guarded_slot(scope, slot, falsey_type)]
439
532
  end
440
533
 
534
+ CASE_SUBJECT_KINDS = %i[local ivar global constant].freeze
535
+ private_constant :CASE_SUBJECT_KINDS
536
+
441
537
  # When the clause has exactly one `RegularExpressionNode` literal condition, narrow the
442
538
  # match-data globals on the body edge (same rule as `analyse_regex_match_predicate`'s
443
539
  # truthy edge). With multiple regex conditions (`when /a/, /b/`) the body is reachable
@@ -460,8 +556,29 @@ module Rigor
460
556
 
461
557
  # Internal analyser. Returns `[truthy_scope, falsey_scope]` when the predicate shape is
462
558
  # recognised, or `nil` to signal "no narrowing" so the public surface can fall back to the
463
- # entry scope.
559
+ # entry scope. Issue #1359 — a condition whose value is a `gets`-family reader's, the bare call
560
+ # or a variable written with it (`while gets`, `if (line = gets)`), also binds `$_` on each edge
561
+ # to the line the reader returned ({LastLine.predicate_scopes}).
464
562
  def analyse(node, scope)
563
+ edges = analyse_shape(node, scope)
564
+ reader = last_line_condition(node)
565
+ reader ? LastLine.predicate_scopes(reader, scope, edges) : edges
566
+ end
567
+
568
+ # The reader call a condition's value is, or nil.
569
+ def last_line_condition(node)
570
+ call =
571
+ case node
572
+ when Prism::CallNode then node
573
+ when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode,
574
+ Prism::GlobalVariableWriteNode
575
+ node.value
576
+ end
577
+ call if call.is_a?(Prism::CallNode) && LastLine::READERS.include?(call.name)
578
+ end
579
+ private_class_method :last_line_condition
580
+
581
+ def analyse_shape(node, scope)
465
582
  case node
466
583
  when Prism::ParenthesesNode
467
584
  analyse_parentheses(node, scope)
@@ -473,20 +590,19 @@ module Rigor
473
590
  analyse_local_write(node, scope)
474
591
  when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
475
592
  analyse_ivar(node, scope)
476
- when Prism::ClassVariableWriteNode
477
- analyse_cvar_write(node, scope)
478
- when Prism::GlobalVariableWriteNode
479
- analyse_global_write(node, scope)
593
+ when Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode
594
+ analyse_cvar_or_global_write(node, scope)
595
+ when Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode
596
+ analyse_slot_read(node, scope)
480
597
  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)
598
+ analyse_call(write_receiver_as_read(node) || node, scope)
599
+ when Prism::AndNode, Prism::OrNode, Prism::IfNode, Prism::UnlessNode
600
+ analyse_composition(node, scope)
486
601
  when Prism::MatchWriteNode
487
602
  analyse_match_write(node, scope)
488
603
  end
489
604
  end
605
+ private_class_method :analyse_shape
490
606
 
491
607
  # rubocop:disable-next Metrics/ClassLength
492
608
  class << self
@@ -843,6 +959,12 @@ module Rigor
843
959
  ]
844
960
  end
845
961
 
962
+ def analyse_cvar_or_global_write(node, scope)
963
+ return analyse_cvar_write(node, scope) if node.is_a?(Prism::ClassVariableWriteNode)
964
+
965
+ analyse_global_write(node, scope)
966
+ end
967
+
846
968
  def analyse_cvar_write(node, scope)
847
969
  current = scope.cvar(node.name)
848
970
  return nil if current.nil?
@@ -863,6 +985,96 @@ module Rigor
863
985
  ]
864
986
  end
865
987
 
988
+ # Issue #1429 — `if $g`, `return unless $g`, `if CONFIG`: the truthiness guard on a global or constant
989
+ # read, the counterpart of {#analyse_local_read} ({#narrow_slot}).
990
+ def analyse_slot_read(node, scope)
991
+ slot = receiver_slot(node, scope, kinds: GLOBAL_AND_CONSTANT)
992
+ return nil if slot.nil?
993
+
994
+ [
995
+ narrow_slot(scope, slot, narrow_truthy(slot.current)),
996
+ narrow_slot(scope, slot, narrow_falsey(slot.current))
997
+ ]
998
+ end
999
+
1000
+ GLOBAL_AND_CONSTANT = %i[global constant].freeze
1001
+ CLASS_GUARD_SLOT_KINDS = %i[ivar global constant].freeze
1002
+ ALL_SLOT_KINDS = %i[local ivar global constant].freeze
1003
+ private_constant :GLOBAL_AND_CONSTANT, :CLASS_GUARD_SLOT_KINDS, :ALL_SLOT_KINDS
1004
+
1005
+ # The {ReceiverSlot} `node` reads, among `kinds`, or nil. A local or instance variable needs a binding. A
1006
+ # global the scope does not bind is read as it reads unbound, and a constant reference as it resolves
1007
+ # (through any narrowing already on it), so a guard narrows it from that type.
1008
+ def receiver_slot(node, scope, kinds: ALL_SLOT_KINDS)
1009
+ case node
1010
+ when Prism::LocalVariableReadNode then variable_slot(:local, node.name, scope.local(node.name), kinds)
1011
+ when Prism::InstanceVariableReadNode then variable_slot(:ivar, node.name, scope.ivar(node.name), kinds)
1012
+ when Prism::GlobalVariableReadNode then global_slot(node, scope, kinds)
1013
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then constant_slot(node, scope, kinds)
1014
+ end
1015
+ end
1016
+
1017
+ def variable_slot(kind, name, current, kinds)
1018
+ return nil if current.nil? || !kinds.include?(kind)
1019
+
1020
+ ReceiverSlot.new(kind: kind, name: name, current: current)
1021
+ end
1022
+
1023
+ def global_slot(node, scope, kinds)
1024
+ return nil unless kinds.include?(:global)
1025
+
1026
+ ReceiverSlot.new(kind: :global, name: node.name, current: scope.global(node.name) || scope.type_of(node))
1027
+ end
1028
+
1029
+ def constant_slot(node, scope, kinds)
1030
+ return nil unless kinds.include?(:constant)
1031
+
1032
+ key = constant_key(node)
1033
+ return nil if key.nil?
1034
+
1035
+ ReceiverSlot.new(kind: :constant, name: key, current: scope.type_of(node))
1036
+ end
1037
+
1038
+ # `scope` with `slot`'s receiver read as `type` on one edge. A local or instance variable rebinds as its
1039
+ # analysers always did. A global or constant is left alone when the edge learns nothing (`type` is what
1040
+ # it read), and otherwise narrows with its pre-guard type recorded, so code that may rebind it restores
1041
+ # the binding ({Scope#forget_guard_narrowings}); the frame-local specials skip the record.
1042
+ def narrow_slot(scope, slot, type)
1043
+ case slot.kind
1044
+ when :local then scope.with_local(slot.name, type)
1045
+ when :ivar then scope.with_ivar(slot.name, type)
1046
+ when :global then narrow_global_slot(scope, slot, type)
1047
+ else
1048
+ type == slot.current ? scope : scope.with_constant_narrowing(slot.name, type, slot.current)
1049
+ end
1050
+ end
1051
+
1052
+ # Issue #1446 — {#narrow_slot} for a class guard: an instance variable narrows with its pre-guard type
1053
+ # recorded, as a global does, since code that may reach `self` may rebind it. The other guards on an instance
1054
+ # variable keep {#narrow_slot}'s unrecorded binding.
1055
+ def narrow_guarded_slot(scope, slot, type)
1056
+ return narrow_slot(scope, slot, type) unless slot.kind == :ivar
1057
+ return scope if type == slot.current
1058
+
1059
+ scope.with_guarded_ivar(slot.name, type, slot.current)
1060
+ end
1061
+
1062
+ # Issue #1446 — `scope`, the guard's truthy edge, with `class_names` recorded for `slot` when the guard left
1063
+ # it `bot` ({Scope#with_bot_guard_classes}): the value that passes it is one of those classes. Nothing is
1064
+ # recorded when a condition names no class.
1065
+ def record_bot_guard(scope, slot, type, class_names)
1066
+ return scope unless type.is_a?(Type::Bot) && !class_names.empty? && class_names.none?(&:nil?)
1067
+
1068
+ scope.with_bot_guard_classes(slot.kind, slot.name, class_names)
1069
+ end
1070
+
1071
+ def narrow_global_slot(scope, slot, type)
1072
+ return scope if type == slot.current
1073
+
1074
+ record = !UNRECORDED_GUARD_GLOBALS.include?(slot.name)
1075
+ scope.with_guarded_global(slot.name, type, slot.current, record: record)
1076
+ end
1077
+
866
1078
  # `if /(?<x>...)/ =~ str` — Prism wraps the `=~` call in a `MatchWriteNode` listing the
867
1079
  # named-capture targets. The parent `eval_match_write` has already bound each target to
868
1080
  # `String | nil`; in the truthy branch (the regex matched) every named capture is
@@ -918,6 +1130,25 @@ module Rigor
918
1130
  # literals (Slice 6 phase 2 sub-phase 2).
919
1131
  # Anything else returns nil so the surrounding analyser falls through to the
920
1132
  # no-narrowing fallback.
1133
+ # Issue #1223 — `(x = e).nil?`, `(x ||= e).empty?`, `(@x = e).is_a?(C)`. A write's value is the binding
1134
+ # it leaves, and a predicate is narrowed in the scope after its receiver ran, so the call narrows the
1135
+ # variable exactly as the same call on a read of it would. Every analyser keys its receiver on a read
1136
+ # node, so the call is analysed with its receiver replaced by one ({ReceiverAlias.read_of}). Before #1223
1137
+ # the statement evaluator did not thread a receiver's write at all, so the local was unbound and there
1138
+ # was nothing to narrow; nil when the receiver is not such a write, bare or parenthesised on its own.
1139
+ def write_receiver_as_read(node)
1140
+ write = written_receiver(node.receiver)
1141
+ write && node.copy(receiver: ReceiverAlias.read_of(write))
1142
+ end
1143
+
1144
+ def written_receiver(receiver)
1145
+ while receiver.is_a?(Prism::ParenthesesNode) && receiver.body.is_a?(Prism::StatementsNode) &&
1146
+ receiver.body.body.size == 1
1147
+ receiver = receiver.body.body.first
1148
+ end
1149
+ receiver if ReceiverAlias.variable_write?(receiver)
1150
+ end
1151
+
921
1152
  def analyse_call(node, scope)
922
1153
  return nil if node.block
923
1154
 
@@ -1042,7 +1273,7 @@ module Rigor
1042
1273
  end
1043
1274
 
1044
1275
  def simple_dispatch_name?(name)
1045
- %i[nil? ! is_a? kind_of? instance_of? == != === =~ match? key? has_key? empty? any?
1276
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~ !~ match? key? has_key? empty? any?
1046
1277
  none? respond_to? nan? finite?].include?(name)
1047
1278
  end
1048
1279
 
@@ -1053,7 +1284,7 @@ module Rigor
1053
1284
  when :instance_of? then analyse_class_predicate(node, scope, exact: true)
1054
1285
  when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
1055
1286
  when :=== then analyse_case_equality_predicate(node, scope)
1056
- when :=~ then analyse_regex_match_predicate(node, scope)
1287
+ when :=~, :!~ then dispatch_regex_match_predicate(node, scope, name)
1057
1288
  when :match? then analyse_whole_regex_match_predicate(node, scope)
1058
1289
  when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
1059
1290
  when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
@@ -1062,17 +1293,29 @@ module Rigor
1062
1293
  end
1063
1294
  end
1064
1295
 
1296
+ def dispatch_regex_match_predicate(node, scope, name)
1297
+ name == :=~ ? analyse_regex_match_predicate(node, scope) : analyse_regex_not_match_predicate(node, scope)
1298
+ end
1299
+
1065
1300
  # T3 (template-corpora survey) — `recv.respond_to?(sym)` truthy edge narrows `recv`
1066
1301
  # non-nil. `nil.respond_to?(m)` is `false` for every method `m` that `NilClass` does not
1067
1302
  # define, so a truthy `respond_to?` proves the receiver was not `nil` UNLESS the queried
1068
1303
  # symbol is one of `NilClass`'s own methods (`:to_s`, `:inspect`, `:nil?`, …) — `nil`
1069
1304
  # DOES respond to those, so the truthy edge admits a nil receiver and we narrow nothing.
1070
1305
  #
1306
+ # Issue #1429 — the truthy edge also admits `m` itself. `respond_to?` is a guard written in the code
1307
+ # (ADR-117 Decision point 3), so a member of the receiver's type whose class Rigor knows to lack `m`
1308
+ # is not what the edge holds: members that may respond keep their type, and when no member may, the
1309
+ # receiver reads `Dynamic[top]` ({#respond_to_admitted}). `$stdout.respond_to?(:string) ?
1310
+ # $stdout.string : ""` on an `IO`-typed `$stdout` therefore does not report `undefined method`.
1311
+ # `Dynamic[top]` rather than `Dynamic[IO]`: method availability on a `Dynamic[T]` is checked against
1312
+ # `T` (special-types.md), so only the untyped carrier is sure to keep the guarded call quiet.
1313
+ #
1071
1314
  # Conservative floor: narrow only on a literal `Symbol`/`String` argument resolved
1072
1315
  # against the RBS environment; a non-literal symbol, a missing argument, or a symbol
1073
1316
  # 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.
1317
+ # ("does not respond" proves little about the receiver's type). A local, instance variable,
1318
+ # global or constant receiver narrows ({#receiver_slot}).
1076
1319
  def analyse_respond_to_predicate(node, scope)
1077
1320
  return nil if node.block
1078
1321
  return nil if node.arguments.nil? || node.arguments.arguments.size != 1
@@ -1081,16 +1324,63 @@ module Rigor
1081
1324
  return nil if sym.nil?
1082
1325
  return nil if nilclass_method?(sym, scope)
1083
1326
 
1084
- reader, writer = emptiness_receiver_accessors(node.receiver)
1085
- return nil if reader.nil?
1327
+ slot = receiver_slot(node.receiver, scope)
1328
+ return nil if slot.nil?
1086
1329
 
1087
- current = scope.public_send(reader, node.receiver.name)
1088
- return nil if current.nil?
1330
+ admitted = respond_to_admitted(narrow_non_nil(slot.current), sym.to_sym, scope)
1331
+ return nil if admitted.equal?(slot.current) || admitted == slot.current
1089
1332
 
1090
- non_nil = narrow_non_nil(current)
1091
- return nil if non_nil.equal?(current)
1333
+ [narrow_slot(scope, slot, admitted), scope]
1334
+ end
1335
+
1336
+ # The members of `type` that may respond to `method_name`, or, when Rigor knows every member lacks it
1337
+ # ({#lacks_method?}), `Dynamic[top]`: the members Rigor read the edge from are wrong about the object there,
1338
+ # and no class is named to read it as instead. A member Rigor cannot judge (`Dynamic`, `Bot`, a class no
1339
+ # signature declares) may respond.
1340
+ #
1341
+ # When every member lacking `m` records what the file literally shows (a literal, a `Tuple`, a `HashShape`, a
1342
+ # class object, `nil` / `true` / `false`), the edge is `Bot`. For a literal value that is exact
1343
+ # (`1.respond_to?(:string)` is false in every run); a class object's arm goes unchecked when a gem without a
1344
+ # signature adds the method (`Time.respond_to?(:zone)` under ActiveSupport).
1345
+ def respond_to_admitted(type, method_name, scope)
1346
+ members = type.is_a?(Type::Union) ? type.members : [type]
1347
+ kept = members.reject { |member| lacks_method?(member, method_name, scope) }
1348
+ return type if kept.size == members.size
1349
+ return Type::Combinator.union(*kept) unless kept.empty?
1350
+ return Type::Combinator.bot if members.all? { |member| literal_carrier?(member) }
1092
1351
 
1093
- [scope.public_send(writer, node.receiver.name, non_nil), scope]
1352
+ Type::Combinator.untyped
1353
+ end
1354
+
1355
+ LITERAL_CARRIERS = [Type::Constant, Type::Tuple, Type::HashShape, Type::Singleton].freeze
1356
+ private_constant :LITERAL_CARRIERS
1357
+
1358
+ def literal_carrier?(member)
1359
+ LITERAL_CARRIERS.any? { |carrier| member.is_a?(carrier) } ||
1360
+ (member.is_a?(Type::Nominal) && VALUE_PINNED_CLASSES.include?(member.class_name))
1361
+ end
1362
+
1363
+ # True only when every class `member` dispatches through is declared in RBS, is not a mixin module or
1364
+ # `Object` / `BasicObject` (whose instances may be of any class), is not a plugin's open receiver, and
1365
+ # neither its signature nor the project defines `method_name` on it.
1366
+ def lacks_method?(member, method_name, scope)
1367
+ targets = ProjectMethodOwnership.targets(member)
1368
+ return false if targets.nil? || targets.empty?
1369
+
1370
+ targets.all? { |class_name, kind| class_lacks_method?(class_name, method_name, kind, scope) }
1371
+ end
1372
+
1373
+ OPEN_SURFACE_CLASSES = %w[Object BasicObject].freeze
1374
+ private_constant :OPEN_SURFACE_CLASSES
1375
+
1376
+ def class_lacks_method?(class_name, method_name, kind, scope)
1377
+ return false if kind == :instance && OPEN_SURFACE_CLASSES.include?(class_name)
1378
+ return false unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
1379
+ return false if kind == :instance && scope.environment.rbs_module?(class_name)
1380
+ return false if scope.environment.plugin_registry&.open_receiver?(class_name)
1381
+ return false if ProjectMethodOwnership.defines?(class_name, method_name, kind, scope)
1382
+
1383
+ ExternalAncestorResolution.method_definition(class_name, method_name, kind, scope: scope).nil?
1094
1384
  end
1095
1385
 
1096
1386
  # True when `nil` responds to `sym` — i.e. `NilClass` (own, inherited
@@ -1285,7 +1575,9 @@ module Rigor
1285
1575
  #
1286
1576
  # Returns nil (no narrowing) when the receiver / argument pair does not resolve to exactly one
1287
1577
  # regex pattern we can count (see {#regex_match_pattern}).
1288
- def analyse_regex_match_predicate(node, scope)
1578
+ #
1579
+ # `refine: false` leaves out the whole-receiver refinement, which names a String.
1580
+ def analyse_regex_match_predicate(node, scope, refine: true)
1289
1581
  return nil if node.arguments.nil?
1290
1582
  return nil unless node.arguments.arguments.size == 1
1291
1583
 
@@ -1305,10 +1597,96 @@ module Rigor
1305
1597
  # match-global logic above. A successful `str =~ /\A\d+\z/` proves the WHOLE string
1306
1598
  # matched, so the string operand narrows to the imported refinement the anchored pattern
1307
1599
  # names. The falsey edge is untouched: a failed match proves nothing about the shape.
1308
- truthy = apply_whole_receiver_refinement(truthy, node.receiver, arg, pattern.source, scope)
1600
+ truthy = apply_whole_receiver_refinement(truthy, node.receiver, arg, pattern.source, scope) if refine
1309
1601
  [truthy, falsey]
1310
1602
  end
1311
1603
 
1604
+ # Issue #1379 — `subject !~ pattern` is `Kernel#!~`, which returns `!(subject =~ pattern)`. It runs the same
1605
+ # match in the caller's frame, so its edges are {#analyse_regex_match_predicate}'s swapped: the falsey edge is
1606
+ # the match edge (`$~` a `MatchData`, an unconditional `$N` a `String`, and the whole-receiver refinement), the
1607
+ # truthy edge the no-match edge (`$~` and `$N` nil). The same patterns are recognised and the same ones
1608
+ # declined (a non-regex operand, an ambiguous constant, `//x`).
1609
+ #
1610
+ # Unlike `=~`'s, an edge here is narrowed only when the receiver proves what the frame holds on it
1611
+ # ({#not_match_receiver_edges}). An edge it cannot vouch for keeps the match globals forgotten, since the call
1612
+ # may still have rebound them. The refinement is kept only when the local it would narrow is known to be a
1613
+ # String on the match edge ({#refinable_operand?}).
1614
+ def analyse_regex_not_match_predicate(node, scope)
1615
+ edges = analyse_regex_match_predicate(node, scope, refine: refinable_operand?(node, scope))
1616
+ return nil if edges.nil?
1617
+
1618
+ match, no_match = edges
1619
+ forgotten = scope.forget_match_globals
1620
+ case not_match_receiver_edges(node, scope)
1621
+ when :both then [no_match, match]
1622
+ when :match then [forgotten, match]
1623
+ else [forgotten, forgotten]
1624
+ end
1625
+ end
1626
+
1627
+ # Which edges of `recv !~ pattern` the receiver lets {#analyse_regex_not_match_predicate} narrow. `Kernel#!~`
1628
+ # dispatches `=~` on the receiver:
1629
+ #
1630
+ # - A Regexp receiver (the pattern on the left) runs `Regexp#=~`, which sets `$~` on both outcomes, a `nil`
1631
+ # argument included: `:both`.
1632
+ # - `String#=~` and `Symbol#=~` do the same: `:both` when every member of the receiver's type is a String or a
1633
+ # Symbol.
1634
+ # - `NilClass#=~` answers nil and leaves `$~` alone, so a nil receiver reaches the no-match edge with the
1635
+ # previous match still bound; it never reaches the match edge. A `Dynamic` receiver may be nil at run time
1636
+ # whatever its static facet says: `:match` when every member is a String, a Symbol, nil, or a `Dynamic`
1637
+ # that is untyped or whose facet holds only those.
1638
+ # - Any other receiver may define `=~` in Ruby, which binds `$~` in its own frame, not the caller's: nil.
1639
+ #
1640
+ # A safe-navigation `recv&.!~(pattern)` answers nil without matching on a nil receiver, so it declines too.
1641
+ def not_match_receiver_edges(node, scope)
1642
+ return nil if node.receiver.nil? || node.safe_navigation?
1643
+ return :both if regex_operand_pattern(node.receiver, scope).is_a?(RegexMatchPattern)
1644
+
1645
+ kinds = not_match_member_kinds(scope.type_of(node.receiver), scope)
1646
+ return :both if kinds.all? { |kind| %i[string symbol].include?(kind) }
1647
+
1648
+ :match if kinds.all? { |kind| %i[string symbol nil dynamic].include?(kind) }
1649
+ end
1650
+
1651
+ # Whether the local operand {#apply_whole_receiver_refinement} would narrow holds only Strings and nil, which
1652
+ # the match edge excludes. The refinement names a String, so a member that may be a Symbol — a Symbol, or an
1653
+ # untyped `Dynamic` that `Kernel#!~` accepts as the receiver or `Regexp#=~` as the argument — keeps it out:
1654
+ # `return if raw !~ /\A\d+\z/` on an untyped `raw` must not make `raw.to_proc` an undefined method.
1655
+ def refinable_operand?(node, scope)
1656
+ operand = [node.receiver, *node.arguments&.arguments].find { |o| o.is_a?(Prism::LocalVariableReadNode) }
1657
+ type = operand && scope.local(operand.name)
1658
+ return true if type.nil?
1659
+
1660
+ not_match_member_kinds(type, scope).all? { |kind| %i[string nil].include?(kind) }
1661
+ end
1662
+
1663
+ def not_match_member_kinds(type, scope)
1664
+ (type.is_a?(Type::Union) ? type.members : [type]).map { |member| not_match_member_kind(member, scope) }
1665
+ end
1666
+
1667
+ # `:string` or `:symbol` for a member {.narrow_not_class} proves a String or a Symbol (a value or refinement of
1668
+ # either, or a class the environment orders below it; a project subclass it cannot order reads as neither),
1669
+ # `:nil`, `:dynamic` for a `Dynamic` whose facet is untyped or holds only Strings, Symbols and nil, or nil for
1670
+ # anything else.
1671
+ def not_match_member_kind(member, scope)
1672
+ return not_match_dynamic_kind(member, scope) if member.is_a?(Type::Dynamic)
1673
+ return :nil if narrow_nil(member) == member && !member.is_a?(Type::Bot)
1674
+
1675
+ environment = scope.environment
1676
+ { "String" => :string, "Symbol" => :symbol }.each do |class_name, kind|
1677
+ return kind if narrow_not_class(member, class_name, environment: environment, scope: scope).is_a?(Type::Bot)
1678
+ end
1679
+ nil
1680
+ end
1681
+
1682
+ def not_match_dynamic_kind(member, scope)
1683
+ facet = member.static_facet
1684
+ return :dynamic if facet.is_a?(Type::Top)
1685
+
1686
+ facet_kinds = not_match_member_kinds(facet, scope)
1687
+ :dynamic if facet_kinds.all? { |kind| %i[string symbol nil].include?(kind) }
1688
+ end
1689
+
1312
1690
  # `str.match?(/\A\d+\z/)` — `String#match?` is a pure boolean predicate whose truthy edge
1313
1691
  # proves a property of the ENTIRE receiver. When the argument is a fully `\A…\z`-anchored
1314
1692
  # single-char-class pattern recognised by {RegexRefinement.for_whole_pattern}, narrow the
@@ -1409,13 +1787,17 @@ module Rigor
1409
1787
  # `String`; every other numbered group present in the pattern stays `String | nil` on
1410
1788
  # both edges (a truthy match leaves an optional group nil at runtime), so we do not
1411
1789
  # narrow it on the truthy edge.
1790
+ #
1791
+ # Both edges start from `scope` with the match globals forgotten (#1385): the match rebinds every one of them,
1792
+ # so a `$N` an earlier match narrowed says nothing here. Without that, a `when /(x)/` arm, or the right operand
1793
+ # of `s =~ /(a)(b)(c)/ && t =~ /(x)/`, kept `$2` and `$3` narrowed to `String` where Ruby reads nil.
1412
1794
  def regex_match_predicate_scopes(scope, unconditional)
1413
1795
  string_t = Type::Combinator.nominal_of("String")
1414
1796
  match_data_t = Type::Combinator.nominal_of("MatchData")
1415
1797
  nil_t = Type::Combinator.constant_of(nil)
1416
1798
 
1417
- truthy = scope
1418
- falsey = scope
1799
+ truthy = scope.forget_match_globals
1800
+ falsey = truthy
1419
1801
  truthy = truthy.with_global(:$~, match_data_t)
1420
1802
  falsey = falsey.with_global(:$~, nil_t)
1421
1803
  REGEX_MATCH_GLOBALS.each do |name|
@@ -1449,8 +1831,10 @@ module Rigor
1449
1831
  # them to non-nil `String` would be unsound. The walker is intentionally light (char
1450
1832
  # scan, not a regex-AST parse): backslash escapes are skipped; `(?:…)`, lookahead
1451
1833
  # `(?=…)`/`(?!…)`, 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.
1834
+ # `(?<name>…)` / `(?'name'…)` do. Once the pattern has a named group, Ruby numbers only
1835
+ # the named groups and a plain `(…)` does not capture (#1471): `/(?<x>a)(b)/` has one
1836
+ # group, so `$2` is nil. Conservatism is one-directional — when in doubt a group is
1837
+ # treated as conditional (dropped from the Set), never the reverse.
1454
1838
  def unconditional_capture_groups(source)
1455
1839
  # `unconditional` collects every capturing index; a group is later removed (with its
1456
1840
  # whole subtree) when it is optionally quantified, nested under an optional ancestor,
@@ -1460,7 +1844,15 @@ module Rigor
1460
1844
  # mutually exclusive, so its descendant captures may be absent on a successful match;
1461
1845
  # the group itself still participates. Closing a frame rolls its subtree up to the
1462
1846
  # parent so an optional / alternated ancestor disqualifies it.
1463
- state = { unconditional: Set.new, stack: [[nil, [], false]], group_index: 0 }
1847
+ state = { unconditional: Set.new, stack: [[nil, [], false]], group_index: 0, named: named_groups?(source) }
1848
+ each_structural_char(source) { |pos, chr| scan_group_char(source, pos, chr, state) }
1849
+ # Drain the virtual root: a top-level `|` disqualifies all.
1850
+ finalize_frame(state, state[:stack].pop, optional: false)
1851
+ state[:unconditional]
1852
+ end
1853
+
1854
+ # Yields each position of `source` outside a backslash escape and a character class, with its char.
1855
+ def each_structural_char(source)
1464
1856
  pos = 0
1465
1857
  length = source.length
1466
1858
  while pos < length
@@ -1473,12 +1865,13 @@ module Rigor
1473
1865
  pos = skip_char_class(source, pos) + 1
1474
1866
  next
1475
1867
  end
1476
- scan_group_char(source, pos, chr, state)
1868
+ yield pos, chr
1477
1869
  pos += 1
1478
1870
  end
1479
- # Drain the virtual root: a top-level `|` disqualifies all.
1480
- finalize_frame(state, state[:stack].pop, optional: false)
1481
- state[:unconditional]
1871
+ end
1872
+
1873
+ def named_groups?(source)
1874
+ each_structural_char(source) { |pos, chr| break true if chr == "(" && named_group?(source, pos) } == true
1482
1875
  end
1483
1876
 
1484
1877
  # Updates the walk `state` at a group-relevant char during
@@ -1488,7 +1881,7 @@ module Rigor
1488
1881
  case chr
1489
1882
  when "("
1490
1883
  idx = nil
1491
- if capturing_group?(source, pos)
1884
+ if capturing_group?(source, pos, named: state[:named])
1492
1885
  idx = (state[:group_index] += 1)
1493
1886
  state[:unconditional] << idx
1494
1887
  end
@@ -1527,11 +1920,20 @@ module Rigor
1527
1920
  end
1528
1921
  end
1529
1922
 
1530
- def capturing_group?(source, pos)
1531
- return true unless source[pos + 1] == "?"
1923
+ # A plain `(…)` captures unless the pattern has a named group (`named:`); of the `(?…)` forms only a named
1924
+ # group does.
1925
+ def capturing_group?(source, pos, named:)
1926
+ return !named unless source[pos + 1] == "?"
1927
+
1928
+ named_group?(source, pos)
1929
+ end
1930
+
1931
+ # `(?<name>…)` and `(?'name'…)` are named groups; `(?<=…)`/`(?<!…)` (lookbehind) and
1932
+ # `(?:…)`/`(?=…)`/`(?!…)` are not.
1933
+ def named_group?(source, pos)
1934
+ return false unless source[pos + 1] == "?"
1935
+ return true if source[pos + 2] == "'"
1532
1936
 
1533
- # `(?<name>…)` captures; `(?<=…)`/`(?<!…)` (lookbehind) and
1534
- # `(?:…)`/`(?=…)`/`(?!…)` do not.
1535
1937
  source[pos + 2] == "<" && source[pos + 3] != "=" && source[pos + 3] != "!"
1536
1938
  end
1537
1939
 
@@ -1962,9 +2364,29 @@ module Rigor
1962
2364
  analyse_class_predicate_on_local(node, scope, class_name, exact)
1963
2365
  when Prism::CallNode
1964
2366
  analyse_class_predicate_on_chain(node, scope, class_name, exact)
2367
+ else
2368
+ analyse_class_predicate_on_slot(node.receiver, scope, class_name, exact)
1965
2369
  end
1966
2370
  end
1967
2371
 
2372
+ # Issue #1429 — `$stdout.is_a?(StringIO)`, `STDOUT.kind_of?(StringIO)`: a global or constant receiver
2373
+ # narrows as a local does ({#class_predicate_scopes}), with the pre-guard type recorded ({#narrow_slot}). So
2374
+ # does an instance variable (#1446, {#narrow_guarded_slot}).
2375
+ def analyse_class_predicate_on_slot(receiver, scope, class_name, exact)
2376
+ slot = receiver_slot(receiver, scope, kinds: CLASS_GUARD_SLOT_KINDS)
2377
+ return nil if slot.nil?
2378
+
2379
+ class_slot_scopes(scope, slot, class_name, exact: exact)
2380
+ end
2381
+
2382
+ def class_slot_scopes(scope, slot, class_name, exact:)
2383
+ environment = scope.environment
2384
+ truthy = narrow_class(slot.current, class_name, exact: exact, environment: environment, scope: scope)
2385
+ falsey = narrow_not_class(slot.current, class_name, exact: exact, environment: environment, scope: scope)
2386
+ truthy_scope = record_bot_guard(narrow_guarded_slot(scope, slot, truthy), slot, truthy, [class_name])
2387
+ [truthy_scope, narrow_guarded_slot(scope, slot, falsey)]
2388
+ end
2389
+
1968
2390
  # The class name a `is_a?` / `kind_of?` / `instance_of?` argument denotes: the top-level
1969
2391
  # `bare_name` when the reference is written `::Foo` (nil when nothing declares it there),
1970
2392
  # the lexical resolution otherwise.
@@ -2096,16 +2518,8 @@ module Rigor
2096
2518
  end
2097
2519
 
2098
2520
  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
- ]
2521
+ slot = ReceiverSlot.new(kind: :local, name: name, current: current)
2522
+ class_slot_scopes(scope, slot, class_name, exact: exact)
2109
2523
  end
2110
2524
 
2111
2525
  # Slice 7 phase 4 — `===`-narrowing. The case-equality predicate `<receiver> === local`
@@ -2139,9 +2553,23 @@ module Rigor
2139
2553
  analyse_case_equality_receiver(node.receiver, scope, arg.name, current)
2140
2554
  when Prism::CallNode
2141
2555
  analyse_case_equality_on_chain(node.receiver, arg, scope)
2556
+ else
2557
+ analyse_case_equality_on_slot(node.receiver, arg, scope)
2142
2558
  end
2143
2559
  end
2144
2560
 
2561
+ # Issue #1429 — `StringIO === $stdout`, `StringIO === STDOUT`: the class-constant receiver form on a global or
2562
+ # constant argument, and on an instance variable (#1446). The Range / Regexp receivers stay local-only.
2563
+ def analyse_case_equality_on_slot(receiver, arg, scope)
2564
+ class_name = lexical_class_name(receiver, scope)
2565
+ return nil if class_name.nil?
2566
+
2567
+ slot = receiver_slot(arg, scope, kinds: CLASS_GUARD_SLOT_KINDS)
2568
+ return nil if slot.nil?
2569
+
2570
+ class_slot_scopes(scope, slot, class_name, exact: false)
2571
+ end
2572
+
2145
2573
  # `Class === <local/ivar>.<method>` — the case-equality counterpart of
2146
2574
  # {.analyse_class_predicate_on_chain}. The `open3` idiom `if Hash === cmd.last` narrows
2147
2575
  # `cmd.last` to the class inside the branch, recorded as a single-hop method-chain
@@ -2360,6 +2788,10 @@ module Rigor
2360
2788
  # that position, or nil when the overload shape does not allow a precise match.
2361
2789
  def lookup_positional_arg(call_node, method_def, target_name)
2362
2790
  arguments = call_node.arguments&.arguments || []
2791
+ # A `(?)` overload (`RBS::Types::UntypedFunction`, #1430) names no parameter and accepts any argument list,
2792
+ # so a call may reach it with the target's position holding anything: no overload proves the target.
2793
+ return nil unless method_def.method_types.all? { |mt| mt.type.respond_to?(:required_positionals) }
2794
+
2363
2795
  method_def.method_types.each do |mt|
2364
2796
  params = mt.type.required_positionals + mt.type.optional_positionals
2365
2797
  index = params.find_index { |param| param.name == target_name }
@@ -2375,7 +2807,9 @@ module Rigor
2375
2807
  # cannot statically classify are treated as "no narrowing": the body falls back to the
2376
2808
  # union of what we did learn (or the entry type when nothing learned), and the falsey
2377
2809
  # 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)
2810
+ #
2811
+ # `[body type, next-clause type]` of a `when` clause's conditions over the subject type `current`.
2812
+ def case_when_types(scope, current, conditions)
2379
2813
  truthy_members = []
2380
2814
  falsey_type = current
2381
2815
  fully_narrowable = true
@@ -2392,10 +2826,7 @@ module Rigor
2392
2826
  end
2393
2827
 
2394
2828
  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
- ]
2829
+ [truthy, fully_narrowable ? falsey_type : current]
2399
2830
  end
2400
2831
 
2401
2832
  # Per-condition rule. Returns `nil` when the condition shape is not recognised (caller
@@ -2647,9 +3078,12 @@ module Rigor
2647
3078
  # `is_a?(Integer)`) narrow DOWN to `Nominal[class_name]`; otherwise (disjoint
2648
3079
  # hierarchies under `is_a?`, mismatch under `instance_of?`) collapse to `Bot`.
2649
3080
  # Conservative when the analyzer environment cannot resolve either class.
3081
+ #
3082
+ # A disjoint `Bot` narrows the edge but is not always a verdict that the code is dead: see
3083
+ # {.disjoint_nominal_guard?} (issue #1429).
2650
3084
  def narrow_nominal_to_class(nominal, class_name, context)
2651
3085
  return nominal if nominal.class_name == class_name
2652
- return Type::Combinator.bot if context.exact
3086
+ return exact_nominal_to_class(nominal, class_name, context) if context.exact
2653
3087
 
2654
3088
  case class_ordering(nominal.class_name, class_name, context)
2655
3089
  when :superclass then Type::Combinator.nominal_of(class_name)
@@ -2664,6 +3098,17 @@ module Rigor
2664
3098
  end
2665
3099
  end
2666
3100
 
3101
+ # `instance_of?(class_name)` compares the exact class, so its truthy edge holds `class_name` itself. Issue
3102
+ # #1429: when `class_name` is a class below the nominal's (`Numeric` under `instance_of?(Integer)`), that is
3103
+ # the edge's type; the edge was `Bot` before, for every class but the nominal's own. A module is never an
3104
+ # object's class, and any other ordering leaves no class the object can have, so those stay `Bot`.
3105
+ def exact_nominal_to_class(nominal, class_name, context)
3106
+ return Type::Combinator.bot if context.environment.rbs_module?(class_name)
3107
+ return Type::Combinator.bot unless class_ordering(nominal.class_name, class_name, context) == :superclass
3108
+
3109
+ Type::Combinator.nominal_of(class_name)
3110
+ end
3111
+
2667
3112
  def narrow_nominal_not_class(nominal, class_name, context)
2668
3113
  return Type::Combinator.bot if nominal.class_name == class_name
2669
3114
  return nominal if context.exact
@@ -2808,25 +3253,15 @@ module Rigor
2808
3253
  context.environment.class_ordering(lhs, rhs)
2809
3254
  end
2810
3255
 
3256
+ # `x.nil?` on a local, instance variable, global or constant (#1429) read.
2811
3257
  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?
3258
+ slot = receiver_slot(receiver, scope)
3259
+ return nil if slot.nil?
2824
3260
 
2825
- [
2826
- scope.with_ivar(receiver.name, narrow_nil(current)),
2827
- scope.with_ivar(receiver.name, narrow_non_nil(current))
2828
- ]
2829
- end
3261
+ [
3262
+ narrow_slot(scope, slot, narrow_nil(slot.current)),
3263
+ narrow_slot(scope, slot, narrow_non_nil(slot.current))
3264
+ ]
2830
3265
  end
2831
3266
 
2832
3267
  # The three value-pinned classes a union arm can name whose instances are the single
@@ -2945,21 +3380,39 @@ module Rigor
2945
3380
  def analyse_safe_nav_receiver(node, scope)
2946
3381
  return nil unless node.safe_navigation?
2947
3382
 
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
3383
+ narrowed = non_nil_slot_scope(node.receiver, scope)
3384
+ narrowed && [narrowed, scope]
3385
+ end
2955
3386
 
2956
- current = scope.public_send(reader, receiver.name)
2957
- return nil if current.nil?
3387
+ # `scope` with the receiver `node` reads (a local, instance variable, global or constant, #1429) narrowed to
3388
+ # its non-nil fragment, or nil when there is no such receiver or it holds no `nil` to remove.
3389
+ def non_nil_slot_scope(node, scope)
3390
+ slot = receiver_slot(node, scope)
3391
+ return nil if slot.nil?
2958
3392
 
2959
- non_nil = narrow_non_nil(current)
2960
- return nil if non_nil.equal?(current)
3393
+ non_nil = narrow_non_nil(slot.current)
3394
+ return nil if non_nil.equal?(slot.current)
3395
+
3396
+ narrow_slot(scope, slot, non_nil)
3397
+ end
3398
+
3399
+ # The scope {.safe_navigation_scope} answers for a receiver, seen through the `&.` links of a chain whose
3400
+ # operands leave the root's binding alone, or nil. Unlike {.non_nil_slot_scope} it compares the fragment
3401
+ # structurally: `narrow_non_nil` rebuilds a union it removed nothing from, and a receiver that cannot be nil
3402
+ # must leave the call's operands on the scope they are typed from.
3403
+ def non_nil_receiver_scope(node, scope)
3404
+ if node.is_a?(Prism::CallNode)
3405
+ return nil unless node.safe_navigation?
3406
+ return nil if OperandEffects.any?(node.arguments) || OperandEffects.any?(node.block)
3407
+
3408
+ return non_nil_receiver_scope(node.receiver, scope)
3409
+ end
2961
3410
 
2962
- [scope.public_send(writer, receiver.name, non_nil), scope]
3411
+ slot = receiver_slot(node, scope)
3412
+ return nil if slot.nil?
3413
+
3414
+ non_nil = narrow_non_nil(slot.current)
3415
+ non_nil == slot.current ? nil : narrow_slot(scope, slot, non_nil)
2963
3416
  end
2964
3417
 
2965
3418
  # Issue #606 slice 1 — the SAFE-NAV CHAIN forms. {.analyse_safe_nav_receiver} above proves
@@ -3031,12 +3484,17 @@ module Rigor
3031
3484
  return nil if suffixes.empty? && !allow_bare
3032
3485
 
3033
3486
  receiver = current.receiver
3034
- return nil unless receiver.is_a?(Prism::LocalVariableReadNode) ||
3035
- receiver.is_a?(Prism::InstanceVariableReadNode)
3487
+ return nil unless SLOT_READ_NODES.any? { |klass| receiver.is_a?(klass) }
3036
3488
 
3037
3489
  [receiver, suffixes]
3038
3490
  end
3039
3491
 
3492
+ SLOT_READ_NODES = [
3493
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode,
3494
+ Prism::ConstantReadNode, Prism::ConstantPathNode
3495
+ ].freeze
3496
+ private_constant :SLOT_READ_NODES
3497
+
3040
3498
  # Which edge — if either — a chain's outcome narrows on. The inner suffixes (everything
3041
3499
  # below the outermost) only ever have to NOT return, so they are held to the raise test
3042
3500
  # alone; the outermost decides the edge per the table in {NIL_FALSEY_SUFFIXES}'s comment.
@@ -3051,20 +3509,7 @@ module Rigor
3051
3509
  end
3052
3510
 
3053
3511
  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)
3512
+ non_nil_slot_scope(receiver, scope)
3068
3513
  end
3069
3514
 
3070
3515
  # Issue #606 slice 1, second form — a safe-nav chain COMPARED to a value that cannot be
@@ -3174,14 +3619,23 @@ module Rigor
3174
3619
  return edges unless node.safe_navigation? && edges
3175
3620
 
3176
3621
  truthy, falsey = edges
3177
- safe = analyse_safe_nav_receiver(node, scope)
3178
- return edges unless safe
3622
+ slot = receiver_slot(node.receiver, scope)
3623
+ return edges if slot.nil?
3624
+
3625
+ non_nil = narrow_non_nil(slot.current)
3626
+ return edges if non_nil.equal?(slot.current)
3179
3627
 
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]
3628
+ [narrow_slot(truthy, slot, non_nil), falsey]
3629
+ end
3630
+
3631
+ # The predicates composed from other predicates, whose edges are built from their
3632
+ # operands' edges.
3633
+ def analyse_composition(node, scope)
3634
+ case node
3635
+ when Prism::AndNode then analyse_and(node, scope)
3636
+ when Prism::OrNode then analyse_or(node, scope)
3637
+ else analyse_conditional(node, scope)
3638
+ end
3185
3639
  end
3186
3640
 
3187
3641
  # `a && b` short-circuits: the truthy edge is the truthy edge of `b` evaluated under
@@ -3191,7 +3645,7 @@ module Rigor
3191
3645
  # output scopes.
3192
3646
  def analyse_and(node, scope)
3193
3647
  truthy_a, falsey_a = analyse(node.left, scope) || [scope, scope]
3194
- truthy_b, falsey_b = analyse(node.right, truthy_a) || [truthy_a, truthy_a]
3648
+ truthy_b, falsey_b = operand_edges(node.right, truthy_a)
3195
3649
  [truthy_b, falsey_a.join(falsey_b)]
3196
3650
  end
3197
3651
 
@@ -3200,9 +3654,105 @@ module Rigor
3200
3654
  # falsey scope evaluated under `a`'s falsey scope.
3201
3655
  def analyse_or(node, scope)
3202
3656
  truthy_a, falsey_a = analyse(node.left, scope) || [scope, scope]
3203
- truthy_b, falsey_b = analyse(node.right, falsey_a) || [falsey_a, falsey_a]
3657
+ truthy_b, falsey_b = operand_edges(node.right, falsey_a)
3204
3658
  [truthy_a.join(truthy_b), falsey_b]
3205
3659
  end
3660
+
3661
+ # The edges of an operand that runs only on one edge of an earlier operand, which is the
3662
+ # step `&&` and `||` both take for their right operand. Issue #1359 — the operand runs after
3663
+ # that edge, so a `$_` the earlier operand narrowed is forgotten when the operand may set it
3664
+ # (`gets && log(items.each { gets })`), before the operand's own edges narrow it again.
3665
+ # Issue #1429 — likewise a guard's narrowing of a global or constant the operand may rebind
3666
+ # (`$sep && reset_sep && $sep.length`).
3667
+ def operand_edges(node, edge_scope)
3668
+ edge_scope = LastLine.forget_if_set(edge_scope, node)
3669
+ if edge_scope.guard_narrowed? && GuardRebinding.may_rebind?(node, edge_scope)
3670
+ edge_scope = edge_scope.forget_guard_narrowings
3671
+ end
3672
+ analyse(node, edge_scope) || [edge_scope, edge_scope]
3673
+ end
3674
+
3675
+ # Issue #1017 — `p ? q : r` (and `if` / `unless` in condition position) is truthy through
3676
+ # `p ∧ q` or `¬p ∧ r` and falsey through `p ∧ ¬q` or `¬p ∧ ¬r`. Each conjunction is the
3677
+ # `&&` step — the arm's edges under the predicate's edge ({#operand_edges}) — and each
3678
+ # disjunction is the `||` merge (`Scope#join`), so `p ? false : q` narrows as `!p && q`
3679
+ # does without a literal-arm rule: an arm whose value is settled contributes only its
3680
+ # live edge ({#settled_edges}). Beyond {CONDITIONAL_GUARD_DEPTH} the guard declines.
3681
+ #
3682
+ # The predicate's own edges are never dropped as settled. `scope` is the scope after the
3683
+ # whole conditional ran, so it has already joined the arms, and a carrier every arm
3684
+ # narrowed has lost its optimistic mark there (`Scope#with_local` drops it): a
3685
+ # `missable.nil?` predicate reads as a proof-looking `false` it is not. An arm is judged
3686
+ # under the predicate's edge instead, where a settled value follows from that edge.
3687
+ def analyse_conditional(node, scope)
3688
+ return nil if conditional_guard_height(node) > CONDITIONAL_GUARD_DEPTH
3689
+
3690
+ predicate, then_arm, else_arm = conditional_parts(node)
3691
+ truthy_p, falsey_p = operand_edges(predicate, scope)
3692
+ truthy_q, falsey_q = settled_edges(then_arm, truthy_p)
3693
+ truthy_r, falsey_r = settled_edges(else_arm, falsey_p)
3694
+ truthy = join_live_edges(truthy_q, truthy_r)
3695
+ falsey = join_live_edges(falsey_q, falsey_r)
3696
+ return nil if truthy.nil? && falsey.nil?
3697
+
3698
+ [truthy || scope, falsey || scope]
3699
+ end
3700
+
3701
+ # `[predicate, arm run when it is truthy, arm run when it is falsey]`. An arm is a body or
3702
+ # `nil` for an absent / empty one; an `elsif` is the nested conditional itself.
3703
+ def conditional_parts(node)
3704
+ if node.is_a?(Prism::UnlessNode)
3705
+ [node.predicate, else_body(node.else_clause), node.statements]
3706
+ else
3707
+ subsequent = node.subsequent
3708
+ [node.predicate, node.statements, subsequent.is_a?(Prism::ElseNode) ? else_body(subsequent) : subsequent]
3709
+ end
3710
+ end
3711
+
3712
+ def else_body(else_node)
3713
+ else_node&.statements
3714
+ end
3715
+
3716
+ # An operand's `[truthy, falsey]` edges with a dead edge as `nil`. An absent arm evaluates
3717
+ # to `nil`, so only its falsey edge is live. Otherwise the edge the operand's type proves
3718
+ # unreachable is dropped — unless that proof rests on an optimistically nil-free lookup
3719
+ # (ADR-101, issue #313), which `branch_certainty` refuses to elide an arm on as well.
3720
+ def settled_edges(node, scope)
3721
+ return [nil, scope] if node.nil?
3722
+
3723
+ truthy, falsey = operand_edges(node, scope)
3724
+ case predicate_certainty(scope.type_of(node))
3725
+ when :truthy then optimistic_operand?(node, scope) ? [truthy, falsey] : [truthy, nil]
3726
+ when :falsey then optimistic_operand?(node, scope) ? [truthy, falsey] : [nil, falsey]
3727
+ else [truthy, falsey]
3728
+ end
3729
+ end
3730
+
3731
+ def optimistic_operand?(node, scope)
3732
+ node = node.body.last while node.is_a?(Prism::StatementsNode) && !node.body.empty?
3733
+ !OptimisticOrigin.resolve(node, scope).nil?
3734
+ end
3735
+
3736
+ def join_live_edges(left, right)
3737
+ return right if left.nil?
3738
+ return left if right.nil?
3739
+
3740
+ left.join(right)
3741
+ end
3742
+
3743
+ # The number of conditionals stacked in `node` along the shapes {.analyse} walks.
3744
+ def conditional_guard_height(node)
3745
+ case node
3746
+ when Prism::IfNode, Prism::UnlessNode
3747
+ 1 + conditional_parts(node).map { |part| conditional_guard_height(part) }.max
3748
+ when Prism::ParenthesesNode then conditional_guard_height(node.body)
3749
+ when Prism::StatementsNode then conditional_guard_height(node.body.last)
3750
+ when Prism::AndNode, Prism::OrNode
3751
+ [conditional_guard_height(node.left), conditional_guard_height(node.right)].max
3752
+ when Prism::CallNode then node.name == :! ? conditional_guard_height(node.receiver) : 0
3753
+ else 0
3754
+ end
3755
+ end
3206
3756
  end
3207
3757
  end
3208
3758
  end