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
data/lib/rigor/scope.rb CHANGED
@@ -3,10 +3,13 @@
3
3
  require_relative "type"
4
4
  require_relative "environment"
5
5
  require_relative "scope/discovery_index"
6
+ require_relative "scope/resolution_chain"
7
+ require_relative "inference/definer_resolution"
6
8
  require_relative "analysis/fact_store"
7
9
  require_relative "analysis/dependency_recorder"
8
10
  require_relative "inference/expression_typer"
9
11
  require_relative "inference/flow_tracer"
12
+ require_relative "inference/optimistic_origin"
10
13
  require_relative "inference/statement_evaluator"
11
14
  require_relative "inference/def_node_resolver"
12
15
 
@@ -23,10 +26,12 @@ module Rigor
23
26
  :indexed_narrowings, :method_chain_narrowings,
24
27
  :declaration_sourced, :published_constant_sourced,
25
28
  :source_path, :discovery, :struct_fold_safe_locals,
26
- :opaque_block_self, :lexical_nesting,
29
+ :opaque_block_self, :singleton_class_body, :lexical_nesting,
27
30
  :dynamic_origins, :local_origins, :ivar_origins,
28
31
  :void_origins, :plugin_typed_calls,
29
- :optimistic_origins, :optimistic_locals, :optimistic_ivars
32
+ :optimistic_origins, :optimistic_locals, :optimistic_ivars,
33
+ :repeated_or_writes, :match_frame,
34
+ :constant_narrowings, :guard_records, :bot_guard_classes
30
35
 
31
36
  # ADR-53 Track A — the seed-time discovery tables live on the {DiscoveryIndex} the scope carries by a single
32
37
  # reference; the per-table readers stay on Scope so engine call sites and plugins are unaffected by the
@@ -47,8 +52,26 @@ module Rigor
47
52
  def discovered_def_sources = @discovery.discovered_def_sources
48
53
  def discovered_singleton_def_sources = @discovery.discovered_singleton_def_sources
49
54
  def discovered_method_visibilities = @discovery.discovered_method_visibilities
55
+ def discovered_parameter_envelopes = @discovery.discovered_parameter_envelopes
50
56
  def discovered_superclasses = @discovery.discovered_superclasses
57
+
58
+ # Issue #1097 — `{file path => [[start_offset, end_offset, name, kind, owner], ...]}`, the def /
59
+ # block / lambda body ranges {singleton_def_shadows_call?} / {instance_def_shadows_call?} order a
60
+ # project-defined override against.
61
+ def discovered_deferred_ranges = @discovery.discovered_deferred_ranges
62
+ # Issue #1120 — `{refined class name => {method name => [refining module names]}}`: what each
63
+ # `refine X do … end` block defines, visible only where a `using` of its module is in effect.
64
+ def discovered_refinements = @discovery.discovered_refinements
65
+ # Issue #1367 — the project's `Inference::GlobalWriteCensus`, a `Set` of frozen entries.
66
+ def discovered_global_write_census = @discovery.discovered_global_write_census
51
67
  def discovered_includes = @discovery.discovered_includes
68
+ # Issue #1123 — `{qualified class or module name => [module names it `prepend`s, as written]}`, in
69
+ # instance-ancestor search order (nearest prepend first). The one table that tells a `prepend` from an
70
+ # `include`: {ResolutionChain} reads it to put a prepended module ahead of the class.
71
+ # The plugin-facing view: each class's prepends once, nearest first. The discovery index itself keeps every
72
+ # statement (a repeated `prepend` is a no-op in Ruby, the first statement winning), and the resolution chain
73
+ # reads that raw list.
74
+ def discovered_prepends = @discovery.discovered_prepends.transform_values { |names| names.uniq.freeze }.freeze
52
75
  def discovered_extends = @discovery.discovered_extends
53
76
  def discovered_class_sources = @discovery.discovered_class_sources
54
77
  # Issue #644 — `{qualified constant name => Set[declaring file]}`; seeded only on an ADR-46 recording run.
@@ -56,6 +79,30 @@ module Rigor
56
79
  def published_constant_names = @discovery.published_constant_names
57
80
  def local_constant_names = @discovery.local_constant_names
58
81
 
82
+ # Issue #617 — the census names sharing `name`'s last segment that bind, spelled as the census spells
83
+ # them (a `*::LIMIT` wildcard included), so the caller still decides which of them a reference resolves
84
+ # to. A `||=`-only name is left out while no other file memoizes its segment: the memoization idiom's
85
+ # own write, however often one file repeats it, is not what binds it.
86
+ def bound_constant_names(name)
87
+ @discovery.constant_writers[name.split("::").last] || EMPTY_BOUND_CONSTANT_NAMES
88
+ end
89
+
90
+ # Issue #1290 — the census names sharing `name`'s last segment that some write other than a memo `||=`
91
+ # assigns: the ones that shadow an outer constant of the name, so the lexical ladder stops at them
92
+ # (`Reflection.resolve_constant_type`). That asks once per constant reference, so a bare name is looked up
93
+ # as it stands rather than split, and a qualified one is sliced after its last `::` rather than split into
94
+ # an Array of every segment ([#1502](https://github.com/rigortype/rigor/issues/1502)). The two agree on every
95
+ # name a parsed constant path spells. Only the `Foo::` Prism recovers from a syntax error differs, and no
96
+ # file with a parse error is typed.
97
+ def shadowing_constant_names(name)
98
+ separator = name.rindex("::")
99
+ segment = separator ? name[(separator + 2)..] : name
100
+ @discovery.constant_shadowers[segment] || EMPTY_BOUND_CONSTANT_NAMES
101
+ end
102
+
103
+ EMPTY_BOUND_CONSTANT_NAMES = [].freeze
104
+ private_constant :EMPTY_BOUND_CONSTANT_NAMES
105
+
59
106
  # Issue #667 — the instance-variable names of `class_name` whose class-ivar seed comes from a foreign
60
107
  # published constant (`@mode = AppConfig::MODE` in `initialize`, read in a sibling method).
61
108
  # `StatementEvaluator#seed_instance_ivars` stamps the flow mark for these at method-body entry, the way
@@ -186,10 +233,38 @@ module Rigor
186
233
  # Issue #667 — the empty answer of {#published_constant_ivars_for}, so a class with no such ivar (every
187
234
  # class in a project that publishes nothing) allocates none.
188
235
  EMPTY_PUBLISHED_CONSTANT_IVARS = Set.new.freeze
236
+ # The index `||=` sites of a repeating block body whose slot an earlier run of the body may have filled,
237
+ # keyed by node identity and laid by a block-return pass at the body's entry
238
+ # ({#with_repeated_or_writes}; `Inference::RepeatedOrWrites` decides which). The memoizing `||=` reading
239
+ # (`StatementEvaluator#index_compound_write_value`) is withheld at such a site. The mark belongs to the
240
+ # site, so no rebind or narrowing of a variable drops it, and a join keeps a site either arm holds: the
241
+ # mark only ever withholds that reading, whose answer is the narrower one.
242
+ EMPTY_REPEATED_OR_WRITES = {}.compare_by_identity.freeze
243
+ # Issue #1429 — the narrowing a guard leaves on a constant reference (`STDOUT.is_a?(StringIO)`, `if CONFIG`),
244
+ # keyed by the reference's spelling ({Inference::Narrowing.constant_key}: `"STDOUT"`, `"::Foo::BAR"`). A constant
245
+ # has no binding of its own in the scope, so the table is what a read in the guarded edge answers
246
+ # (`ExpressionTyper#type_of_constant_read`); a spelling it does not hold resolves as before.
247
+ EMPTY_CONSTANT_NARROWINGS = {}.freeze
248
+ # Issue #1429 — what each global and constant a guard narrowed was bound to before the guard, keyed
249
+ # `[:global, :$name]` / `[:constant, key]`. Ruby may rebind a global (or `const_set` a constant) whenever code the
250
+ # analysis cannot see runs, so a call or block that may run project or unresolved code restores each recorded
251
+ # binding to the union of this pre-guard type and its narrowed one ({#forget_guard_narrowings}). A write drops
252
+ # the record ({#with_global}), and a name the frame-local special-variable machinery owns is never recorded.
253
+ # Issue #1446 — a class guard's narrowing of an instance variable is recorded too, keyed `[:ivar, :@name]`
254
+ # ({#with_guarded_ivar}), since that code may reach `self` and rebind it; a write drops it ({#without_ivar_guard}).
255
+ EMPTY_GUARD_RECORDS = {}.freeze
256
+ # Issue #1446 — the classes a class guard narrowed a receiver to where the narrowing left it `bot`, keyed like
257
+ # {EMPTY_GUARD_RECORDS} with `[:local, :name]` too: `return unless @cb.is_a?(Proc)` binds `@cb` to `bot` when the
258
+ # guard is disjoint from its binding, but the value that passes it is a `Proc` (or a subclass of one), and a call
259
+ # on it dispatches on that class. `Inference::GuardRebinding` reads the class a `bot` receiver runs its methods
260
+ # on from here ({#bot_guard_classes_for}); a `bot` no class guard produced has no entry. A write or another
261
+ # narrowing of the name drops its entry, and a join keeps one only both arms hold.
262
+ EMPTY_BOT_GUARD_CLASSES = {}.freeze
189
263
  private_constant :EMPTY_VAR_BINDINGS, :EMPTY_INDEXED_NARROWINGS,
190
264
  :EMPTY_CHAIN_NARROWINGS, :EMPTY_DECLARATION_SOURCED,
191
265
  :EMPTY_FOLD_SAFE, :EMPTY_ORIGINS, :EMPTY_PUBLISHED_CONSTANT_SOURCED,
192
- :EMPTY_PUBLISHED_CONSTANT_IVARS
266
+ :EMPTY_PUBLISHED_CONSTANT_IVARS, :EMPTY_REPEATED_OR_WRITES,
267
+ :EMPTY_CONSTANT_NARROWINGS, :EMPTY_GUARD_RECORDS, :EMPTY_BOT_GUARD_CLASSES
193
268
 
194
269
  class << self
195
270
  def empty(environment: Environment.default, source_path: nil)
@@ -230,7 +305,7 @@ module Rigor
230
305
  @plugin_typed_calls.key?(node)
231
306
  end
232
307
 
233
- def initialize(
308
+ def initialize( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- one keyword and assignment per field
234
309
  environment:, locals:,
235
310
  fact_store: Analysis::FactStore.empty,
236
311
  self_type: nil,
@@ -245,6 +320,7 @@ module Rigor
245
320
  source_path: nil,
246
321
  struct_fold_safe_locals: EMPTY_FOLD_SAFE,
247
322
  opaque_block_self: false,
323
+ singleton_class_body: false,
248
324
  lexical_nesting: nil,
249
325
  dynamic_origins: {}.compare_by_identity,
250
326
  local_origins: EMPTY_ORIGINS,
@@ -253,7 +329,12 @@ module Rigor
253
329
  plugin_typed_calls: {}.compare_by_identity,
254
330
  optimistic_origins: {}.compare_by_identity,
255
331
  optimistic_locals: EMPTY_ORIGINS,
256
- optimistic_ivars: EMPTY_ORIGINS
332
+ optimistic_ivars: EMPTY_ORIGINS,
333
+ repeated_or_writes: EMPTY_REPEATED_OR_WRITES,
334
+ match_frame: nil,
335
+ constant_narrowings: EMPTY_CONSTANT_NARROWINGS,
336
+ guard_records: EMPTY_GUARD_RECORDS,
337
+ bot_guard_classes: EMPTY_BOT_GUARD_CLASSES
257
338
  )
258
339
  @environment = environment
259
340
  @locals = locals
@@ -270,6 +351,7 @@ module Rigor
270
351
  @source_path = source_path
271
352
  @struct_fold_safe_locals = struct_fold_safe_locals
272
353
  @opaque_block_self = opaque_block_self
354
+ @singleton_class_body = singleton_class_body
273
355
  @lexical_nesting = lexical_nesting
274
356
  @dynamic_origins = dynamic_origins
275
357
  @local_origins = local_origins
@@ -279,6 +361,11 @@ module Rigor
279
361
  @optimistic_origins = optimistic_origins
280
362
  @optimistic_locals = optimistic_locals
281
363
  @optimistic_ivars = optimistic_ivars
364
+ @repeated_or_writes = repeated_or_writes
365
+ @match_frame = match_frame
366
+ @constant_narrowings = constant_narrowings
367
+ @guard_records = guard_records
368
+ @bot_guard_classes = bot_guard_classes
282
369
  freeze
283
370
  end
284
371
 
@@ -291,19 +378,30 @@ module Rigor
291
378
  self
292
379
  end
293
380
 
294
- def optimistic_local(name) = @optimistic_locals[name.to_sym]
295
- def optimistic_ivar(name) = @optimistic_ivars[name.to_sym]
381
+ def optimistic_local(name) = Inference::OptimisticOrigin.bound_cause(@optimistic_locals[name.to_sym])
382
+ def optimistic_ivar(name) = Inference::OptimisticOrigin.bound_cause(@optimistic_ivars[name.to_sym])
383
+
384
+ # Issue #1302 — what the value a marked local / ivar is bound to answers on a miss, as its binding recorded
385
+ # it beside the mark, or {Inference::OptimisticOrigin::UNKNOWN_MISS}. The answer lives in the mark's own
386
+ # table entry ({Inference::OptimisticOrigin::BoundMark}), so every transition that keeps or drops the mark
387
+ # keeps or drops the answer with it.
388
+ def optimistic_local_miss(name) = Inference::OptimisticOrigin.bound_miss(@optimistic_locals[name.to_sym])
389
+ def optimistic_ivar_miss(name) = Inference::OptimisticOrigin.bound_miss(@optimistic_ivars[name.to_sym])
296
390
 
297
- def with_optimistic_local(name, cause)
391
+ # `miss` is what the bound value answers when the bet fails ({Inference::OptimisticOrigin.miss_answer}); the
392
+ # default records none, so a read through the binding widens as an untold miss does.
393
+ def with_optimistic_local(name, cause, miss: Inference::OptimisticOrigin::UNKNOWN_MISS)
298
394
  return self if cause.nil?
299
395
 
300
- rebuild(optimistic_locals: @optimistic_locals.merge(name.to_sym => cause).freeze)
396
+ mark = Inference::OptimisticOrigin.bound_mark(cause, miss)
397
+ rebuild(optimistic_locals: @optimistic_locals.merge(name.to_sym => mark).freeze)
301
398
  end
302
399
 
303
- def with_optimistic_ivar(name, cause)
400
+ def with_optimistic_ivar(name, cause, miss: Inference::OptimisticOrigin::UNKNOWN_MISS)
304
401
  return self if cause.nil?
305
402
 
306
- rebuild(optimistic_ivars: @optimistic_ivars.merge(name.to_sym => cause).freeze)
403
+ mark = Inference::OptimisticOrigin.bound_mark(cause, miss)
404
+ rebuild(optimistic_ivars: @optimistic_ivars.merge(name.to_sym => mark).freeze)
307
405
  end
308
406
 
309
407
  # ADR-82 WD1 — the propagated origin of the `Dynamic` value currently bound to a local / instance
@@ -331,6 +429,13 @@ module Rigor
331
429
  end
332
430
 
333
431
  def with_local(name, type)
432
+ bind_local(name, type, keep_marks: false)
433
+ end
434
+
435
+ # The one body behind {#with_local} and {#with_mutated_local}, which differ only in whether the two marks a
436
+ # write drops survive. Passing the kept sets through unchanged, rather than dropping and re-adding them, keeps
437
+ # a mutation's rebind to the one `rebuild` a write costs.
438
+ def bind_local(name, type, keep_marks:)
334
439
  # `rigor trace` — the moment a local enters the scope.
335
440
  Inference::FlowTracer.bind(name, type) if Inference::FlowTracer.active?
336
441
  new_locals = @locals.merge(name.to_sym => type).freeze
@@ -349,14 +454,16 @@ module Rigor
349
454
  rebuild(locals: new_locals, fact_store: new_fact_store,
350
455
  indexed_narrowings: new_indexed_narrowings,
351
456
  method_chain_narrowings: new_chain_narrowings,
352
- declaration_sourced: drop_declaration_sourced_for(:local, name),
457
+ declaration_sourced: keep_marks ? @declaration_sourced : drop_local_declaration_marks(name),
353
458
  # Issue #667 — rebinding is flow-live for the published-constant mark too: the new value need
354
459
  # not be a copy of anything. `with_published_constant_mark` re-stamps afterward when the write's
355
460
  # rvalue is one.
356
461
  published_constant_sourced: drop_published_constant_sourced_for(:local, name),
357
462
  local_origins: drop_origin(@local_origins, name),
358
- optimistic_locals: drop_origin(@optimistic_locals, name))
463
+ optimistic_locals: keep_marks ? @optimistic_locals : drop_origin(@optimistic_locals, name),
464
+ bot_guard_classes: drop_bot_guard_class(:local, name))
359
465
  end
466
+ private :bind_local
360
467
 
361
468
  def with_fact(fact)
362
469
  rebuild(fact_store: fact_store.with_fact(fact))
@@ -420,6 +527,27 @@ module Rigor
420
527
  # True when this scope sits inside a block whose `self` is unmodelled ({#entering_opaque_block}).
421
528
  def opaque_block_self? = @opaque_block_self
422
529
 
530
+ # Issue #963 — marks the body of a `class << ...` as such. Inside it `self` is the SINGLETON class, which
531
+ # Rigor models with the same `Singleton[X]` carrier a `class X` body gets, so the carrier alone cannot say
532
+ # which of the two a scope came from. The distinction decides what `define_method` does: in a `class << self`
533
+ # body it defines a CLASS method (`self` there is the singleton class), while in every other body reached
534
+ # with a `Singleton[X]` self — a `class X` body, `def self.x`, a `def` inside `class << self` — `self` is the
535
+ # class object and the same call defines an INSTANCE method.
536
+ #
537
+ # The mark is stamped at singleton-class-body entry and inherited by every scope derived inside it, blocks
538
+ # included; a `def` body starts from a fresh scope and therefore clears it by construction, which is exactly
539
+ # the boundary the distinction needs. A meta-class body (`Class.new do ... end`) clears it explicitly: that
540
+ # block is a class body of its own, whatever encloses it.
541
+ def with_singleton_class_body(flag)
542
+ return self if @singleton_class_body == flag
543
+
544
+ rebuild(singleton_class_body: flag)
545
+ end
546
+
547
+ # True when this scope IS a `class << ...` body (not merely inside one lexically — a `def` reached from it
548
+ # answers false).
549
+ def singleton_class_body? = @singleton_class_body
550
+
423
551
  # True when `name`'s `Struct` member reads are fold-safe in this body (the local is provably never mutated /
424
552
  # aliased / escaped).
425
553
  def struct_fold_safe?(name)
@@ -450,6 +578,11 @@ module Rigor
450
578
  end
451
579
 
452
580
  def with_ivar(name, type)
581
+ bind_ivar(name, type, @guard_records)
582
+ end
583
+
584
+ # The one body behind {#with_ivar} and {#with_guarded_ivar}, which differ only in the guard records they leave.
585
+ def bind_ivar(name, type, guard_records)
453
586
  new_indexed_narrowings = drop_indexed_narrowings_for(:ivar, name)
454
587
  new_chain_narrowings = drop_chain_narrowings_for(:ivar, name)
455
588
  # ADR-58 WD1 — a method-local ivar write or narrowing is flow-live: drop any declaration-sourced mark so
@@ -461,8 +594,11 @@ module Rigor
461
594
  declaration_sourced: drop_declaration_sourced_for(:ivar, name),
462
595
  published_constant_sourced: drop_published_constant_sourced_for(:ivar, name),
463
596
  ivar_origins: drop_origin(@ivar_origins, name),
464
- optimistic_ivars: drop_origin(@optimistic_ivars, name))
597
+ optimistic_ivars: drop_origin(@optimistic_ivars, name),
598
+ guard_records: guard_records,
599
+ bot_guard_classes: drop_bot_guard_class(:ivar, name))
465
600
  end
601
+ private :bind_ivar
466
602
 
467
603
  # ADR-58 WD1 — used by the method-entry seed to mark an ivar whose only provenance is the class-ivar index.
468
604
  # Unlike `with_ivar` this binds the type AND records the declaration-sourced mark in one transition.
@@ -486,6 +622,20 @@ module Rigor
486
622
  rebuild(declaration_sourced: add_declaration_sourced(:local, name))
487
623
  end
488
624
 
625
+ # Issue #1287 — rebinds `name` for an in-place mutation of the object it already holds: a mutator's widening
626
+ # (`r << x`), a content floor after a closure or callee mutated it, an element or member write through it. The
627
+ # binding still names the same object, so this is not a flow-live write, and the marks `with_local` drops stay:
628
+ # ADR-58's declaration-sourced mark and issue #286's optimistic nil-freeness mark, with the miss answer issue
629
+ # #1302 records in it. A source-level write keeps going through `with_local`, which drops both.
630
+ #
631
+ # Two other per-local tables are deliberately still dropped. An ADR-82 `local_origins` cause explains the
632
+ # `Dynamic` the ASSIGNMENT bound, and a floor's `Dynamic[top]` has a different cause. Issue #667's
633
+ # published-constant mark can never be live here: only a frozen scalar publishes, and a frozen value is not
634
+ # mutated in place.
635
+ def with_mutated_local(name, type)
636
+ bind_local(name, type, keep_marks: true)
637
+ end
638
+
489
639
  # Issue #667 — record that `name` is currently bound to a value copied out of a foreign published
490
640
  # constant. Always applied AFTER the `with_local` / `with_ivar` that binds the value (both drop the mark
491
641
  # unconditionally), exactly as {#with_local_declaration_mark} is.
@@ -505,6 +655,8 @@ module Rigor
505
655
  # ADR-58 WD1 — true when `(kind, name)`'s binding optionality is purely declaration-sourced (no flow-live
506
656
  # write/narrowing has touched it).
507
657
  def declaration_sourced?(kind, name)
658
+ return false if @declaration_sourced.empty?
659
+
508
660
  @declaration_sourced.include?([kind.to_sym, name.to_sym])
509
661
  end
510
662
 
@@ -548,24 +700,324 @@ module Rigor
548
700
  rebuild(cvars: @cvars.merge(name.to_sym => type).freeze)
549
701
  end
550
702
 
703
+ # Issue #1362 — a write or narrowing of a global is flow-live, so it drops the ADR-58 `:global` mark
704
+ # {#seed_declaration_sourced_global} stamped on the program-global seed.
705
+ #
706
+ # Issue #1429 — `$stdout` and `$>` are one variable, so a write to either also restores a guard's narrowing of the
707
+ # other ({#forget_guard_narrowing_of}).
551
708
  def with_global(name, type)
552
- rebuild(globals: @globals.merge(name.to_sym => type).freeze)
709
+ name = name.to_sym
710
+ written = rebuild(globals: @globals.merge(name => type).freeze,
711
+ declaration_sourced: drop_declaration_sourced_for(:global, name),
712
+ guard_records: drop_guard_record(:global, name),
713
+ bot_guard_classes: drop_bot_guard_class(:global, name))
714
+ alias_name = STDOUT_ALIASES[name]
715
+ alias_name ? written.forget_guard_narrowing_of(:global, alias_name) : written
716
+ end
717
+
718
+ STDOUT_ALIASES = { :$stdout => :$>, :$> => :$stdout }.freeze
719
+ private_constant :STDOUT_ALIASES
720
+
721
+ # Issue #1429 — {#forget_guard_narrowings} for the one name `[kind, name]`.
722
+ def forget_guard_narrowing_of(kind, name)
723
+ key = [kind, name]
724
+ pre_guard = @guard_records[key]
725
+ return self if pre_guard.nil?
726
+
727
+ if kind == :global
728
+ current = @globals[name]
729
+ globals = current ? @globals.merge(name => Type::Combinator.union(pre_guard, current)).freeze : @globals
730
+ rebuild(globals: globals, guard_records: @guard_records.except(key).freeze)
731
+ else
732
+ current = @constant_narrowings[name]
733
+ constants = if current
734
+ @constant_narrowings.merge(name => Type::Combinator.union(pre_guard, current)).freeze
735
+ else
736
+ @constant_narrowings
737
+ end
738
+ rebuild(constant_narrowings: constants, guard_records: @guard_records.except(key).freeze)
739
+ end
740
+ end
741
+
742
+ # Issue #1429 — binds the global `name` to `type` on a guard's edge, recording `pre_guard`, the binding the guard
743
+ # narrowed, unless an earlier guard already recorded one ({EMPTY_GUARD_RECORDS}). `record: false` is the
744
+ # frame-local specials' form, which binds without a record: their own machinery forgets them.
745
+ def with_guarded_global(name, type, pre_guard, record: true)
746
+ name = name.to_sym
747
+ records = record ? add_guard_record([:global, name].freeze, pre_guard) : drop_guard_record(:global, name)
748
+ rebuild(globals: @globals.merge(name => type).freeze,
749
+ declaration_sourced: drop_declaration_sourced_for(:global, name),
750
+ guard_records: records,
751
+ bot_guard_classes: drop_bot_guard_class(:global, name))
752
+ end
753
+
754
+ # Issue #1446 — binds the instance variable `name` to `type` on a class guard's edge, recording `pre_guard` as
755
+ # {#with_guarded_global} does. The truthiness, `nil?`, `&.` and `respond_to?` guards narrow an instance variable
756
+ # through {#with_ivar} and record nothing.
757
+ def with_guarded_ivar(name, type, pre_guard)
758
+ name = name.to_sym
759
+ bind_ivar(name, type, add_guard_record([:ivar, name].freeze, pre_guard))
760
+ end
761
+
762
+ # Issue #1446 — this scope with no guard record for the instance variable `name`: what a write to it leaves.
763
+ # {#with_ivar} keeps the record, since a narrowing binds through it too.
764
+ def without_ivar_guard(name)
765
+ records = drop_guard_record(:ivar, name.to_sym)
766
+ records.equal?(@guard_records) ? self : rebuild(guard_records: records)
767
+ end
768
+
769
+ # Issue #1446 — true when a class guard's narrowing of the instance variable `name` is live.
770
+ def guard_narrowed_ivar?(name)
771
+ !@guard_records.empty? && @guard_records.key?([:ivar, name.to_sym])
772
+ end
773
+
774
+ # Issue #1429 — the type a guard narrowed the constant reference `key` to, or nil.
775
+ def constant_narrowing(key)
776
+ return nil if @constant_narrowings.empty?
777
+
778
+ @constant_narrowings[key]
779
+ end
780
+
781
+ # Issue #1429 — the constant reference `key` read as `type` on a guard's edge, recording `pre_guard` as
782
+ # {#with_guarded_global} does.
783
+ def with_constant_narrowing(key, type, pre_guard)
784
+ rebuild(constant_narrowings: @constant_narrowings.merge(key => type).freeze,
785
+ guard_records: add_guard_record([:constant, key].freeze, pre_guard),
786
+ bot_guard_classes: drop_bot_guard_class(:constant, key))
787
+ end
788
+
789
+ # Issue #1446 — this scope with `class_names` recorded as the classes a class guard narrowed the receiver `[kind,
790
+ # name]` to, where the guard left it `bot` ({EMPTY_BOT_GUARD_CLASSES}). Call it after the narrowing, which drops
791
+ # any earlier entry.
792
+ def with_bot_guard_classes(kind, name, class_names)
793
+ key = [kind, kind == :constant ? name : name.to_sym].freeze
794
+ rebuild(bot_guard_classes: @bot_guard_classes.merge(key => class_names.dup.freeze).freeze)
795
+ end
796
+
797
+ # Issue #1446 — the classes a class guard narrowed the `bot` receiver `[kind, name]` to, or nil.
798
+ def bot_guard_classes_for(kind, name)
799
+ return nil if @bot_guard_classes.empty?
800
+
801
+ @bot_guard_classes[[kind, name]]
802
+ end
803
+
804
+ # Issue #1429 — every constant reference whose last segment is `name` with no narrowing: what a write to a constant
805
+ # named `name` leaves, since `Foo::BAR` and `BAR` may name the one it wrote.
806
+ def without_constant_narrowings_named(name)
807
+ keys = @constant_narrowings.keys.select { |key| key.split("::").last == name }
808
+ return self if keys.empty?
809
+
810
+ records = @guard_records.reject { |(kind, key), _| kind == :constant && keys.include?(key) }
811
+ classes = @bot_guard_classes.except(*keys.map { |key| [:constant, key] }).freeze
812
+ rebuild(constant_narrowings: @constant_narrowings.except(*keys).freeze, guard_records: records.freeze,
813
+ bot_guard_classes: classes)
814
+ end
815
+
816
+ # Issue #1429 — the constant reference `key` with no narrowing.
817
+ def without_constant_narrowing(key)
818
+ return self unless @constant_narrowings.key?(key)
819
+
820
+ rebuild(constant_narrowings: @constant_narrowings.except(key).freeze,
821
+ guard_records: @guard_records.except([:constant, key]).freeze,
822
+ bot_guard_classes: drop_bot_guard_class(:constant, key))
823
+ end
824
+
825
+ # True when a guard's narrowing of a global, constant or instance variable is live, the state
826
+ # {#forget_guard_narrowings} drops and so the gate on every scan that decides whether to.
827
+ def guard_narrowed?
828
+ !@guard_records.empty?
829
+ end
830
+
831
+ # Issue #1429 — this scope past code that may rebind a global or a constant: each one a guard narrowed reads
832
+ # the union of its narrowed type and the binding the guard narrowed, and no record is left. The union, not the
833
+ # pre-guard binding alone, because the code may leave the value as it was: `$stdout.is_a?(StringIO)`, then a
834
+ # helper, reads `IO | StringIO`, which keeps `$stdout.string` quiet as the guard intended.
835
+ #
836
+ # Issue #1446 — an instance variable a class guard narrowed is restored the same way.
837
+ def forget_guard_narrowings
838
+ return self if @guard_records.empty?
839
+
840
+ tables = { global: @globals, constant: @constant_narrowings, ivar: @ivars }
841
+ @guard_records.each do |(kind, name), pre_guard|
842
+ table = tables.fetch(kind)
843
+ current = table[name]
844
+ tables[kind] = table.merge(name => Type::Combinator.union(pre_guard, current)) if current
845
+ end
846
+ globals, constants, ivars = tables.values_at(:global, :constant, :ivar).map { |t| t.frozen? ? t : t.freeze }
847
+ rebuild(globals: globals, constant_narrowings: constants, ivars: ivars, guard_records: EMPTY_GUARD_RECORDS)
848
+ end
849
+
850
+ # Issue #1362 (ADR-58 parity, ADR-117 Decision point 2) — used by the method-entry and top-level seeds to bind a
851
+ # global Ruby's own signatures declare to its declared type joined with the file's writes, and to record that the
852
+ # binding is still that seed. The declared members are real type information but not diagnostic fuel
853
+ # ({Analysis::CheckRules::DeclarationSourcedGuard}); a write or narrowing drops the mark ({#with_global}).
854
+ def seed_declaration_sourced_global(name, type)
855
+ rebuild(globals: @globals.merge(name.to_sym => type).freeze,
856
+ declaration_sourced: add_declaration_sourced(:global, name))
857
+ end
858
+
859
+ # Issue #1362 — a local written from a read of a marked global (`sep = $/`) carries ADR-58's `:local` mark, and
860
+ # this records the globals it may copy, so a consumer can compare against the file's writes to them
861
+ # ({#declaration_sourced_global_copies}). Applied after {#with_declaration_sourced_local}; the local's next
862
+ # rebinding drops both ({#bind_local}), and a join of two copies keeps both branches' globals.
863
+ def with_global_copy_marks(name, globals)
864
+ name = name.to_sym
865
+ added = globals.map { |global| [:global_copy, name, global.to_sym].freeze }
866
+ .reject { |ref| @declaration_sourced.include?(ref) }
867
+ return self if added.empty?
868
+
869
+ rebuild(declaration_sourced: @declaration_sourced.dup.merge(added).freeze)
870
+ end
871
+
872
+ # Issue #1362 — this scope with the local `name`'s ADR-58 mark and copy record dropped and its binding kept: a
873
+ # retried pass that re-enters with a binding the accumulated one accepts, but from another source.
874
+ def without_local_declaration_marks(name)
875
+ dropped = drop_local_declaration_marks(name)
876
+ dropped.equal?(@declaration_sourced) ? self : rebuild(declaration_sourced: dropped)
877
+ end
878
+
879
+ # The globals the local `name` is a marked copy of ({#with_global_copy_marks}), empty for a local that copies
880
+ # no marked global.
881
+ def declaration_sourced_global_copies(name)
882
+ return EMPTY_GLOBAL_COPIES unless declaration_sourced?(:local, name)
883
+
884
+ name = name.to_sym
885
+ @declaration_sourced.filter_map { |ref| ref[2] if ref[0] == :global_copy && ref[1] == name }
886
+ end
887
+ EMPTY_GLOBAL_COPIES = [].freeze
888
+ private_constant :EMPTY_GLOBAL_COPIES
889
+
890
+ # Mark `nodes`, index `||=` sites, as ones whose slot an earlier run of a repeating block body may have
891
+ # filled ({EMPTY_REPEATED_OR_WRITES}).
892
+ def with_repeated_or_writes(nodes)
893
+ return self if nodes.all? { |node| @repeated_or_writes.key?(node) }
894
+
895
+ marked = @repeated_or_writes.dup
896
+ nodes.each { |node| marked[node] = true }
897
+ rebuild(repeated_or_writes: marked.freeze)
898
+ end
899
+
900
+ # True when {#with_repeated_or_writes} marked the index `||=` node `node` (by identity).
901
+ def repeated_or_write?(node)
902
+ return false if @repeated_or_writes.empty?
903
+
904
+ @repeated_or_writes.key?(node)
553
905
  end
554
906
 
555
907
  # Regex match-data globals (`$~`, `$&`, `$1..$9`, the pre/post-match and last-paren back-references). Narrowed
556
- # on a successful-`=~` / `case`-`when` match edge (see `Narrowing#regex_match_predicate_scopes`); any
557
- # subsequent method call could run another match and rebind every one of them, so `eval_call` forgets the
558
- # narrowed facts here. Always safe — only drops facts, so a subsequent read falls back to the default
559
- # `String | nil`. Program-level `$GLOBAL = ...` seeds use other names and are untouched.
908
+ # on a successful-`=~` / `case`-`when` match edge (see `Narrowing#regex_match_predicate_scopes`). They live in
909
+ # the method frame's special-variable slot, which the method's blocks and closures share (issue #1358), so a
910
+ # later call that may run a match in this frame — a match-capable call, a block that may match, a call once
911
+ # the frame has made a closure that may — rebinds every one of them, and `eval_call` forgets the narrowed
912
+ # facts here. Always safe — only drops facts, so a subsequent read falls back to the default `String | nil`.
913
+ # Program-level `$GLOBAL = ...` seeds use other names and are untouched.
560
914
  MATCH_DATA_GLOBALS = %i[$~ $& $` $' $+ $1 $2 $3 $4 $5 $6 $7 $8 $9].freeze
561
915
  private_constant :MATCH_DATA_GLOBALS
562
916
 
563
917
  def forget_match_globals
564
- return self unless @globals.keys.any? { |k| MATCH_DATA_GLOBALS.include?(k) }
918
+ return self unless match_globals_bound?
565
919
 
566
920
  rebuild(globals: @globals.except(*MATCH_DATA_GLOBALS).freeze)
567
921
  end
568
922
 
923
+ # Issue #1361 — this scope with every bound match-data global rebound to `Dynamic[top]`, and an unbound one left
924
+ # unbound: the view a `define_method` / `define_singleton_method` body enters with
925
+ # ({Inference::FreshFrameBlocks.entry}). The body reads the defining frame's slot whenever the method is called,
926
+ # which the narrowing where it is written neither proves nor refutes, so it is neither narrowed nor flagged.
927
+ def untyped_match_globals
928
+ return self unless match_globals_bound?
929
+
930
+ untyped = Type::Combinator.untyped
931
+ rebound = MATCH_DATA_GLOBALS.each_with_object({}) { |name, acc| acc[name] = untyped if @globals.key?(name) }
932
+ rebuild(globals: @globals.merge(rebound).freeze)
933
+ end
934
+
935
+ # True when any match-data global holds a binding, which a match edge or {#untyped_match_globals} makes: the only
936
+ # state {#forget_match_globals} can drop, and so the gate on every scan that decides whether to.
937
+ def match_globals_bound?
938
+ !@globals.empty? && MATCH_DATA_GLOBALS.any? { |name| @globals.key?(name) }
939
+ end
940
+
941
+ # Issue #1359 — the last line read, `$_`, lives in the same frame slot as the match globals, and so on the same
942
+ # terms: a `gets`-family call, a write, or a block or closure of the frame that may run either rebinds it
943
+ # ({Inference::LastLine}). It is bound only where a condition on a reader narrows it, or where code writes it.
944
+ LAST_LINE = :$_
945
+ private_constant :LAST_LINE
946
+
947
+ def forget_last_line
948
+ return self unless last_line_bound?
949
+
950
+ rebuild(globals: @globals.except(LAST_LINE).freeze)
951
+ end
952
+
953
+ # The `$_` half of {#untyped_match_globals}: a bound `$_` rebound to `Dynamic[top]`, an unbound one left alone.
954
+ def untyped_last_line
955
+ return self unless last_line_bound?
956
+
957
+ rebuild(globals: @globals.merge(LAST_LINE => Type::Combinator.untyped).freeze)
958
+ end
959
+
960
+ # The gate on every scan that decides whether to forget `$_`, as {#match_globals_bound?} is for the match globals.
961
+ def last_line_bound?
962
+ !@globals.empty? && @globals.key?(LAST_LINE)
963
+ end
964
+
965
+ # Issue #1360 — the exception being rescued, `$!`, and its backtrace, `$@`. Ruby finds them through the nearest
966
+ # rescue frame of the running execution context, not through the method frame, so they are bound only inside a
967
+ # `rescue` clause or a rescue modifier's fallback and read their earlier binding again once it exits
968
+ # ({Inference::ErrorInfo}). `$?`, the status of the last child process, is thread-local, and is bound after a
969
+ # subprocess call ({Inference::LastStatus}).
970
+ ERROR_INFO_GLOBALS = %i[$! $@].freeze
971
+ LAST_STATUS_GLOBALS = %i[$?].freeze
972
+ private_constant :ERROR_INFO_GLOBALS, :LAST_STATUS_GLOBALS
973
+
974
+ # This scope with `$!` and `$@` unbound: the view of a body that runs outside the rescue clause it is written in.
975
+ def forget_error_info = forget_globals(ERROR_INFO_GLOBALS)
976
+
977
+ # This scope with a bound `$!` or `$@` rebound to `Dynamic[top]`, and an unbound one left unbound, as
978
+ # {#untyped_match_globals} gives a `define_method` body.
979
+ def untyped_error_info = untyped_globals(ERROR_INFO_GLOBALS)
980
+
981
+ # This scope with `$?` unbound: the view of a body that may run on another thread.
982
+ def forget_last_status = forget_globals(LAST_STATUS_GLOBALS)
983
+
984
+ # The `$?` half of {#untyped_error_info}.
985
+ def untyped_last_status = untyped_globals(LAST_STATUS_GLOBALS)
986
+
987
+ def forget_globals(names)
988
+ return self if @globals.empty? || names.none? { |name| @globals.key?(name) }
989
+
990
+ rebuild(globals: @globals.except(*names).freeze)
991
+ end
992
+
993
+ def untyped_globals(names)
994
+ return self if @globals.empty? || names.none? { |name| @globals.key?(name) }
995
+
996
+ untyped = Type::Combinator.untyped
997
+ rebound = names.each_with_object({}) { |name, acc| acc[name] = untyped if @globals.key?(name) }
998
+ rebuild(globals: @globals.merge(rebound).freeze)
999
+ end
1000
+ private :forget_globals, :untyped_globals
1001
+
1002
+ # Issue #1358 — stamps the frame `body` runs in ({Inference::MatchRebinding::Frame}) on a method, class or
1003
+ # file body's entry scope; a method passes its `parameters` too, whose defaults run in the same frame. Every
1004
+ # scope derived from it, a block's included, runs in that frame.
1005
+ def with_match_frame(body, parameters = nil)
1006
+ rebuild(match_frame: Inference::MatchRebinding::Frame.new(body, parameters))
1007
+ end
1008
+
1009
+ # True when this scope's frame makes a closure that may rebind its match globals whenever it is invoked
1010
+ # ({Inference::MatchRebinding.matching_closure?}). False where no body stamped a frame.
1011
+ def match_rebinding_closure?
1012
+ !@match_frame.nil? && @match_frame.matching_closure?(self)
1013
+ end
1014
+
1015
+ # True when this scope's frame makes a closure that may set its `$_` whenever it is invoked
1016
+ # ({Inference::LastLine.closure?}). False where no body stamped a frame.
1017
+ def last_line_closure?
1018
+ !@match_frame.nil? && @match_frame.last_line_closure?(self)
1019
+ end
1020
+
569
1021
  # Slice 7 phase 2 — class-level ivar accumulator. Keyed by the qualified class name (e.g. `"Rigor::Scope"`);
570
1022
  # the value is a `Hash[Symbol, Type::t]` of every ivar that appears as a write target inside any def body of
571
1023
  # that class. `StatementEvaluator#build_method_entry_scope` seeds the method body's `ivars` map from this
@@ -612,6 +1064,18 @@ module Rigor
612
1064
  @self_type.nil?
613
1065
  end
614
1066
 
1067
+ # ADR-119 WD1 — the RAW slot of the def tables for `class_name` and `method_name`: the instance side by default,
1068
+ # the singleton side for `kind: :singleton`. The slot is a live `Prism::DefNode` or a {Inference::DefHandle}
1069
+ # (ADR-85 WD3), returned as stored: no dependency recording and no handle resolution. ADR-119 C1d/C2 decide how
1070
+ # a contested slot answers here. Callers that need a node go through {#user_def_for} or {#singleton_def_for}.
1071
+ # The `same_slot` census reads of this slot stay justified: certainty reaches them through the declaration
1072
+ # signature the producer fingerprints (C1d-a), not through this accessor.
1073
+ def def_node_slot(class_name, method_name, kind = :instance)
1074
+ table = kind == :singleton ? discovered_singleton_def_nodes : discovered_def_nodes
1075
+ per_class = table[class_name]
1076
+ per_class && per_class[method_name]
1077
+ end
1078
+
615
1079
  # v0.0.2 #5 — per-class table mapping `method_name (Symbol) → Prism::DefNode`. Populated by `ScopeIndexer`
616
1080
  # alongside `discovered_methods` for instance-side defs only (singleton-side and `define_method`-introduced
617
1081
  # methods do not contribute a static body the engine can re-type). Consumed by `ExpressionTyper` to do
@@ -699,12 +1163,15 @@ module Rigor
699
1163
  same_file_top_level_def?(method_name) ? node : nil
700
1164
  end
701
1165
 
1166
+ # ADR-119 WD1 errata, deliberately unchanged: this compares the recorded site's FILE with the call's file, and
1167
+ # which file a def sits in does not depend on whether it executes. The `<toplevel>` slots are contested
1168
+ # truthfully (ADR-119 C1d-a) but unread until C2 makes the top-level reader decline on them.
702
1169
  def same_file_top_level_def?(method_name)
703
1170
  key = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
704
1171
  site = discovered_def_sources.dig(key, method_name.to_sym)
705
1172
  return true if site.nil? || @source_path.nil?
706
1173
 
707
- File.expand_path(site.sub(/:\d+\z/, "")) == File.expand_path(@source_path)
1174
+ File.absolute_path(site.sub(/:\d+\z/, "")) == File.absolute_path(@source_path)
708
1175
  end
709
1176
  private :same_file_top_level_def?
710
1177
 
@@ -756,6 +1223,86 @@ module Rigor
756
1223
  site
757
1224
  end
758
1225
 
1226
+ # Issue #1097 — whether `class_name`'s own singleton `def method_name` has RUN by the time `call_node`
1227
+ # executes. Such a def precedes every `extend` in the singleton ancestry, so once it exists it owns the
1228
+ # call — but `sig { ... }` written BEFORE `def self.sig` in the same body still resolves through the
1229
+ # already-extended module, because `def` takes effect at execution. The site table stores
1230
+ # `"path:line"`: ordering applies only to calls executed eagerly in the same file's class body — a
1231
+ # call inside a def body runs at invocation time, and a def in another file can never be ordered
1232
+ # against the call site, so both conservatively count as shadowing. A nil `call_node`
1233
+ # (position-less dispatch probes) does the same.
1234
+ def singleton_def_shadows_call?(class_name, method_name, call_node)
1235
+ def_shadows_call?(user_singleton_def_site_for(class_name, method_name), class_name, method_name,
1236
+ :singleton, call_node)
1237
+ end
1238
+
1239
+ # The instance-side twin of {#singleton_def_shadows_call?}: whether `class_name`'s `def method_name`
1240
+ # has run by call time — for `extend M` edges, where M's instance surface is what answers.
1241
+ def instance_def_shadows_call?(class_name, method_name, call_node)
1242
+ def_shadows_call?(user_def_site_for(class_name, method_name), class_name, method_name, :instance,
1243
+ call_node)
1244
+ end
1245
+
1246
+ # Shared ordering half of the two `*_def_shadows_call?` predicates, over this file's
1247
+ # `discovered_deferred_ranges` (issue #1097). `exists` — a recorded `"path:line"` site or the
1248
+ # method-existence table — says a def of `method_name`/`kind` on `class_name` is known at all; a
1249
+ # discovered def with no site (an `attr_*` sibling, a bundle seed that predates the table) counts
1250
+ # through `discovered_method?`. The ranges then answer the timing questions, and can supply defs
1251
+ # the site table missed (a `module_function`-installed `sig` never enters the singleton-def
1252
+ # table). A foreign-file site cannot be ordered against the call — shadowing. A nil `call_node`
1253
+ # (position-less dispatch probes) or a file the index never saw falls back to `exists`.
1254
+ def def_shadows_call?(site, class_name, method_name, kind, call_node)
1255
+ exists = !site.nil? || discovered_method?(class_name, method_name, kind)
1256
+ return exists if call_node.nil?
1257
+
1258
+ path = source_path
1259
+ return exists unless path
1260
+
1261
+ if site
1262
+ site_path, = site.rpartition(":")
1263
+ return true unless site_path == path
1264
+ end
1265
+
1266
+ ranges = @discovery.discovered_deferred_ranges[path]
1267
+ return exists if ranges.nil?
1268
+
1269
+ deferred_ranges_shadow_call?(ranges, method_name.to_sym, kind, class_name.to_s,
1270
+ call_node.location, exists)
1271
+ end
1272
+ private :def_shadows_call?
1273
+
1274
+ # The range scan behind {#def_shadows_call?}. A call CONTAINED in any def / block / lambda /
1275
+ # `END` range is deferred — it runs at invocation time — so it is shadowed iff a matching def is
1276
+ # known (`exists`, or a row the site table missed). An EAGER call is shadowed iff the earliest
1277
+ # same-name, same-kind (`:both` matches either), same-OWNER row starts before it: owner scoping
1278
+ # keeps `class A`'s defs from ordering `class F`'s calls, and earliest-of keeps
1279
+ # `def self.sig; sig {}; def self.sig` honest where the def-node table is later-wins. With no
1280
+ # matching row the answer is `exists` — "a def exists but cannot be ordered" stays conservative,
1281
+ # "no def at all" stays permissive.
1282
+ def deferred_ranges_shadow_call?(ranges, method_name, kind, class_name, call_loc, exists)
1283
+ contained = false
1284
+ matching = false
1285
+ earliest = nil
1286
+ ranges.each do |(start, finish, name, def_kind, owner)|
1287
+ contained ||= start <= call_loc.start_offset && call_loc.end_offset <= finish
1288
+ next unless deferred_row_orders_call?(name, def_kind, owner, method_name, kind, class_name)
1289
+
1290
+ matching = true
1291
+ earliest = start if earliest.nil? || start < earliest
1292
+ end
1293
+ return exists || matching if contained
1294
+
1295
+ earliest.nil? ? exists : earliest <= call_loc.start_offset
1296
+ end
1297
+ private :deferred_ranges_shadow_call?
1298
+
1299
+ # Whether a range row can order the call: same method name, same qualified owner, and a def kind
1300
+ # that answers this predicate's side (`:both` — `module_function` — matches either).
1301
+ def deferred_row_orders_call?(name, def_kind, owner, method_name, kind, class_name)
1302
+ name == method_name && owner == class_name && (def_kind == kind || def_kind == :both)
1303
+ end
1304
+ private :deferred_row_orders_call?
1305
+
759
1306
  # ADR-24 slice 2 — per-class table mapping a fully qualified user-class name to its superclass name AS WRITTEN
760
1307
  # at the `class Foo < Bar` declaration (`"Bar"`, possibly a qualified `"A::B"`). Populated by `ScopeIndexer` —
761
1308
  # per-file plus the cross-file project pre-pass — and consumed by
@@ -792,10 +1339,15 @@ module Rigor
792
1339
  end
793
1340
 
794
1341
  # ADR-24 slice 2 — per-class/module table mapping a fully qualified user class or module to the list of
795
- # module names it `include`s / `prepend`s, AS WRITTEN at the mixin call. Populated by `ScopeIndexer` (per-file
1342
+ # module names it `include`s / `prepend`s, in instance-ancestor SEARCH order: prepended modules first
1343
+ # (Ruby inserts them ahead of the class itself), then included modules nearest-first — `include A;
1344
+ # include B` searches B before A, while `include A, B` keeps `["A", "B"]` (each statement's argument
1345
+ # list lands as one unit, ahead of the earlier statements'). Populated by `ScopeIndexer` (per-file
796
1346
  # plus the cross-file pre-pass) and consumed by `ExpressionTyper#resolve_user_def_through_ancestors` so an
797
- # implicit-self call resolves against an included module's `def`s, not just the superclass chain. As-written
798
- # names are resolved to qualified classes at walk time.
1347
+ # implicit-self call resolves against an included module's `def`s, not just the superclass chain.
1348
+ # As-written names are resolved to qualified classes at walk time. Issue #1173 — the list was call
1349
+ # order until then, a divergence from Ruby's later-include-wins the order-sensitive consumers (the
1350
+ # BFS mixin step, the external-ancestor walk, override visibility) silently absorbed.
799
1351
  def includes_of(class_name)
800
1352
  record_class_dependency(class_name) if Analysis::DependencyRecorder.active?
801
1353
  @discovery.discovered_includes[class_name.to_s] || []
@@ -829,118 +1381,130 @@ module Rigor
829
1381
  names.uniq
830
1382
  end
831
1383
 
832
- # ADR-24 slice 2 — the user-side ancestor walk: resolves `method_name` against `class_name`'s own `def`s,
833
- # then breadth-first through its included / prepended modules (mixins first, as Ruby orders them) and its
834
- # superclass chain. Returns `[def_node, owner]`, or `[nil, nil]` when nothing in the project defines it.
835
- # RBS-known / third-party ancestors resolve to no discovered class and end that branch.
1384
+ # ADR-24, amended for #1567 / #1568 / #1570 / #1571 — every reader below answers in Ruby's linearised
1385
+ # ancestor order, read from {ResolutionChain} (an internal class: `Scope`'s own surface is the plugin API
1386
+ # `spec/rigor/public_api_drift_spec.rb` pins, and the chain is not part of it). None walks `includes_of` /
1387
+ # `superclass_of` / the extends table itself; `spec/rigor/scope/ancestry_walker_detection_spec.rb` fails on
1388
+ # a method that does. Each keeps its signature and its return type exactly: only WHICH definer it answers
1389
+ # moved. `name_memo:` is still accepted and no longer needed — the chain memoises name resolution itself.
1390
+ UNUSED_NAME_MEMO = {}.freeze
1391
+ private_constant :UNUSED_NAME_MEMO
836
1392
  #
837
- # It lives HERE, not in a consumer, because it reads nothing but this scope's frozen discovery index
838
- # (`user_def_for` / `includes_of` / `superclass_of` / the `discovered_*` tables) — the same property that
839
- # lets `ExpressionTyper` memoise its results run-wide. Two callers depend on that single answer:
840
- # `ExpressionTyper#compute_user_def_with_owner`, which wraps it in the run-scoped memo for the dispatch hot
841
- # path, and {Inference::MethodDispatcher::StructMaterialization}, whose `.with` guard must see a `with`
842
- # contributed by an included module. They forked once — the guard resolved own-class-only while the
843
- # dispatcher walked ancestors — and answered differently about the same receiver expression (#598 review).
1393
+ # Every reader below asks {ResolutionChain#settle} whether its chain answer stands, and where it does not
1394
+ # answers what the walk it replaced answered ({ResolutionChain::MasterOrder}) — the tables cannot say which
1395
+ # world ran, and a disagreement is no reason to answer anything new.
1396
+
1397
+ # ADR-24 slice 2 — the user-side method lookup: the first project `def` of `method_name` along
1398
+ # `class_name`'s instance chain, as `[def_node, owner]`, or `[nil, nil]` when nothing the project declares
1399
+ # defines it. An external ancestor is passed over, as before: a caller asking "does the project define
1400
+ # this" must not read an RBS module's declaration as absence (#1572 is the typing read that will stop
1401
+ # there).
844
1402
  #
845
- # `name_memo` is a CACHE, not semantics: the per-edge as-written-name resolutions, so a caller resolving
846
- # many methods against one class pays each ancestor edge once. Omit it and the walk is identical, just
847
- # uncached.
848
- def user_def_through_ancestors(class_name, method_name, name_memo: {})
849
- queue = [class_name.to_s]
850
- seen = {}
851
- visited = 0
852
- until queue.empty?
853
- current = queue.shift
854
- next if current.nil? || seen[current]
1403
+ # It lives HERE, not in a consumer, because it reads nothing but this scope's frozen discovery index — the
1404
+ # property that lets `ExpressionTyper` memoise its results run-wide. `ExpressionTyper#compute_user_def_with_owner`
1405
+ # wraps it in that memo for the dispatch hot path, and {Inference::MethodDispatcher::StructMaterialization}'s
1406
+ # `.with` guard reads the same answer (#598 review).
1407
+ #
1408
+ # #1567 — the walk was breadth-first until the chain replaced it: `class C < Base; include A` with `A`
1409
+ # including `M` answered `Base#foo` where Ruby calls `M#foo`. The prepend wedge #1123 added (a prepended
1410
+ # module searched ahead of the class's own `def`s) and the include search order #1173 fixed are both
1411
+ # positions on the chain now, at every class on it.
1412
+ def user_def_through_ancestors(class_name, method_name, name_memo: UNUSED_NAME_MEMO) # rubocop:disable Lint/UnusedMethodArgument
1413
+ chain = ResolutionChain.for(self, class_name.to_s, :instance, :methods)
1414
+ found = first_user_def(chain, method_name)
1415
+ owner = found&.last
1416
+ if chain.settle(self, owner, owner: owner) { |retro| first_user_def(retro, method_name)&.last } == :master
1417
+ found = master_user_def(class_name.to_s, method_name)
1418
+ end
1419
+ return found if found
855
1420
 
856
- seen[current] = true
857
- visited += 1
858
- return ancestor_walk_gave_up if visited > ANCESTOR_WALK_LIMIT
1421
+ chain.truncated? ? ancestor_walk_gave_up : [nil, nil]
1422
+ end
859
1423
 
860
- found = user_def_for(current, method_name)
861
- return [found, current] if found
1424
+ def first_user_def(chain, method_name)
1425
+ chain.search(self) do |entry|
1426
+ next if entry.external?
862
1427
 
863
- enqueue_ancestors(current, queue, name_memo)
1428
+ node = user_def_for(entry.name, method_name)
1429
+ [node, entry.name] if node
864
1430
  end
865
- [nil, nil]
866
1431
  end
867
1432
 
868
- # Issue #731 — the singleton-side twin of {#user_def_through_ancestors}: resolves `method_name` against
869
- # `class_name`'s own `def self.` / `class << self` bodies, then up the SUPERCLASS chain. Returns
870
- # `[def_node, owner]`, or `[nil, nil]`.
871
- #
872
- # Superclasses only, and that is the whole shape of class-method inheritance in Ruby: a subclass
873
- # inherits its parent's class methods, while an `include`d module contributes INSTANCE methods and its
874
- # own `def self.x` is not callable on the includer. `extend M` needs no walk either — `ScopeIndexer`
875
- # folds an extend into the extender's own singleton entries before the table is frozen — so the one
876
- # edge this misses is a module's singleton surface reached through some other route, which nothing
877
- # resolves today.
878
- def singleton_def_through_ancestors(class_name, method_name, name_memo: {})
879
- queue = [class_name.to_s]
880
- seen = {}
881
- visited = 0
882
- until queue.empty?
883
- current = queue.shift
884
- next if current.nil? || seen[current]
1433
+ def master_user_def(class_name, method_name)
1434
+ names = ResolutionChain::MasterOrder.definer_sequence(self, class_name)
1435
+ names.each { |name| record_class_dependency(name) } if Analysis::DependencyRecorder.active?
1436
+ names.each do |name|
1437
+ node = user_def_for(name, method_name)
1438
+ return [node, name] if node
1439
+ end
1440
+ nil
1441
+ end
1442
+ private :first_user_def, :master_user_def
1443
+
1444
+ # Issue #731 — the singleton-side twin of {#user_def_through_ancestors}: the first `def self.` /
1445
+ # `class << self` body of `method_name` along `class_name`'s singleton chain, as `[def_node, owner]`, or
1446
+ # `[nil, nil]` — each class object's own table, then the modules it extends in Ruby's order, then the
1447
+ # superclass's. `ScopeIndexer` folds an extended module's own `def`s into the extender's table, so those
1448
+ # answer at the extender; a module the extended one includes is its own entry (#1567's singleton shape:
1449
+ # `class C < Base; extend A` with `A` including `M` is `M#foo`, not `Base.foo`). Where the chain does
1450
+ # not stand, the answer is the class objects' alone, as the superclass-only walk this replaced gave.
1451
+ def singleton_def_through_ancestors(class_name, method_name, name_memo: UNUSED_NAME_MEMO) # rubocop:disable Lint/UnusedMethodArgument
1452
+ chain = ResolutionChain.for(self, class_name.to_s, :singleton, :methods)
1453
+ found = first_singleton_def(chain, method_name)
1454
+ owner = found&.last
1455
+ if chain.settle(self, owner, owner: owner) { |retro| first_singleton_def(retro, method_name)&.last } == :master
1456
+ found = first_singleton_def(chain, method_name, :singleton)
1457
+ end
1458
+ return found if found
885
1459
 
886
- seen[current] = true
887
- visited += 1
888
- return ancestor_walk_gave_up if visited > ANCESTOR_WALK_LIMIT
1460
+ chain.truncated? ? ancestor_walk_gave_up : [nil, nil]
1461
+ end
889
1462
 
890
- found = singleton_def_for(current, method_name)
891
- return [found, current] if found
1463
+ def first_singleton_def(chain, method_name, side = nil)
1464
+ chain.search(self, side: side) do |entry|
1465
+ next if entry.external?
892
1466
 
893
- enqueue_ancestors(current, queue, name_memo, mixins: false)
1467
+ name = entry.name
1468
+ node = entry.side == :singleton ? singleton_def_for(name, method_name) : user_def_for(name, method_name)
1469
+ [node, entry.name] if node
894
1470
  end
895
- [nil, nil]
896
1471
  end
1472
+ private :first_singleton_def
897
1473
 
898
1474
  # Issue #633 — the ancestors a project class reaches that the project itself does NOT declare: the
899
- # `< StandardError` / `< Array` superclasses and the `include Comparable` mixins the two walks above
900
- # deliberately drop, gathered breadth-first over the project ancestry so an inherited edge counts too.
1475
+ # `< StandardError` / `< Array` superclasses and the `include Comparable` mixins, as the CANDIDATE LIST for
1476
+ # each as-written name ({#ancestor_name_candidates}: the nesting spellings first, the bare name last), in
1477
+ # Ruby's order along the instance chain — the order the caller that adopts the FIRST answering one (#1173)
1478
+ # depends on. Resolving a candidate means asking the RBS environment, which this frozen-index read does
1479
+ # not do; the caller takes the first candidate its own oracle knows. Where the chain does not stand, the
1480
+ # groups are the depth-first ones the walk this replaced emitted.
901
1481
  #
902
- # Each entry is the CANDIDATE LIST for one as-written name ({#ancestor_name_candidates}: the nesting
903
- # spellings first, the bare name last), not a resolved class — resolving it means asking the RBS
904
- # environment, which this frozen-index walk does not read. The caller takes the first candidate its own
905
- # oracle knows and ignores the rest, the same "most-qualified first" order the project-side resolution
906
- # uses.
907
- def external_ancestor_name_candidates(class_name, name_memo: {})
908
- groups = []
909
- queue = [class_name.to_s]
910
- seen = {}
911
- visited = 0
912
- until queue.empty?
913
- current = queue.shift
914
- next if current.nil? || seen[current]
915
-
916
- seen[current] = true
917
- visited += 1
918
- break if visited > ANCESTOR_WALK_LIMIT
919
-
920
- collect_external_ancestors(current, queue, groups, name_memo)
1482
+ # Issue #527 slice 1 — `mixins: false` answers the superclass edge only: a consumer resolving one KIND of
1483
+ # inheritance edge at a time (the dispatch arm lands `< Hash` before `include Enumerable`) narrows it, and
1484
+ # records only the superclass chain it read.
1485
+ def external_ancestor_name_candidates(class_name, name_memo: UNUSED_NAME_MEMO, mixins: true) # rubocop:disable Lint/UnusedMethodArgument
1486
+ chain = ResolutionChain.for(self, class_name.to_s, :instance, :methods)
1487
+ groups = external_groups(chain, mixins)
1488
+ if chain.settle(self, groups) { |retro| external_groups(retro, mixins) } == :master
1489
+ groups = ResolutionChain::MasterOrder.external_groups(self, class_name.to_s, mixins)
1490
+ end
1491
+ if Analysis::DependencyRecorder.active?
1492
+ mixins ? chain.record(self) : chain.level_classes.each { |name| record_class_dependency(name) if name }
921
1493
  end
1494
+ # Issue #527 — the cut is a budget event, not an answer: consumers read the groups as "the ancestors
1495
+ # this class reaches", so a short list must be visible in `--stats` beside the other walks' exhaustions.
1496
+ Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT) if chain.truncated?
922
1497
  groups
923
1498
  end
924
1499
 
925
- # One node of {#external_ancestor_name_candidates}: the project-declared ancestors continue the walk,
926
- # everything else is reported as a candidate list.
927
- def collect_external_ancestors(current, queue, groups, name_memo)
928
- raw_names = includes_of(current).dup
929
- raw_super = superclass_of(current)
930
- raw_names << raw_super if raw_super
931
- raw_names.each do |raw|
932
- resolved = resolve_ancestor_class_name(current, raw, name_memo)
933
- if resolved
934
- queue.push(resolved)
935
- else
936
- groups << ancestor_name_candidates(current, raw)
937
- end
938
- end
1500
+ def external_groups(chain, mixins)
1501
+ chain.entries.filter_map { |entry| entry.candidates if entry.external? && (mixins || entry.superclass_edge) }
939
1502
  end
940
- private :collect_external_ancestors
1503
+ private :external_groups
941
1504
 
942
- # The BFS node cap; a hierarchy past it gives up rather than walking unboundedly (ADR-41 WD4).
943
- ANCESTOR_WALK_LIMIT = 100
1505
+ # The budget {ResolutionChain::LIMIT} enforces; a hierarchy past it gives up rather than reading an answer
1506
+ # off a cut chain (ADR-41 WD4).
1507
+ ANCESTOR_WALK_LIMIT = ResolutionChain::LIMIT
944
1508
 
945
1509
  EMPTY_HEADER_NESTING = [].freeze
946
1510
  private_constant :EMPTY_HEADER_NESTING
@@ -949,13 +1513,13 @@ module Rigor
949
1513
  Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
950
1514
  [nil, nil]
951
1515
  end
1516
+ private :ancestor_walk_gave_up
952
1517
 
953
- # Issue #723 — the same ancestor walk as {#user_def_through_ancestors}, asked of the DISCOVERY table
954
- # rather than the def-node table: does the project define `method_name` on `class_name` or on any
955
- # ancestor the project itself declares? The two tables are not interchangeable —
956
- # `discovered_methods` also carries `define_method`, `attr_*` and the whole singleton side, none of
957
- # which contributes a `Prism::DefNode` — and the suppression probe shared by the `call.*` check rules
958
- # needs the broader one.
1518
+ # Issue #723 — the question {#user_def_through_ancestors} asks, asked of the DISCOVERY table rather than
1519
+ # the def-node table: does the project define `method_name` on `class_name` or on any ancestor the project
1520
+ # itself declares? The two tables are not interchangeable — `discovered_methods` also carries
1521
+ # `define_method`, `attr_*` and the whole singleton side, none of which contributes a `Prism::DefNode` —
1522
+ # and the suppression probe shared by the `call.*` check rules needs the broader one.
959
1523
  #
960
1524
  # Why it must walk at all: `Analysis::CheckRules#source_declared_method?` asked `discovered_method?`,
961
1525
  # which is keyed on the receiver's OWN name, while the typer resolves the same call through this
@@ -963,72 +1527,47 @@ module Rigor
963
1527
  # `call.undefined-method` on a method `dump_type` resolved on the same line of the same run — writing
964
1528
  # MORE RBS made the run worse, the incentive #653 removed for plugin-typed calls.
965
1529
  #
966
- # `kind: :singleton` follows the superclass chain ONLY: a class method is inherited by a subclass, but
967
- # an `include`d module contributes instance methods and its own `def self.x` is not callable on the
968
- # includer. `extend M` is not lost to that narrowing — `Inference::ScopeIndexer` folds an extend into
969
- # the extender's own singleton entries before this table is frozen.
970
- def discovered_method_through_ancestors?(class_name, method_name, kind, name_memo: {})
1530
+ # `kind: :singleton` reads the singleton chain: the class objects' own methods (an extended module's are
1531
+ # folded into its extender's) and the instance methods of the modules they extend and those modules'
1532
+ # includes. Existence is a union, so the chain's two worlds, which hold the same modules, agree on it.
1533
+ def discovered_method_through_ancestors?(class_name, method_name, kind, name_memo: UNUSED_NAME_MEMO) # rubocop:disable Lint/UnusedMethodArgument
971
1534
  return false if class_name.nil?
972
1535
 
973
- queue = [class_name.to_s]
974
- seen = {}
975
- visited = 0
976
- until queue.empty?
977
- current = queue.shift
978
- next if current.nil? || seen[current]
979
-
980
- seen[current] = true
981
- visited += 1
982
- # Budget exhaustion is uncertainty, not absence: answering "not declared" here would hand a
983
- # `call.undefined-method` a fired verdict it has no evidence for. Suppress and record the hit.
984
- if visited > ANCESTOR_WALK_LIMIT
985
- Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
986
- return true
987
- end
988
-
989
- return true if discovered_method?(current, method_name, kind)
1536
+ chain = ResolutionChain.for(self, class_name.to_s, kind == :singleton ? :singleton : :instance, :methods)
1537
+ found = chain.search(self) do |entry|
1538
+ next false if entry.external?
990
1539
 
991
- enqueue_ancestors(current, queue, name_memo, mixins: kind != :singleton)
992
- end
993
- false
994
- end
995
-
996
- # Pushes `current`'s direct ancestors onto the BFS queue: included / prepended modules first (Ruby places
997
- # mixins nearer than the superclass), then the superclass. Each as-written name is resolved against the
998
- # nesting `current`'s declaration header is written in ({#ancestor_name_candidates}); names that resolve
999
- # to no project class/module are dropped. `mixins: false` walks the superclass chain alone, for the
1000
- # singleton-side question where an `include` contributes nothing.
1001
- def enqueue_ancestors(current, queue, name_memo, mixins: true)
1002
- if mixins
1003
- includes_of(current).each do |raw|
1004
- resolved = resolve_ancestor_class_name(current, raw, name_memo)
1005
- queue.push(resolved) if resolved
1006
- end
1540
+ discovered_method?(entry.name, method_name, entry.side == :singleton ? :singleton : kind_for(kind))
1007
1541
  end
1008
- raw_super = superclass_of(current)
1009
- return if raw_super.nil?
1542
+ return true if found
1543
+ # Budget exhaustion is uncertainty, not absence: answering "not declared" here would hand a
1544
+ # `call.undefined-method` a fired verdict it has no evidence for. Suppress and record the hit.
1545
+ return false unless chain.truncated?
1010
1546
 
1011
- resolved_super = resolve_ancestor_class_name(current, raw_super, name_memo)
1012
- queue.push(resolved_super) if resolved_super
1547
+ Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
1548
+ true
1013
1549
  end
1014
1550
 
1015
- # Resolves an ancestor name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) to a project-discovered class,
1016
- # following Ruby's `Module.nesting` constant lookup from the subclass's declaration HEADER, innermost
1017
- # first, then bare. nil when no candidate names a discovered user class.
1018
- def resolve_ancestor_class_name(subclass_qualified, raw_ancestor, name_memo)
1019
- by_subclass = (name_memo[subclass_qualified] ||= {})
1020
- return by_subclass[raw_ancestor] if by_subclass.key?(raw_ancestor)
1551
+ # A module entry on the singleton chain answers with its INSTANCE methods; on the instance chain, the
1552
+ # caller's kind stands.
1553
+ def kind_for(kind) = kind == :singleton ? :instance : kind
1554
+ private :kind_for
1021
1555
 
1022
- by_subclass[raw_ancestor] = compute_ancestor_class_name(subclass_qualified, raw_ancestor)
1556
+ # Pushes `current`'s direct ancestors onto a breadth-first queue: included / prepended modules first, then
1557
+ # the superclass, each resolved against the nesting `current`'s declaration header is written in; names
1558
+ # that resolve to no project class/module are dropped. `mixins: false` gives the superclass alone. No
1559
+ # reader in the engine walks this way any more (they read {ResolutionChain}); it stays for the plugin
1560
+ # surface, with the ADR-46 class edge the walk it served always filed.
1561
+ def enqueue_ancestors(current, queue, name_memo, mixins: true) # rubocop:disable Lint/UnusedMethodArgument
1562
+ record_class_dependency(current) if Analysis::DependencyRecorder.active?
1563
+ queue.concat(ResolutionChain.direct_ancestors(self, current.to_s, mixins))
1023
1564
  end
1024
- private :ancestor_walk_gave_up, :resolve_ancestor_class_name
1025
1565
 
1026
1566
  # Issue #682 — the candidate names an ancestor spelled `raw_ancestor` can denote in `subclass_qualified`,
1027
1567
  # in Ruby's lookup order, most-qualified first and the bare name last. The single owner of the question:
1028
- # three walks resolved a superclass / include name apiece ({#enqueue_ancestors} for method lookup,
1029
- # `Reflection.resolve_ancestor_name` for the constant ladder's ancestor rung, and
1030
- # `Analysis::CheckRules`' override-visibility rule), and each derived the order by PEELING the
1031
- # subclass's qualified name one `::` segment at a time.
1568
+ # three walks resolved a superclass / include name apiece (the method walk, the constant ladder's
1569
+ # ancestor rung, and `Analysis::CheckRules`' override-visibility rule — all three now {ResolutionChain}),
1570
+ # and each derived the order by PEELING the subclass's qualified name one `::` segment at a time.
1032
1571
  #
1033
1572
  # That peel is the NESTED spelling's answer, given to both spellings. Ruby evaluates a superclass
1034
1573
  # expression before entering the body, so the cref that governs it is the one the declaration's HEADER
@@ -1040,9 +1579,9 @@ module Rigor
1040
1579
  # The header nesting is a property of the DECLARATION, not of the reader, so it is read from the
1041
1580
  # discovery table `Inference::ScopeIndexer` records it in rather than from this scope's own
1042
1581
  # `#lexical_nesting`. Answering off the reader's chain would be wrong past the first hop of the ancestor
1043
- # BFS (the chain describes the reader's class, not the ancestor being resolved) and would silently
1044
- # corrupt the run-scoped ancestor memos in `Inference::ExpressionTyper#class_graph_buckets` and
1045
- # `Reflection.ancestor_constant_scopes`, both of which key on the class name alone because this walk is
1582
+ # chain (the chain describes the reader's class, not the ancestor being resolved) and would silently
1583
+ # corrupt the {ResolutionChain} memo and the ones layered on it (`Inference::ExpressionTyper#class_graph_buckets`,
1584
+ # `Reflection.ancestor_constant_scopes`), all of which key on the class name alone because this walk is
1046
1585
  # a pure function of the frozen discovery tables.
1047
1586
  #
1048
1587
  # The peel survives as the FALLBACK, for a class no declaration walk recorded — a scope seeded without
@@ -1062,9 +1601,70 @@ module Rigor
1062
1601
 
1063
1602
  recorded = @discovery.discovered_header_nestings[subclass_qualified.to_s]
1064
1603
  entries = recorded ? recorded_header_nesting(recorded, raw) : peeled_header_nesting(subclass_qualified)
1604
+ if !entries.empty? && DiscoveryIndex.ambiguous_header_nesting?(entries)
1605
+ return ambiguous_ancestor_candidates(entries, raw_ancestor)
1606
+ end
1607
+
1608
+ entries.map { |entry| "#{entry}::#{raw_ancestor}" } << raw_ancestor.to_s
1609
+ end
1610
+
1611
+ # Issue #986 — the candidate list for a raw name two declaration sites of ONE class wrote in different
1612
+ # crefs (the compact-header rename pass landed both on this key). Each alternative chain gets its own
1613
+ # candidate list, in the order this walk would have used for that site alone.
1614
+ #
1615
+ # When two of them resolve to two DIFFERENT project classes there is no candidate list to return: at
1616
+ # runtime both `include`s run, and which of the two same-named modules ends up nearer in the MRO is the
1617
+ # load order of the two files, which this walk cannot know. Answering with either is a WRONG ANCESTOR,
1618
+ # and a wrong ancestor is a false-positive source rather than a missed one — two same-named modules can
1619
+ # declare the same method at different arities, and `call.wrong-arity` then fires on a correct program.
1620
+ # The empty list declines instead: the name resolves to no project class, the receiver's methods stay
1621
+ # `Dynamic`, and every rule reading this walk goes quiet.
1622
+ #
1623
+ # Where the alternatives AGREE, or only one of them resolves at all, there is nothing to adjudicate: the
1624
+ # answer is the union of their chains, most-qualified first — the same list a class whose sites needed
1625
+ # no rename gets — so an unambiguous collision is unchanged, and an EXTERNAL ancestor keeps the full
1626
+ # candidate list `#external_ancestor_name_candidates` hands the gem / RBS probe.
1627
+ def ambiguous_ancestor_candidates(alternatives, raw_ancestor)
1628
+ return [] if ambiguous_ancestor_resolutions_of(alternatives, raw_ancestor).size > 1
1629
+
1630
+ entries = alternatives.reduce([], :|).sort_by { |entry| [-entry.split("::").size, entry] }
1065
1631
  entries.map { |entry| "#{entry}::#{raw_ancestor}" } << raw_ancestor.to_s
1066
1632
  end
1067
1633
 
1634
+ def ambiguous_ancestor_resolutions_of(alternatives, raw_ancestor)
1635
+ alternatives.filter_map do |chain|
1636
+ (chain.map { |entry| "#{entry}::#{raw_ancestor}" } << raw_ancestor.to_s)
1637
+ .find { |candidate| known_user_class?(candidate) }
1638
+ end.uniq
1639
+ end
1640
+ private :ambiguous_ancestor_candidates, :ambiguous_ancestor_resolutions_of
1641
+
1642
+ # Issue #986 — the several project classes an ancestor name resolves to when the compact-header rename
1643
+ # collision left it ambiguous, and `EMPTY_HEADER_NESTING` for every other name. {#ancestor_name_candidates}
1644
+ # declines such a name because no ONE class is its answer; a caller that also knows which METHOD it is
1645
+ # looking up can do better than that decline, and a rule that reports on a method must, because the
1646
+ # decline otherwise silences it for the whole receiver — its own `def`s and its unambiguous ancestors
1647
+ # included.
1648
+ #
1649
+ # Both of these classes are ancestors at runtime: both `include`s run, and only their MRO ORDER is the
1650
+ # load order this walk cannot see. So a method only one of them declares is answered by that one
1651
+ # whatever the order, and only a method they BOTH declare is unanswerable.
1652
+ # `Analysis::CheckRules::SourceArity` reads this for exactly that: it takes both as mixin levels and
1653
+ # declines on the disagreement its own envelope join already knows how to spot.
1654
+ def ambiguous_ancestor_resolutions(subclass_qualified, raw_ancestor)
1655
+ raw = raw_ancestor.to_s
1656
+ return EMPTY_HEADER_NESTING if raw.start_with?("::")
1657
+
1658
+ recorded = @discovery.discovered_header_nestings[subclass_qualified.to_s]
1659
+ return EMPTY_HEADER_NESTING if recorded.nil?
1660
+
1661
+ entries = recorded_header_nesting(recorded, raw)
1662
+ return EMPTY_HEADER_NESTING if entries.empty? || !DiscoveryIndex.ambiguous_header_nesting?(entries)
1663
+
1664
+ resolved = ambiguous_ancestor_resolutions_of(entries, raw_ancestor)
1665
+ resolved.size > 1 ? resolved : EMPTY_HEADER_NESTING
1666
+ end
1667
+
1068
1668
  # Issue #728 — the chain of the declaration site that WROTE `raw`, which is the cref Ruby resolves that
1069
1669
  # one name in. A class's sites need not agree: `class Foo < Base` at the top level and a rooted
1070
1670
  # `class ::Foo; include Helper; end` inside `module Outer` are two sites of `Foo`, and the union of
@@ -1088,10 +1688,6 @@ module Rigor
1088
1688
  end
1089
1689
  private :peeled_header_nesting
1090
1690
 
1091
- def compute_ancestor_class_name(subclass_qualified, raw_ancestor)
1092
- ancestor_name_candidates(subclass_qualified, raw_ancestor).find { |c| known_user_class?(c) }
1093
- end
1094
-
1095
1691
  # Issue #723 — `discovered_methods` is in the list because the other three miss a class whose only
1096
1692
  # project-side content is CLASS methods: `class Base; def self.build = :built; end` records no instance
1097
1693
  # def node, no superclass and no include, so the ancestor-name resolver did not recognise `Base` as a
@@ -1106,13 +1702,13 @@ module Rigor
1106
1702
  # narrower predicate (`discovered_classes` alone) would read a project class as having left the project
1107
1703
  # and attribute its root name to a same-named locked gem — a WRONG provenance label, which is the one
1108
1704
  # direction ADR-82 forbids.
1109
- private :ancestor_walk_gave_up, :compute_ancestor_class_name
1110
1705
 
1111
1706
  # Records, for a resolved cross-class ancestry read, every file that declares `class_name` (its declaration /
1112
- # reopening / superclass / include sites). The `discovered_class_sources` table it reads is populated only by
1113
- # the cross-file project pre-pass ({Inference::ScopeIndexer.discovered_def_index_for_paths}) and only when
1114
- # dependency recording is active. No-op when the class is not a project class (core / stdlib / gem names
1115
- # never appear in the source map). Gated by the caller on the recorder being active.
1707
+ # reopening / superclass / include sites). The `discovered_class_sources` table it reads is populated by the
1708
+ # cross-file project pre-pass ({Inference::ScopeIndexer.discovered_def_index_for_paths}); a scope built
1709
+ # without one holds none, and the read then files nothing. No-op when the class is not a project class
1710
+ # (core / stdlib / gem names never appear in the source map). Gated by the caller on the recorder being
1711
+ # active.
1116
1712
  def record_class_dependency(class_name)
1117
1713
  sites = @discovery.discovered_class_sources[class_name.to_s]
1118
1714
  return if sites.nil?
@@ -1132,8 +1728,7 @@ module Rigor
1132
1728
  # so a bare referent is re-checked when the class appears or disappears and at no other time. A file edge
1133
1729
  # would have re-checked every bare referent of every class the file declares on any edit to it.
1134
1730
  def record_class_existence(class_name)
1135
- segment = class_name.to_s.delete_prefix("::").split("::").last
1136
- Analysis::DependencyRecorder.read_name(:class, segment) if segment
1731
+ Analysis::DependencyRecorder.read_last_segment(:class, class_name.to_s)
1137
1732
  end
1138
1733
 
1139
1734
  # Issue #644 — the positive ADR-46 edge for a cross-file VALUE constant. `Reflection.constant_type_at`
@@ -1152,6 +1747,17 @@ module Rigor
1152
1747
  sites.each { |site| Analysis::DependencyRecorder.read_site(site) }
1153
1748
  end
1154
1749
 
1750
+ # Issue #992 — one class's `discovered_parameter_envelopes` bucket (`{[kind, method_name] => envelope}` plus
1751
+ # the class-wide marks), or an empty Hash. Records the ADR-46 class edge first: a verdict read off the
1752
+ # bucket depends on every file that declares the class, and a `memoize :f` added to one of them moves it.
1753
+ def parameter_envelopes_of(class_name)
1754
+ record_class_dependency(class_name) if Analysis::DependencyRecorder.active?
1755
+ @discovery.discovered_parameter_envelopes[class_name.to_s] || EMPTY_PARAMETER_ENVELOPES
1756
+ end
1757
+
1758
+ EMPTY_PARAMETER_ENVELOPES = {}.freeze
1759
+ private_constant :EMPTY_PARAMETER_ENVELOPES
1760
+
1155
1761
  # v0.1.2 — per-class table mapping `method_name (Symbol) → :public | :private | :protected`. Populated by
1156
1762
  # `ScopeIndexer` for every `def` it sees inside a class body, with the visibility taken from the surrounding
1157
1763
  # `private` / `protected` / `public` modifier state plus any post-hoc `private :name, ...` named-argument
@@ -1272,10 +1878,37 @@ module Rigor
1272
1878
  joined_locals = join_bindings(locals, other.locals)
1273
1879
  joined_ivars = join_bindings(ivars, other.ivars)
1274
1880
  joined_cvars = join_bindings(cvars, other.cvars)
1275
- joined_globals = join_bindings(globals, other.globals)
1881
+ joined_globals = unbind_split_last_line(join_bindings(globals, other.globals), other)
1276
1882
  build_joined_scope(joined_locals, joined_ivars, joined_cvars, joined_globals, other)
1277
1883
  end
1278
1884
 
1885
+ # Issue #1429 — a join keeps a guard record while the joined scope still narrows its name: a global both arms
1886
+ # bind, a constant both arms narrow (one an arm does not narrow reads its resolved type after the join, so its
1887
+ # narrowing is gone), and an instance variable both arms bind (#1446). An arm without a record contributes nothing
1888
+ # to the restore target, whose union with the joined binding {#forget_guard_narrowings} takes, so it still covers
1889
+ # that arm's binding.
1890
+ # `joined` maps each record kind to the joined scope's table of that kind.
1891
+ def join_guard_records(other, joined)
1892
+ mine = @guard_records
1893
+ theirs = other.guard_records
1894
+ return EMPTY_GUARD_RECORDS if mine.empty? && theirs.empty?
1895
+
1896
+ merged = mine.merge(theirs) { |_key, left, right| Type::Combinator.union(left, right) }
1897
+ kept = merged.select { |(kind, name), _| joined.fetch(kind).key?(name) }
1898
+ kept.empty? ? EMPTY_GUARD_RECORDS : kept.freeze
1899
+ end
1900
+ private :join_guard_records
1901
+
1902
+ # Issue #1359 — arms that bind `$_` apart join with it unbound rather than to their union. The arms of a reader
1903
+ # condition bind `String` and `nil`, and `String?` after `if gets … end` would report correct code that proves the
1904
+ # line some other way (`ok = gets ? true : false; return unless ok; line = $_; line.chomp`).
1905
+ def unbind_split_last_line(joined, other)
1906
+ return joined unless joined.key?(LAST_LINE) && @globals[LAST_LINE] != other.globals[LAST_LINE]
1907
+
1908
+ joined.except(LAST_LINE).freeze
1909
+ end
1910
+ private :unbind_split_last_line
1911
+
1279
1912
  def ==(other)
1280
1913
  other.is_a?(Scope) &&
1281
1914
  environment.equal?(other.environment) &&
@@ -1287,8 +1920,7 @@ module Rigor
1287
1920
  @globals == other.globals &&
1288
1921
  @indexed_narrowings == other.indexed_narrowings &&
1289
1922
  @method_chain_narrowings == other.method_chain_narrowings &&
1290
- @declaration_sourced == other.declaration_sourced &&
1291
- @published_constant_sourced == other.published_constant_sourced
1923
+ same_marks?(other)
1292
1924
  end
1293
1925
  alias eql? ==
1294
1926
 
@@ -1298,6 +1930,19 @@ module Rigor
1298
1930
 
1299
1931
  private
1300
1932
 
1933
+ # The marks {#==} compares: ADR-58's, issue #667's and the repeated `||=` sites, and #1429's guard state.
1934
+ def same_marks?(other)
1935
+ @declaration_sourced == other.declaration_sourced &&
1936
+ @published_constant_sourced == other.published_constant_sourced &&
1937
+ @repeated_or_writes == other.repeated_or_writes &&
1938
+ same_guard_state?(other)
1939
+ end
1940
+
1941
+ def same_guard_state?(other)
1942
+ @constant_narrowings == other.constant_narrowings && @guard_records == other.guard_records &&
1943
+ @bot_guard_classes == other.bot_guard_classes
1944
+ end
1945
+
1301
1946
  def rebuild(
1302
1947
  locals: @locals, fact_store: @fact_store, self_type: @self_type,
1303
1948
  ivars: @ivars, cvars: @cvars, globals: @globals,
@@ -1309,6 +1954,7 @@ module Rigor
1309
1954
  source_path: @source_path,
1310
1955
  struct_fold_safe_locals: @struct_fold_safe_locals,
1311
1956
  opaque_block_self: @opaque_block_self,
1957
+ singleton_class_body: @singleton_class_body,
1312
1958
  lexical_nesting: @lexical_nesting,
1313
1959
  dynamic_origins: @dynamic_origins,
1314
1960
  local_origins: @local_origins,
@@ -1317,7 +1963,12 @@ module Rigor
1317
1963
  plugin_typed_calls: @plugin_typed_calls,
1318
1964
  optimistic_origins: @optimistic_origins,
1319
1965
  optimistic_locals: @optimistic_locals,
1320
- optimistic_ivars: @optimistic_ivars
1966
+ optimistic_ivars: @optimistic_ivars,
1967
+ repeated_or_writes: @repeated_or_writes,
1968
+ match_frame: @match_frame,
1969
+ constant_narrowings: @constant_narrowings,
1970
+ guard_records: @guard_records,
1971
+ bot_guard_classes: @bot_guard_classes
1321
1972
  )
1322
1973
  self.class.new(
1323
1974
  environment: environment, locals: locals,
@@ -1331,6 +1982,7 @@ module Rigor
1331
1982
  source_path: source_path,
1332
1983
  struct_fold_safe_locals: struct_fold_safe_locals,
1333
1984
  opaque_block_self: opaque_block_self,
1985
+ singleton_class_body: singleton_class_body,
1334
1986
  lexical_nesting: lexical_nesting,
1335
1987
  dynamic_origins: dynamic_origins,
1336
1988
  local_origins: local_origins,
@@ -1339,7 +1991,12 @@ module Rigor
1339
1991
  plugin_typed_calls: plugin_typed_calls,
1340
1992
  optimistic_origins: optimistic_origins,
1341
1993
  optimistic_locals: optimistic_locals,
1342
- optimistic_ivars: optimistic_ivars
1994
+ optimistic_ivars: optimistic_ivars,
1995
+ repeated_or_writes: repeated_or_writes,
1996
+ match_frame: match_frame,
1997
+ constant_narrowings: constant_narrowings,
1998
+ guard_records: guard_records,
1999
+ bot_guard_classes: bot_guard_classes
1343
2000
  )
1344
2001
  end
1345
2002
 
@@ -1359,7 +2016,7 @@ module Rigor
1359
2016
 
1360
2017
  def build_joined_scope(joined_locals, joined_ivars, joined_cvars, joined_globals, other)
1361
2018
  self.class.new(
1362
- environment: environment,
2019
+ environment: @environment,
1363
2020
  locals: joined_locals.freeze,
1364
2021
  fact_store: fact_store.join(other.fact_store),
1365
2022
  self_type: self_type == other.self_type ? self_type : nil,
@@ -1379,7 +2036,7 @@ module Rigor
1379
2036
  # end; m == :x` must not warn because one arm's copy is invisible to the reader's author. ADR-67
1380
2037
  # WD6b's `:inferred_param` taint takes the same direction for the same reason.
1381
2038
  published_constant_sourced: join_published_constant_sourced(other),
1382
- source_path: source_path,
2039
+ source_path: @source_path,
1383
2040
  # Issue #589 — the fold-safe set MUST survive a merge. It was simply absent from this constructor
1384
2041
  # call, so it fell back to the empty default and every `if` / `while` in a method silently revoked
1385
2042
  # struct member folding for the whole body after it: `s = S.new("r"); while c; i += 1; end; s.raw`
@@ -1405,6 +2062,10 @@ module Rigor
1405
2062
  # block entry by `entering_opaque_block` and inherited through `rebuild`), so both arms usually carry
1406
2063
  # the same value and the `||` is that value.
1407
2064
  opaque_block_self: @opaque_block_self || other.opaque_block_self,
2065
+ # Issue #963 — a body property like the two above, so both arms of an in-body merge carry the identical
2066
+ # value and the `||` is that value. `||` is also the safe direction on its own terms: keeping the mark
2067
+ # declines the `define_method` narrowing, which is the pre-#963 answer.
2068
+ singleton_class_body: @singleton_class_body || other.singleton_class_body,
1408
2069
  # Issue #652 — the recorded `Module.nesting`, stamped once at body entry and threaded by `rebuild`
1409
2070
  # exactly as the fold-safe set is. A join is a control-flow merge INSIDE one body, so both arms
1410
2071
  # always carry the identical chain; taking this scope's is that chain. Dropping it would silently
@@ -1416,11 +2077,55 @@ module Rigor
1416
2077
  void_origins: @void_origins,
1417
2078
  plugin_typed_calls: @plugin_typed_calls,
1418
2079
  optimistic_origins: @optimistic_origins,
1419
- optimistic_locals: join_origins(@optimistic_locals, other.optimistic_locals),
1420
- optimistic_ivars: join_origins(@optimistic_ivars, other.optimistic_ivars)
2080
+ optimistic_locals: join_optimistic_marks(@optimistic_locals, other.optimistic_locals),
2081
+ optimistic_ivars: join_optimistic_marks(@optimistic_ivars, other.optimistic_ivars),
2082
+ # UNION, the published-constant mark's direction: the mark only withholds the memoizing `||=`
2083
+ # reading, so keeping a site either arm holds is the wider answer.
2084
+ repeated_or_writes: join_repeated_or_writes(other),
2085
+ # Issue #1358 — the frame the body runs in, stamped at its entry like the nesting above, so both arms
2086
+ # of a merge inside one body carry the same one; `||` keeps it should either arm lack it, since dropping
2087
+ # it only loses the frame's resets.
2088
+ match_frame: @match_frame || other.match_frame,
2089
+ # Issue #1429 — the guard narrowings of globals and constants and their pre-guard records, and of instance
2090
+ # variables (#1446).
2091
+ **join_guard_narrowings(other, joined_globals, joined_ivars)
1421
2092
  )
1422
2093
  end
1423
2094
 
2095
+ def join_guard_narrowings(other, joined_globals, joined_ivars)
2096
+ joined_constants = join_constant_narrowings(other)
2097
+ joined = { global: joined_globals, ivar: joined_ivars, constant: joined_constants }
2098
+ { constant_narrowings: joined_constants, guard_records: join_guard_records(other, joined),
2099
+ bot_guard_classes: join_bot_guard_classes(other) }
2100
+ end
2101
+
2102
+ # Issue #1446 — an entry both arms hold, with the classes of either: the joined receiver reads `bot` only where
2103
+ # both arms do. One only an arm holds is dropped, so a call on the joined `bot` reads as on an unknown one.
2104
+ def join_bot_guard_classes(other)
2105
+ mine = @bot_guard_classes
2106
+ theirs = other.bot_guard_classes
2107
+ return mine if mine.equal?(theirs)
2108
+ return EMPTY_BOT_GUARD_CLASSES if mine.empty? || theirs.empty?
2109
+
2110
+ joined = mine.each_with_object({}) do |(key, names), acc|
2111
+ other_names = theirs[key]
2112
+ acc[key] = (names | other_names).freeze if other_names
2113
+ end
2114
+ joined.empty? ? EMPTY_BOT_GUARD_CLASSES : joined.freeze
2115
+ end
2116
+
2117
+ # Issue #1429 — a constant reference both arms narrow reads the union; one only an arm narrows reads its resolved
2118
+ # type again, which the other arm reads too.
2119
+ def join_constant_narrowings(other)
2120
+ mine = @constant_narrowings
2121
+ theirs = other.constant_narrowings
2122
+ return mine if mine.equal?(theirs)
2123
+ return EMPTY_CONSTANT_NARROWINGS if mine.empty? || theirs.empty?
2124
+
2125
+ joined = join_bindings(mine, theirs)
2126
+ joined.empty? ? EMPTY_CONSTANT_NARROWINGS : joined
2127
+ end
2128
+
1424
2129
  # Issue #589 — intersect the struct fold-safe grants. Zero-alloc on the common path, where both arms
1425
2130
  # carry the same frozen set the body scan stamped.
1426
2131
  # A `nil` on EITHER side yields no grants. The field is keyword-defaulted to `EMPTY_FOLD_SAFE` and
@@ -1448,6 +2153,17 @@ module Rigor
1448
2153
  theirs.merge(mine).freeze
1449
2154
  end
1450
2155
 
2156
+ # Issue #286's mark tables join as {#join_origins} does, except for the miss answer a mark may carry (issue
2157
+ # #1302): a name both arms mark keeps its answer only when the arms agree on it. A name one arm marks keeps
2158
+ # that arm's answer, since a miss runs through that arm only.
2159
+ def join_optimistic_marks(mine, theirs)
2160
+ return mine if mine.equal?(theirs) || theirs.empty?
2161
+ return theirs if mine.empty?
2162
+
2163
+ theirs.merge(mine) { |_name, their_mark, my_mark| Inference::OptimisticOrigin.join_bound_marks(my_mark, their_mark) }
2164
+ .freeze
2165
+ end
2166
+
1451
2167
  # ADR-82 WD1 — rebinding drops any propagated origin for the name (the new value's provenance is set
1452
2168
  # afterward by `with_local_origin` when it is a `Dynamic` with a recorded cause). Zero-alloc when the name
1453
2169
  # carries no origin — the overwhelming common case, so this stays off the with_local hot-path budget.
@@ -1464,6 +2180,11 @@ module Rigor
1464
2180
  # value on the branch where it derives from an inferred parameter, so if EITHER branch taints it, a
1465
2181
  # downstream use could observe the lower-bound value and firing on it is an FP (`x = param else x = 5;
1466
2182
  # x.foo`). Intersecting would lose the taint at the merge and re-surface the false positive.
2183
+ #
2184
+ # Issue #1362 — a local both branches mark as copies of global seeds keeps its mark with the union of the globals
2185
+ # the two record, so a consumer compares against the writes to every one of them. A `(:local, name)` mark only
2186
+ # one branch backs with a record is dropped with it: that branch copies a global and the other an ivar, so no
2187
+ # comparison answers for the merge and it is flow-live.
1467
2188
  def join_declaration_sourced(other)
1468
2189
  mine = @declaration_sourced
1469
2190
  theirs = other.declaration_sourced
@@ -1476,12 +2197,33 @@ module Rigor
1476
2197
  if mine.empty? || theirs.empty?
1477
2198
  EMPTY_DECLARATION_SOURCED
1478
2199
  else
1479
- mine.select { |ref| ref[0] != :inferred_param && theirs.include?(ref) }
2200
+ join_copy_records(mine.select { |ref| ref[0] != :inferred_param && theirs.include?(ref) }, mine, theirs)
1480
2201
  end
1481
2202
  merged = inferred.merge(intersected)
1482
2203
  merged.empty? ? EMPTY_DECLARATION_SOURCED : merged.freeze
1483
2204
  end
1484
2205
 
2206
+ # `kept`, the refs both branches carry, with the `[:global_copy, …]` records rejoined ({#join_declaration_sourced}).
2207
+ def join_copy_records(kept, mine, theirs)
2208
+ mine_copies = global_copy_records(mine)
2209
+ theirs_copies = global_copy_records(theirs)
2210
+ return kept if mine_copies.empty? && theirs_copies.empty?
2211
+
2212
+ joined = kept.reject { |ref| ref[0] == :global_copy }
2213
+ (mine_copies.keys | theirs_copies.keys).each do |name|
2214
+ if mine_copies.key?(name) && theirs_copies.key?(name) && joined.include?([:local, name])
2215
+ joined.concat(mine_copies[name] | theirs_copies[name])
2216
+ else
2217
+ joined.delete([:local, name])
2218
+ end
2219
+ end
2220
+ joined
2221
+ end
2222
+
2223
+ def global_copy_records(refs)
2224
+ refs.each_with_object({}) { |ref, acc| (acc[ref[1]] ||= []) << ref if ref[0] == :global_copy }
2225
+ end
2226
+
1485
2227
  def indexed_key(receiver_kind, receiver_name, key)
1486
2228
  IndexedKey.new(
1487
2229
  receiver_kind: receiver_kind.to_sym,
@@ -1538,6 +2280,15 @@ module Rigor
1538
2280
  (mine | theirs).freeze
1539
2281
  end
1540
2282
 
2283
+ def join_repeated_or_writes(other)
2284
+ mine = @repeated_or_writes
2285
+ theirs = other.repeated_or_writes
2286
+ return mine if mine.equal?(theirs) || theirs.empty?
2287
+ return theirs if mine.empty?
2288
+
2289
+ mine.merge(theirs).freeze
2290
+ end
2291
+
1541
2292
  def add_declaration_sourced(kind, name)
1542
2293
  ref = [kind.to_sym, name.to_sym]
1543
2294
  return @declaration_sourced if @declaration_sourced.include?(ref)
@@ -1556,6 +2307,38 @@ module Rigor
1556
2307
  dropped.freeze
1557
2308
  end
1558
2309
 
2310
+ # The `(:local, name)` mark and, when it was there, the `[:global_copy, name, global]` records that only ever sit
2311
+ # beside it ({#with_global_copy_marks}).
2312
+ def drop_local_declaration_marks(name)
2313
+ dropped = drop_declaration_sourced_for(:local, name)
2314
+ return dropped if dropped.equal?(@declaration_sourced)
2315
+
2316
+ name = name.to_sym
2317
+ return dropped unless dropped.any? { |ref| ref[0] == :global_copy && ref[1] == name }
2318
+
2319
+ dropped.dup.delete_if { |ref| ref[0] == :global_copy && ref[1] == name }.freeze
2320
+ end
2321
+
2322
+ def add_guard_record(key, pre_guard)
2323
+ return @guard_records if @guard_records.key?(key)
2324
+
2325
+ @guard_records.merge(key => pre_guard).freeze
2326
+ end
2327
+
2328
+ def drop_bot_guard_class(kind, name)
2329
+ return @bot_guard_classes if @bot_guard_classes.empty?
2330
+
2331
+ key = [kind, kind == :constant ? name : name.to_sym]
2332
+ @bot_guard_classes.key?(key) ? @bot_guard_classes.except(key).freeze : @bot_guard_classes
2333
+ end
2334
+
2335
+ def drop_guard_record(kind, name)
2336
+ return @guard_records if @guard_records.empty?
2337
+
2338
+ key = [kind, name]
2339
+ @guard_records.key?(key) ? @guard_records.except(key).freeze : @guard_records
2340
+ end
2341
+
1559
2342
  def drop_chain_narrowings_for(receiver_kind, receiver_name)
1560
2343
  return @method_chain_narrowings if @method_chain_narrowings.empty?
1561
2344