rigortype 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/enumerable.rbs +51 -0
  4. data/data/core_overlay/enumerator.rbs +84 -0
  5. data/data/core_overlay/hash_rbs3.rbs +41 -0
  6. data/data/core_overlay/process.rbs +40 -0
  7. data/data/core_overlay/string_io.rbs +33 -0
  8. data/data/effects/core.yml +3 -3
  9. data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
  10. data/docs/handbook/03-narrowing.md +95 -10
  11. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  12. data/docs/handbook/07-rbs-and-extended.md +16 -11
  13. data/docs/handbook/10-sorbet.md +9 -10
  14. data/docs/handbook/11-sig-gen.md +454 -13
  15. data/docs/manual/02-cli-reference.md +63 -2
  16. data/docs/manual/03-configuration.md +7 -0
  17. data/docs/manual/04-diagnostics.md +4 -0
  18. data/docs/manual/07-plugins.md +1 -1
  19. data/docs/manual/10-mcp-server.md +3 -2
  20. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  21. data/docs/manual/19-effect-labels.md +10 -0
  22. data/docs/manual/plugins/README.md +7 -0
  23. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  24. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  25. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  26. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  27. data/docs/manual/plugins/rigor-activejob.md +7 -0
  28. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  29. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  30. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  31. data/docs/manual/plugins/rigor-grape.md +106 -0
  32. data/docs/manual/plugins/rigor-graphql.md +23 -2
  33. data/docs/manual/plugins/rigor-pundit.md +8 -1
  34. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  35. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  36. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  37. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  38. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  39. data/exe/rigor +19 -4
  40. data/lib/rigor/analysis/baseline.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  42. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  43. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  49. data/lib/rigor/analysis/check_rules.rb +520 -49
  50. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  51. data/lib/rigor/analysis/fact_store.rb +9 -0
  52. data/lib/rigor/analysis/incremental.rb +26 -0
  53. data/lib/rigor/analysis/incremental_session.rb +36 -4
  54. data/lib/rigor/analysis/project_scan.rb +11 -1
  55. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  56. data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
  57. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  58. data/lib/rigor/analysis/run_cache_key.rb +20 -11
  59. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
  60. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  61. data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
  62. data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
  63. data/lib/rigor/analysis/runner.rb +278 -20
  64. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  65. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  66. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  67. data/lib/rigor/analysis/template_units.rb +399 -0
  68. data/lib/rigor/analysis/worker_session.rb +53 -10
  69. data/lib/rigor/bleeding_edge.rb +0 -2
  70. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  71. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  72. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  73. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  74. data/lib/rigor/cache/descriptor.rb +6 -1
  75. data/lib/rigor/cache/engine_source.rb +29 -1
  76. data/lib/rigor/cache/incremental_snapshot.rb +33 -1
  77. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  78. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  79. data/lib/rigor/cache/store.rb +0 -1
  80. data/lib/rigor/ci_detector.rb +1 -0
  81. data/lib/rigor/cli/doc_links.rb +1 -1
  82. data/lib/rigor/cli/docs_command.rb +4 -4
  83. data/lib/rigor/cli/plugin_command.rb +3 -3
  84. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  85. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  86. data/lib/rigor/cli/skill_command.rb +1 -1
  87. data/lib/rigor/cli/skill_describe.rb +0 -1
  88. data/lib/rigor/cli/type_of_command.rb +19 -5
  89. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  90. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  91. data/lib/rigor/cli.rb +21 -3
  92. data/lib/rigor/configuration/severity_profile.rb +19 -3
  93. data/lib/rigor/configuration.rb +66 -3
  94. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  95. data/lib/rigor/effects/attribution.rb +11 -2
  96. data/lib/rigor/effects/callee_rule.rb +368 -0
  97. data/lib/rigor/effects/catalog.rb +7 -4
  98. data/lib/rigor/effects/collector.rb +11 -5
  99. data/lib/rigor/effects/config_envelopes.rb +9 -2
  100. data/lib/rigor/effects/definition_context.rb +179 -0
  101. data/lib/rigor/effects/effect_table.rb +11 -3
  102. data/lib/rigor/effects/envelope_check.rb +1 -1
  103. data/lib/rigor/effects/envelope_index.rb +15 -0
  104. data/lib/rigor/effects/file_collection.rb +59 -5
  105. data/lib/rigor/effects/framework_units.rb +1 -1
  106. data/lib/rigor/effects/identity.rb +16 -0
  107. data/lib/rigor/effects/local_ownership.rb +38 -12
  108. data/lib/rigor/effects/method_key.rb +21 -0
  109. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  110. data/lib/rigor/effects/plugin_facts.rb +43 -31
  111. data/lib/rigor/effects/propagator.rb +295 -16
  112. data/lib/rigor/effects/registry.rb +1 -1
  113. data/lib/rigor/effects/scanner.rb +121 -72
  114. data/lib/rigor/effects/signature_sources.rb +1 -1
  115. data/lib/rigor/effects/snapshot.rb +2 -1
  116. data/lib/rigor/effects/summary.rb +27 -4
  117. data/lib/rigor/effects/unit_scan.rb +385 -30
  118. data/lib/rigor/effects/visibility.rb +101 -0
  119. data/lib/rigor/environment/lockfile_resolver.rb +17 -0
  120. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  121. data/lib/rigor/environment/member_consistency.rb +298 -0
  122. data/lib/rigor/environment/rbs_loader.rb +399 -133
  123. data/lib/rigor/environment.rb +103 -38
  124. data/lib/rigor/hashing/xxh3.rb +264 -0
  125. data/lib/rigor/inference/acceptance.rb +139 -9
  126. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  127. data/lib/rigor/inference/block_call_timing.rb +338 -0
  128. data/lib/rigor/inference/block_parameter_binder.rb +73 -27
  129. data/lib/rigor/inference/block_repetition.rb +71 -0
  130. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  131. data/lib/rigor/inference/budget_trace.rb +2 -1
  132. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  133. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  134. data/lib/rigor/inference/captured_locals.rb +387 -15
  135. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  136. data/lib/rigor/inference/content_join.rb +200 -27
  137. data/lib/rigor/inference/def_return_typer.rb +11 -7
  138. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  139. data/lib/rigor/inference/element_read_widening.rb +22 -10
  140. data/lib/rigor/inference/error_info.rb +196 -0
  141. data/lib/rigor/inference/expression_typer.rb +1532 -496
  142. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  143. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  144. data/lib/rigor/inference/global_write_census.rb +239 -0
  145. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  146. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  147. data/lib/rigor/inference/index_write_widening.rb +16 -3
  148. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  149. data/lib/rigor/inference/jump_targets.rb +82 -0
  150. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  151. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  152. data/lib/rigor/inference/last_line.rb +340 -0
  153. data/lib/rigor/inference/last_status.rb +144 -0
  154. data/lib/rigor/inference/macro_block_self_type.rb +167 -17
  155. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  156. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  157. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  158. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  159. data/lib/rigor/inference/match_rebinding.rb +392 -0
  160. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  161. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  162. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  163. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  164. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  165. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  166. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  167. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  168. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  169. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
  170. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  171. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  172. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  173. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  174. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  175. data/lib/rigor/inference/multi_target_binder.rb +340 -52
  176. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  177. data/lib/rigor/inference/mutation_widening.rb +70 -48
  178. data/lib/rigor/inference/narrowing.rb +540 -120
  179. data/lib/rigor/inference/operand_effects.rb +167 -0
  180. data/lib/rigor/inference/operand_walk.rb +88 -0
  181. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  182. data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
  183. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  184. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  185. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  186. data/lib/rigor/inference/receiver_alias.rb +90 -1
  187. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  188. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  189. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  190. data/lib/rigor/inference/return_barrier.rb +54 -0
  191. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  192. data/lib/rigor/inference/scope_indexer.rb +4615 -551
  193. data/lib/rigor/inference/statement_evaluator.rb +3176 -490
  194. data/lib/rigor/inference/stored_block_call.rb +54 -0
  195. data/lib/rigor/inference/string_mutation.rb +44 -7
  196. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  197. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  198. data/lib/rigor/language_server/debouncer.rb +0 -1
  199. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  200. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  201. data/lib/rigor/language_server/project_context.rb +5 -3
  202. data/lib/rigor/mcp/server.rb +2 -1
  203. data/lib/rigor/plugin/base.rb +168 -5
  204. data/lib/rigor/plugin/box_probe.rb +91 -0
  205. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  206. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  207. data/lib/rigor/plugin/loader.rb +2 -1
  208. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  209. data/lib/rigor/plugin/manifest.rb +71 -10
  210. data/lib/rigor/plugin/registry.rb +35 -1
  211. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  212. data/lib/rigor/plugin/template_unit.rb +196 -0
  213. data/lib/rigor/plugin.rb +1 -0
  214. data/lib/rigor/protection/discovery_seed.rb +3 -1
  215. data/lib/rigor/protection/kill_signature.rb +0 -1
  216. data/lib/rigor/protection/mutation_cache.rb +1 -2
  217. data/lib/rigor/rbs_extended.rb +27 -0
  218. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  219. data/lib/rigor/reflection/constant_path.rb +19 -6
  220. data/lib/rigor/reflection.rb +72 -105
  221. data/lib/rigor/scope/discovery_index.rb +135 -2
  222. data/lib/rigor/scope.rb +859 -49
  223. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  224. data/lib/rigor/sig_gen/classification.rb +22 -5
  225. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  226. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  227. data/lib/rigor/sig_gen/generator.rb +558 -31
  228. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  229. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  230. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  231. data/lib/rigor/sig_gen/renderer.rb +124 -9
  232. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  233. data/lib/rigor/sig_gen/write_result.rb +19 -3
  234. data/lib/rigor/sig_gen/writer.rb +166 -35
  235. data/lib/rigor/sig_gen.rb +3 -0
  236. data/lib/rigor/signature_path_audit.rb +1 -1
  237. data/lib/rigor/source/node_walker.rb +0 -3
  238. data/lib/rigor/source/parameter_envelope.rb +72 -0
  239. data/lib/rigor/source.rb +1 -0
  240. data/lib/rigor/type/combinator.rb +88 -12
  241. data/lib/rigor/type/difference.rb +1 -0
  242. data/lib/rigor/type/hash_shape.rb +1 -1
  243. data/lib/rigor/type/refined.rb +1 -0
  244. data/lib/rigor/version.rb +1 -1
  245. data/lib/rigor.rb +1 -0
  246. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  247. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  248. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  249. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  250. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  251. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  252. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  253. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  254. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  255. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  256. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  257. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  258. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  259. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  260. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  261. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  262. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  263. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  264. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  265. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  266. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  267. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  268. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  269. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  270. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  271. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  272. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  273. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  274. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  275. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  276. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  277. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  278. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  279. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  280. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  281. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  282. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  283. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  284. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  285. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  286. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  287. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  288. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  289. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  290. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  291. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  292. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  293. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  294. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  295. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  296. data/sig/rigor/analysis/baseline.rbs +69 -7
  297. data/sig/rigor/analysis/fact_store.rbs +1 -1
  298. data/sig/rigor/analysis/project_scan.rbs +74 -0
  299. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  300. data/sig/rigor/effects/effect_table.rbs +60 -0
  301. data/sig/rigor/effects/envelope.rbs +97 -0
  302. data/sig/rigor/effects/envelope_index.rbs +33 -0
  303. data/sig/rigor/effects/file_collection.rbs +81 -0
  304. data/sig/rigor/effects/label.rbs +26 -0
  305. data/sig/rigor/effects/label_set.rbs +46 -0
  306. data/sig/rigor/effects/method_key.rbs +24 -0
  307. data/sig/rigor/effects/origin.rbs +50 -0
  308. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  309. data/sig/rigor/effects/registry.rbs +68 -0
  310. data/sig/rigor/effects/summary.rbs +50 -0
  311. data/sig/rigor/effects/taint_cause.rbs +12 -0
  312. data/sig/rigor/environment.rbs +12 -10
  313. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  314. data/sig/rigor/inference.rbs +6 -4
  315. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  316. data/sig/rigor/plugin/base.rbs +30 -7
  317. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  318. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  319. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  320. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  321. data/sig/rigor/plugin/io_boundary.rbs +1 -1
  322. data/sig/rigor/plugin/loader.rbs +3 -3
  323. data/sig/rigor/plugin/manifest.rbs +50 -11
  324. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  325. data/sig/rigor/plugin/registry.rbs +62 -1
  326. data/sig/rigor/plugin.rbs +1 -1
  327. data/sig/rigor/rbs_extended.rbs +1 -1
  328. data/sig/rigor/reflection.rbs +7 -6
  329. data/sig/rigor/scope.rbs +135 -20
  330. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  331. data/sig/rigor/source.rbs +4 -4
  332. data/sig/rigor/testing.rbs +10 -4
  333. data/sig/rigor/type.rbs +6 -0
  334. data/sig/rigor.rbs +38 -20
  335. data/skills/rigor-ask/SKILL.md +8 -5
  336. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  337. data/skills/rigor-ci-setup/SKILL.md +17 -21
  338. data/skills/rigor-doctor/SKILL.md +24 -22
  339. data/skills/rigor-doctor/references/01-checks.md +97 -33
  340. data/skills/rigor-editor-setup/SKILL.md +6 -4
  341. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  342. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  343. data/skills/rigor-next-steps/SKILL.md +4 -2
  344. data/skills/rigor-plugin-author/SKILL.md +19 -23
  345. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  346. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  347. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  348. data/skills/rigor-plugin-review/SKILL.md +6 -4
  349. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  350. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  351. data/skills/rigor-project-init/SKILL.md +9 -7
  352. data/skills/rigor-project-init/references/01-detect.md +13 -9
  353. data/skills/rigor-project-init/references/02-configure.md +33 -8
  354. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  355. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  356. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  357. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  358. data/skills/rigor-type-oracle/SKILL.md +4 -6
  359. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  360. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  361. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  362. data/skills/rigor-upgrade/SKILL.md +13 -8
  363. metadata +108 -1
@@ -4,6 +4,12 @@ require "prism"
4
4
 
5
5
  require_relative "../source/node_walker"
6
6
  require_relative "block_parameter_binder"
7
+ require_relative "element_read_widening"
8
+ require_relative "index_write_widening"
9
+ require_relative "mutation_widening"
10
+ require_relative "optimistic_origin"
11
+ require_relative "receiver_alias"
12
+ require_relative "unknown_store_widening"
7
13
 
8
14
  module Rigor
9
15
  module Inference
@@ -16,11 +22,21 @@ module Rigor
16
22
  # A write counts across every local-write form — plain `=` (`LocalVariableWriteNode`), the operator /
17
23
  # `||=` / `&&=` compound forms, and a multi-assign target (`x, y = …` → `LocalVariableTargetNode` under
18
24
  # a `MultiWriteNode`) — at ANY depth: a block is a closure, so a write inside a nested block binds the
19
- # same outer variable. Block-introduced names (parameters, numbered parameters, `;`-locals) and names
20
- # not bound in the outer scope are excluded; a write to either is not a captured rebind of an outer
21
- # variable.
22
- module CapturedLocals
23
- LOCAL_WRITE_NODES = [
25
+ # same outer variable. Block-introduced names (parameters, numbered parameters, `;`-locals), names
26
+ # not bound in the outer scope, a write a nested block's own parameter or block-local shadows, and a
27
+ # write inside a nested `def` or class body ({.outer_local?}) are excluded; a write to any of them is
28
+ # not a captured rebind of an outer variable.
29
+ #
30
+ # All three also ask for the instance variables the body rebinds (`ivars: true`). The per-element fold asks
31
+ # for every other binding that outlives an iteration as well (`non_locals: true`): the class variables and
32
+ # globals the body rebinds, and the instance variable behind an attribute setter it calls on `self`. Names
33
+ # keep their sigil, so a map over every kind never collides, and {.bound_type} / {.bind} reach each name
34
+ # through its own kind of binding.
35
+ #
36
+ # {.content_mutations} is the sibling set on the same terms: the captured outer locals the body mutates
37
+ # IN PLACE rather than rebinds, which the rebind set cannot see and the per-element fold needs as well.
38
+ module CapturedLocals # rubocop:disable Metrics/ModuleLength
39
+ LOCAL_WRITE_NODES = Set[
24
40
  Prism::LocalVariableWriteNode,
25
41
  Prism::LocalVariableOperatorWriteNode,
26
42
  Prism::LocalVariableOrWriteNode,
@@ -28,24 +44,380 @@ module Rigor
28
44
  Prism::LocalVariableTargetNode
29
45
  ].freeze
30
46
 
47
+ # The write forms of the bindings other than locals that outlive an iteration: instance variables, class
48
+ # variables and globals.
49
+ NON_LOCAL_WRITE_NODES = Set[
50
+ Prism::InstanceVariableWriteNode,
51
+ Prism::InstanceVariableOperatorWriteNode,
52
+ Prism::InstanceVariableOrWriteNode,
53
+ Prism::InstanceVariableAndWriteNode,
54
+ Prism::InstanceVariableTargetNode,
55
+ Prism::ClassVariableWriteNode,
56
+ Prism::ClassVariableOperatorWriteNode,
57
+ Prism::ClassVariableOrWriteNode,
58
+ Prism::ClassVariableAndWriteNode,
59
+ Prism::ClassVariableTargetNode,
60
+ Prism::GlobalVariableWriteNode,
61
+ Prism::GlobalVariableOperatorWriteNode,
62
+ Prism::GlobalVariableOrWriteNode,
63
+ Prism::GlobalVariableAndWriteNode,
64
+ Prism::GlobalVariableTargetNode
65
+ ].freeze
66
+
67
+ # The call forms that invoke an attribute setter: `self.w = v`, the compound `self.w += v` / `||=` / `&&=`,
68
+ # and a multi-assign target `self.w, x = …`.
69
+ SETTER_CALL_NODES = Set[
70
+ Prism::CallNode, Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
71
+ Prism::CallTargetNode
72
+ ].freeze
73
+ private_constant :SETTER_CALL_NODES
74
+
75
+ # An attribute writer's name: an identifier followed by `=`. `==`, `!=`, `<=`, `>=`, `===` and `[]=` are
76
+ # not setters.
77
+ SETTER_NAME = /\A[A-Za-z_]\w*=\z/
78
+ private_constant :SETTER_NAME
79
+
80
+ # The binding the per-element fold lays under a block's parameters: each name's type across iterations, and
81
+ # the optimistic nil-freeness mark an iteration's own rebind gave it, which {.bind} adds to the one the
82
+ # call site already holds — as `Scope#join` unions the marks of the scopes it joins. `repeated` is the
83
+ # `RepeatedOrWrites::Marks` of the index `||=` sites whose slot an earlier run may have filled, marked on the
84
+ # same scope: those of the per-element fold's `position`, or every site when no position is given.
85
+ Bindings = Data.define(:types, :marks, :repeated) do
86
+ def names = types.keys
87
+
88
+ def lay(scope, position: nil)
89
+ bound = types.reduce(scope) do |acc, (name, type)|
90
+ CapturedLocals.bind(acc, name, type, optimistic: marks[name])
91
+ end
92
+ bound.with_repeated_or_writes(repeated.at(position))
93
+ end
94
+ end
95
+
96
+ # The empty answers, shared: every block-bearing call's return pass asks both questions, and nearly every
97
+ # body answers nothing to either.
98
+ NO_NAMES = [].freeze
99
+ NO_SITES = {}.freeze
100
+ private_constant :NO_NAMES, :NO_SITES
101
+
31
102
  module_function
32
103
 
33
104
  # @param base_scope — the call-site scope the block closes over.
105
+ # @param ivars — also collect the instance variables the body rebinds. An ivar is not captured — the
106
+ # block shares the caller's `self` — but it outlives an iteration, and the call, exactly as a captured
107
+ # local does. It counts on the same terms as a local (every write form, any depth, bound in
108
+ # `base_scope`), except that one still on its class-wide binding does not: ADR-58's declaration seed is
109
+ # the union of every write in the class, this body's included, so nothing the body stores can move it,
110
+ # and rebinding it would only drop the declaration mark that keeps its nil from being diagnostic fuel.
111
+ # A nested block that rebinds `self` (`o.instance_eval`) writes another object's ivar, and a nested
112
+ # `def` runs only when called; both still count, because an `instance_eval` without a receiver, or a
113
+ # call to that `def` inside the body, does write this one.
114
+ # @param non_locals — the per-element fold's superset of `ivars`: the class variables and globals the body
115
+ # rebinds count too, on the same terms, and so does an attribute setter called on `self` (`self.w = v`)
116
+ # as a rebind of the instance variable it is named after, `@w`: the `attr_writer` / `attr_accessor`
117
+ # convention stores there, and no write node shows it. A hand-written setter storing elsewhere is not
118
+ # seen.
34
119
  # @return the captured names the body writes, each once, in first-write order.
35
- def writes(block_node, base_scope)
120
+ def writes(block_node, base_scope, ivars: false, non_locals: false)
121
+ body = block_node.body
122
+ return NO_NAMES if body.nil?
123
+
124
+ introduced = nil
125
+ names = nil
126
+ outliving = non_locals || ivars
127
+ Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
128
+ name =
129
+ if LOCAL_WRITE_NODES.include?(descendant.class)
130
+ captured_local_write(descendant, ancestors, base_scope) { introduced ||= introduced_locals(block_node) }
131
+ elsif outliving
132
+ rebindable_outliving_write(descendant, base_scope, non_locals)
133
+ end
134
+ (names ||= []) << name if name
135
+ end
136
+ names ? names.uniq : NO_NAMES
137
+ end
138
+
139
+ def rebindable_outliving_write(node, base_scope, non_locals)
140
+ name = outliving_write_name(node, non_locals)
141
+ name if name && rebindable_non_local?(base_scope, name)
142
+ end
143
+
144
+ # The outer local a local-write node rebinds, or nil when the call site does not bind it, the write resolves
145
+ # inside a nested block or `def` ({.outer_local?}), or the block introduces it (the block yields the
146
+ # introduced set, computed only when needed).
147
+ def captured_local_write(node, ancestors, base_scope)
148
+ name = node.name
149
+ return nil unless base_scope.locals.key?(name) && outer_local?(node, ancestors)
150
+
151
+ yield.include?(name) ? nil : name
152
+ end
153
+
154
+ # The non-local `node` rebinds under `non_locals:` — or, without it, the instance variable only.
155
+ def outliving_write_name(node, non_locals)
156
+ name = non_local_write_name(node)
157
+ non_locals || (name && ivar_name?(name) && NON_LOCAL_WRITE_NODES.include?(node.class)) ? name : nil
158
+ end
159
+
160
+ # The instance, class or global variable `node` rebinds, or nil when it rebinds none of them.
161
+ def non_local_write_name(node)
162
+ return node.name if NON_LOCAL_WRITE_NODES.include?(node.class)
163
+
164
+ setter_ivar_name(node)
165
+ end
166
+
167
+ # `@w` for an attribute setter called on `self` (`self.w = …` and its compound and target forms), else nil.
168
+ def setter_ivar_name(node)
169
+ return nil unless SETTER_CALL_NODES.include?(node.class) && node.receiver.is_a?(Prism::SelfNode)
170
+
171
+ setter = node.respond_to?(:write_name) ? node.write_name : node.name
172
+ return nil unless SETTER_NAME.match?(setter)
173
+
174
+ :"@#{setter.to_s.delete_suffix('=')}"
175
+ end
176
+
177
+ def rebindable_non_local?(scope, name)
178
+ variable_kind(name) == :ivar ? rebindable_ivar?(scope, name) : !bound_type(scope, name).nil?
179
+ end
180
+
181
+ def rebindable_ivar?(scope, name)
182
+ !scope.ivar(name).nil? && !scope.declaration_sourced?(:ivar, name)
183
+ end
184
+
185
+ # The binding `scope` holds for a name from {.writes}.
186
+ def bound_type(scope, name)
187
+ case variable_kind(name)
188
+ when :ivar then scope.ivar(name)
189
+ when :cvar then scope.cvar(name)
190
+ when :global then scope.global(name)
191
+ else scope.local(name)
192
+ end
193
+ end
194
+
195
+ # `scope` with a name from {.writes} bound to `type`, keeping the name's optimistic nil-freeness mark
196
+ # (issue #286). `Scope#with_local` / `#with_ivar` drop it as a fresh write should, but here `type`
197
+ # stands for the binding across iterations, and a value that was nil-free only optimistically still is:
198
+ # without the mark `x.nil?` folds to `false` where the runtime answers `true`. `optimistic` is a mark the
199
+ # binding carries besides the one `scope` already holds — one an iteration's own rebind made. Class
200
+ # variables and globals carry no mark. {OptimisticOrigin.carry_local_mark} owns the re-marking, and with
201
+ # it the miss answer the mark records (issue #1302).
202
+ def bind(scope, name, type, optimistic: nil)
203
+ case variable_kind(name)
204
+ when :ivar then OptimisticOrigin.carry_ivar_mark(scope, scope.with_ivar(name, type), name, optimistic)
205
+ when :cvar then scope.with_cvar(name, type)
206
+ when :global then scope.with_global(name, type)
207
+ else OptimisticOrigin.carry_local_mark(scope, scope.with_local(name, type), name, optimistic)
208
+ end
209
+ end
210
+
211
+ # The optimistic nil-freeness mark `scope` holds for a name from {.writes}, or nil.
212
+ def optimistic_mark(scope, name)
213
+ case variable_kind(name)
214
+ when :ivar then scope.optimistic_ivar(name)
215
+ when :local then scope.optimistic_local(name)
216
+ end
217
+ end
218
+
219
+ def ivar_name?(name) = variable_kind(name) == :ivar
220
+
221
+ # Ruby spells a global with a leading `$`, a class variable with `@@`, an instance variable with a single
222
+ # `@`, and a local with none of them.
223
+ def variable_kind(name)
224
+ if name.start_with?("$") then :global
225
+ elsif name.start_with?("@@") then :cvar
226
+ elsif name.start_with?("@") then :ivar
227
+ else :local
228
+ end
229
+ end
230
+
231
+ # The nodes that change a receiver's CONTENT without rebinding it: a call to a name the straight-line
232
+ # widening responds to ({MutationWidening::SHAPE_MUTATORS}), and the index writes that store through
233
+ # `[]=` without being a `[]=` call ({IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES}, the one list the
234
+ # block-return threading gate and the captured-local write-back read too).
235
+ INDEX_STORE_NODES = IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES
236
+ private_constant :INDEX_STORE_NODES
237
+
238
+ # The captured outer locals the body mutates in place, each mapped to its mutation sites (the nodes
239
+ # above) in source order. A site counts through every variable its receiver can evaluate to
240
+ # ({ReceiverAlias.candidates}), at any nesting depth as long as a local's read does not resolve inside a nested
241
+ # block ({.outer_local?}), and a local is excluded on exactly the terms {.writes} excludes it.
242
+ #
243
+ # Under `non_locals: true` the instance variables, class variables and globals the body mutates in place
244
+ # count too, on the terms {.writes} takes a rebound one ({.rebindable_non_local?}). Since the block-return
245
+ # threading gate threads an index write, `@cache[:first] ||= e; @cache[:first] == 2` would otherwise type
246
+ # every position from the empty entry hash, store THAT position's `e`, and fold `find` to `2` where Ruby,
247
+ # keeping the first iteration's `1`, answers `nil`; `$seen << x; n == 1` after `n = $seen.size` folded
248
+ # `find` to `nil` the same way. A class variable or global counts only as the receiver itself (parentheses
249
+ # aside), not through a branch that selects it.
250
+ #
251
+ # A local also counts through the two routes straight-line code widens it by without reading it as the
252
+ # receiver. A mutator on an element read rooted at it (`a[0] << e`, the local {ElementReadWidening}
253
+ # widens) is a site of that local. A self-call whose callee content-mutates the parameter a local is passed
254
+ # to (`add_to(a, e)`, the local `StatementEvaluator#content_mutated_arguments` reports) is a
255
+ # {UnknownStoreWidening::CalleeStore} site of it. Both were invisible here, so every position of the fold
256
+ # read the local at its entry contents: `a = [[1]]; [1, 2].map { |e| v = a[0].size; a[0] << e; v }`
257
+ # folded to `[1, 1]` where Ruby answers `[1, 2]`.
258
+ #
259
+ # @param base_scope — the call-site scope the block closes over.
260
+ # @param non_locals — also collect the instance variables, class variables and globals the body mutates
261
+ # in place.
262
+ # @return `{ name => [site, ...] }`, empty for the overwhelmingly common body that mutates nothing
263
+ # captured.
264
+ def content_mutations(block_node, base_scope, non_locals: false)
36
265
  body = block_node.body
37
- return [] if body.nil?
266
+ return NO_SITES if body.nil?
267
+
268
+ introduced = nil
269
+ body_mutation_sites(body, base_scope, non_locals) { introduced ||= introduced_locals(block_node) }
270
+ end
271
+
272
+ # Issue #1412 — false when `node` certainly holds nothing {.writes} (with `ivars: true`) or
273
+ # {.content_mutations} could collect against `base_scope`: no write to a local it binds, no instance, class or
274
+ # global variable write, no mutator or index store, and no self-call passing a local. It over-approximates
275
+ # (a write a block parameter shadows still answers true) and allocates nothing, so a caller can skip both
276
+ # scans, and whatever it would compute only to use their answers, for the common body that touches no capture.
277
+ def may_touch_capture?(node, base_scope)
278
+ return true if capture_touch?(node, base_scope)
279
+
280
+ node.rigor_each_child { |child| return true if may_touch_capture?(child, base_scope) }
281
+ false
282
+ end
283
+
284
+ def capture_touch?(node, base_scope)
285
+ klass = node.class
286
+ return base_scope.locals.key?(node.name) if LOCAL_WRITE_NODES.include?(klass)
287
+ return true if NON_LOCAL_WRITE_NODES.include?(klass) || INDEX_STORE_NODES.include?(klass)
288
+ return false unless klass == Prism::CallNode
289
+
290
+ receiver = node.receiver
291
+ return true if receiver && MutationWidening::SHAPE_MUTATORS.include?(node.name)
292
+
293
+ callee_call?(node)
294
+ end
295
+
296
+ # Issue #1412 — {.content_mutations} for a `while` / `until` / `for` body: the locals bound in `base_scope`
297
+ # (the scope the loop body first enters from) that the body mutates in place. A loop body opens no scope and
298
+ # introduces no name, so every such local counts on the same depth terms a block's capture does — a read
299
+ # that resolves inside a nested block or lambda, or inside a nested `def` or class body, is not the loop's.
300
+ #
301
+ # @return `{ name => [site, ...] }`, empty for the common body that mutates nothing.
302
+ def loop_content_mutations(statements, base_scope)
303
+ return NO_SITES if statements.nil?
304
+
305
+ body_mutation_sites(statements, base_scope, false) { NO_INTRODUCED }
306
+ end
307
+
308
+ NO_INTRODUCED = Set.new.freeze
309
+ private_constant :NO_INTRODUCED
310
+
311
+ # The mutation sites under `body` whose reads name a variable {.captured_target?} accepts, keyed by name.
312
+ # The block yields the names the enclosing construct introduces, asked only when a local site is found.
313
+ def body_mutation_sites(body, base_scope, non_locals, &)
314
+ sites = nil
315
+ evaluator = nil
316
+ Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
317
+ # A callee is resolved in the call-site scope, as the straight-line callee floor resolves it.
318
+ site = mutation_site(descendant) { evaluator ||= StatementEvaluator.new(scope: base_scope) }
319
+ next if site.nil?
320
+
321
+ site_reads(site).each do |read|
322
+ next unless captured_target?(read, ancestors, base_scope, non_locals, &)
323
+
324
+ ((sites ||= {})[read.name] ||= []) << site
325
+ end
326
+ end
327
+ sites || NO_SITES
328
+ end
329
+
330
+ # The mutation site `node` is — the node itself, or a {UnknownStoreWidening::CalleeStore} for a self-call
331
+ # whose callee content-mutates a local it is passed — or nil. A self-call is resolved by the
332
+ # `StatementEvaluator` the block yields, which the caller builds only when one is needed. A mutator name
333
+ # called on `self` (`self.store(h, k)`, `self.push(a, x)`) names no variable as its receiver, so it is asked
334
+ # as a self-call, as the straight-line callee floor asks it.
335
+ def mutation_site(node)
336
+ receiver = mutated_receiver(node)
337
+ return node if receiver && !receiver.is_a?(Prism::SelfNode)
338
+ return nil unless callee_call?(node)
339
+
340
+ arguments = yield.content_mutated_arguments(node)
341
+ arguments.empty? ? nil : UnknownStoreWidening::CalleeStore.new(node, arguments)
342
+ end
343
+
344
+ # The variable reads a mutation site reaches: a callee store's arguments, the local an element read is
345
+ # rooted at, or else {ReceiverAlias.mutated_reads} of the receiver.
346
+ def site_reads(site)
347
+ return site.arguments if site.is_a?(UnknownStoreWidening::CalleeStore)
348
+
349
+ receiver = mutated_receiver(site)
350
+ path = ElementReadWidening.element_read_path(receiver)
351
+ path ? [path.first] : ReceiverAlias.mutated_reads(receiver)
352
+ end
353
+
354
+ # A call to a method on `self` (implicit or explicit) that passes a local as an argument — the only kind
355
+ # the callee floor can report a site for, so the only kind worth resolving.
356
+ def callee_call?(node)
357
+ return false unless node.is_a?(Prism::CallNode)
358
+ return false unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
359
+
360
+ arguments = node.arguments&.arguments
361
+ !arguments.nil? && arguments.any?(Prism::LocalVariableReadNode)
362
+ end
363
+
364
+ # True when `read` names a variable {.content_mutations} collects: a {.content_target?} which, when it is a
365
+ # local, does not resolve inside a nested block and is not one the block introduces (the block yields that set,
366
+ # computed only when needed). An `it` read is never one: it is the parameter of the innermost block around
367
+ # it, so the body's own `it` is introduced and a nested block's `it` is that block's.
368
+ def captured_target?(read, ancestors, base_scope, non_locals)
369
+ return false if read.is_a?(Prism::ItLocalVariableReadNode)
370
+ return false unless content_target?(read, base_scope, non_locals)
371
+ return true unless read.is_a?(Prism::LocalVariableReadNode)
372
+
373
+ outer_local?(read, ancestors) && !yield.include?(read.name)
374
+ end
375
+
376
+ # A local bound at the call site (the block's own names are excluded by the caller), or — under
377
+ # `non_locals:` — an instance variable, class variable or global {.rebindable_non_local?} accepts.
378
+ def content_target?(read, base_scope, non_locals)
379
+ return base_scope.locals.key?(read.name) if read.is_a?(Prism::LocalVariableReadNode)
380
+
381
+ non_locals && rebindable_non_local?(base_scope, read.name)
382
+ end
383
+
384
+ # False when a local read or write resolves inside a scope nested between the body and `node`. Prism resolves a
385
+ # name in a nested block's own scope first, so `[[9]].each { |a| a << x }` mutates, and `[[9]].each { |a| a =
386
+ # x }` rebinds, that block's parameter, not the outer local that happens to share its name: its `depth` stops
387
+ # short of the body's own scope. A `def`, `class`, `module` or `class << self` body opens a scope of its own
388
+ # that sees no outer local at all, so `def helper; z = 5; end` writes the method's `z` — but only its body,
389
+ # and a `def`'s parameters, are that scope: a `def (o = x).m` receiver, a `class Foo < (s = x)` superclass or
390
+ # a `class << (t = x)` target runs in the enclosing one and is left to the depth test. {.writes} and
391
+ # {.content_mutations} both ask it, so the two sets cannot disagree about which `a` a site names. A name
392
+ # resolving in the body's own scope is left to the block-introduced exclusion: Prism puts an outer local there
393
+ # only when the parse never saw it declared, which a synthetic call-site scope can still bind.
394
+ def outer_local?(node, ancestors)
395
+ nesting = 0
396
+ ancestors.each_with_index do |ancestor, index|
397
+ return false if hard_scope_entered?(ancestor, ancestors[index + 1] || node)
398
+
399
+ nesting += 1 if NESTED_SCOPE_NODES.include?(ancestor.class)
400
+ end
401
+ node.depth >= nesting
402
+ end
403
+
404
+ # True when `child`, the next node on the path, is inside the scope `ancestor` opens.
405
+ def hard_scope_entered?(ancestor, child)
406
+ case ancestor
407
+ when Prism::DefNode then child.equal?(ancestor.body) || child.equal?(ancestor.parameters)
408
+ when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then child.equal?(ancestor.body)
409
+ else false
410
+ end
411
+ end
38
412
 
39
- introduced = introduced_locals(block_node)
40
- outer_writes = []
41
- Source::NodeWalker.each(body) do |descendant|
42
- next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
43
- next if introduced.include?(descendant.name)
44
- next unless base_scope.locals.key?(descendant.name)
413
+ NESTED_SCOPE_NODES = Set[Prism::BlockNode, Prism::LambdaNode].freeze
414
+ private_constant :NESTED_SCOPE_NODES
45
415
 
46
- outer_writes << descendant.name
416
+ def mutated_receiver(node)
417
+ case node
418
+ when Prism::CallNode then node.receiver if MutationWidening::SHAPE_MUTATORS.include?(node.name)
419
+ when *INDEX_STORE_NODES then node.receiver
47
420
  end
48
- outer_writes.uniq
49
421
  end
50
422
 
51
423
  # Names the block itself introduces: parameters (numbered parameters included, via
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../type"
4
+ require_relative "../reflection"
5
+ require_relative "external_ancestor_resolution"
6
+ require_relative "project_method_ownership"
4
7
 
5
8
  module Rigor
6
9
  module Inference
@@ -30,14 +33,19 @@ module Rigor
30
33
  # therefore covers ONLY the core-and-stdlib surface where immediate invocation is part of the documented
31
34
  # contract:
32
35
  #
33
- # - `Array`, `Hash`, `Range`, `Integer`, `Enumerator::Lazy` iteration methods (`each`, `map`, `select`,
34
- # `reject`, `flat_map`, `find`/`detect`, `any?`, `all?`, `none?`, `one?`, `count`, `inject`/`reduce`,
35
- # `each_with_index`, `each_with_object`, `min_by`, `max_by`, `sort_by`, `partition`, `group_by`,
36
- # `tally`, `sum`, `take_while`, `drop_while`, `chunk_while`, `slice_when`, `zip`, `collect`,
36
+ # - `Array`, `Hash`, `Range`, `Set`, `Enumerator`, `Enumerator::Lazy` iteration methods (`each`, `map`,
37
+ # `select`, `reject`, `flat_map`, `find`/`detect`, `any?`, `all?`, `none?`, `one?`, `count`,
38
+ # `inject`/`reduce`, `each_with_index`, `each_with_object`, `min_by`, `max_by`, `sort_by`, `partition`,
39
+ # `group_by`, `tally`, `sum`, `take_while`, `drop_while`, `chunk_while`, `slice_when`, `zip`, `collect`,
37
40
  # `collect_concat`, `filter`, `filter_map`).
38
41
  # - `Hash`-only iteration: `each_pair`, `each_key`, `each_value`, `transform_keys`, `transform_values`.
39
42
  # - `Integer#times`, `Integer#upto`, `Integer#downto`, `Range#each`, `Range#step`.
40
- # - `Object#tap`, `Object#then`, `Object#yield_self`.
43
+ # - `IO` / `File` / `StringIO` line iteration (`each`, `each_line`, `each_byte`, `each_char`,
44
+ # `each_codepoint`, the singleton `foreach`) and the same Enumerable methods minus
45
+ # {DEFERRED_ENUMERATOR_METHODS}.
46
+ # - `Object#tap`, `Object#then`, `Object#yield_self`. The stronger "yields exactly once, before returning"
47
+ # fact for these three lives in {BlockCallTiming}; that table is expected to move to the same
48
+ # `RBS::Extended` call-timing effect as this one.
41
49
  # - Tuple/HashShape carriers map to Array/Hash for catalogue lookup so a literal `[1, 2, 3].each { ... }`
42
50
  # is recognised.
43
51
  #
@@ -46,6 +54,14 @@ module Rigor
46
54
  # retained. False positives in this catalogue would silently weaken the soundness of fact retention in
47
55
  # later sub-phases.
48
56
  #
57
+ # Issue #1234 — a project class is outside the catalogue by name, yet `class Shelf; include Enumerable`
58
+ # answers `find` with `Enumerable#find` all the same. Given the `scope:` whose discovery tables know the
59
+ # project, a `Nominal` receiver of a project class classifies through its ancestry
60
+ # ({.ancestry_non_escaping?}): the method must be one the project does not define anywhere in that
61
+ # ancestry, and the first ancestor outside the project that declares it must be a catalogued class or
62
+ # `Enumerable` ({MIXIN_NON_ESCAPING}). An ancestor the RBS environment does not know could declare
63
+ # anything, so meeting one first declines.
64
+ #
49
65
  # The analyzer is a pure query. It MUST NOT mutate the receiver type or scope, MUST NOT raise on
50
66
  # unrecognised inputs, and MUST be deterministic for a given input.
51
67
  module ClosureEscapeAnalyzer
@@ -53,23 +69,65 @@ module Rigor
53
69
 
54
70
  # @param environment — reserved for the future sub-phase that consults
55
71
  # `RBS::Extended` call-timing effects; sub-phase 3a ignores it.
72
+ # @param scope — the project's discovery tables, for the ancestry step; without it a project class
73
+ # stays `:unknown`.
56
74
  # @return one of `:non_escaping`, `:escaping`, `:unknown`.
57
- def classify(receiver_type:, method_name:, environment: nil) # rubocop:disable Lint/UnusedMethodArgument
75
+ def classify(receiver_type:, method_name:, environment: nil, scope: nil) # rubocop:disable Lint/UnusedMethodArgument
58
76
  return :unknown if receiver_type.nil?
59
77
 
78
+ method_sym = method_name.to_sym
60
79
  class_name = receiver_class_name(receiver_type)
61
- return :unknown if class_name.nil?
80
+ if class_name
81
+ return :non_escaping if non_escaping?(class_name, method_sym)
82
+ return :escaping if escaping?(class_name, method_sym)
83
+ end
84
+
85
+ instance_class = instance_carrier_class_name(receiver_type)
86
+ return :unknown if instance_class.nil?
87
+
88
+ ancestry_non_escaping?(instance_class, method_sym, scope) ? :non_escaping : :unknown
89
+ end
90
+
91
+ # Issue #1234 — whether some catalogue entry lists `method_name` as an iteration method: a name that runs
92
+ # its block once per element wherever the catalogue knows the receiver. `tap` / `then` / `yield_self` are
93
+ # left out, as they run the block exactly once. This is a fact about the NAME, for the one consumer that
94
+ # asks it of an `:unknown` receiver ({BlockRepetition.may_repeat?}); it proves nothing about escape.
95
+ def iterator_name?(method_name)
96
+ ITERATOR_NAMES.include?(method_name)
97
+ end
62
98
 
99
+ # Issue #1234 — whether the NAME of a catalogued iterator is the only thing Rigor knows about the method
100
+ # `receiver_type` answers `method_name` with, so a pass may read it as repetition
101
+ # ({BlockRepetition.may_repeat?}, for an `:unknown` receiver). It holds for a receiver Rigor cannot
102
+ # see at all (`Dynamic`, `Top`), and for a class it can see whose method the project does not define.
103
+ #
104
+ # A method the project defines under a catalogued name is the project's, not the iterator, so the name
105
+ # says nothing about how often it yields: `class Vault; def select(key) = yield(key.to_s); end` runs its
106
+ # block once. "Defines" is a `def`, `define_method` or `attr_*` anywhere in a project class's ancestry
107
+ # (instance side, or singleton side for a class-object receiver), or a signature whose declaring owner
108
+ # is a project class or module. A carrier this does not recognise, or one whose class it cannot name
109
+ # (an anonymous `Struct.new` value), answers false: when Rigor cannot tell whether the project owns the
110
+ # method, it does not assume repetition. {ProjectMethodOwnership.targets} carries the carrier audit.
111
+ def repeats_by_name?(receiver_type:, method_name:, scope:)
63
112
  method_sym = method_name.to_sym
64
- return :non_escaping if non_escaping?(class_name, method_sym)
65
- return :escaping if escaping?(class_name, method_sym)
113
+ return false unless ITERATOR_NAMES.include?(method_sym)
66
114
 
67
- :unknown
115
+ targets = ProjectMethodOwnership.targets(receiver_type)
116
+ return false if targets.nil?
117
+
118
+ targets.none? { |class_name, kind| ProjectMethodOwnership.defines?(class_name, method_sym, kind, scope) }
68
119
  end
69
120
 
70
121
  class << self
71
122
  private
72
123
 
124
+ # The instance-side class a `Nominal` or an ADR-48 member carrier names, for the ancestry step.
125
+ def instance_carrier_class_name(receiver_type)
126
+ case receiver_type
127
+ when Type::Nominal, Type::StructInstance, Type::DataInstance then receiver_type.class_name&.to_s
128
+ end
129
+ end
130
+
73
131
  # Resolve a single concrete class name for catalogue lookup. Returns `nil` when the receiver carrier
74
132
  # does not name a single class (e.g. `Top`, `Dynamic[Top]`, `Union[...]`, `Bot`). `Tuple` projects to
75
133
  # `Array`; `HashShape` to `Hash`; `Singleton[C]` to `C` (so `Integer.times` would resolve as a
@@ -115,6 +173,64 @@ module Rigor
115
173
  methods = ESCAPING[class_name]
116
174
  methods ? methods.include?(method_sym) : false
117
175
  end
176
+
177
+ # Issue #1234 — a project class answers `method_sym` through the catalogued ancestor Ruby dispatches it
178
+ # to. The cheap gates come first: the name must be a catalogued iteration method, and the receiver a
179
+ # class the project declares. A definition anywhere in the project ancestry — the class's own `def
180
+ # find`, a project module's, a reopened `Enumerable`'s — is not the catalogued method and declines.
181
+ # A class the project's `sig/` declares asks its RBS definition where the method comes from; one
182
+ # without RBS walks the ancestors outside the project in method-resolution order.
183
+ def ancestry_non_escaping?(class_name, method_sym, scope)
184
+ return false if scope.nil? || !ITERATOR_NAMES.include?(method_sym)
185
+ return false unless scope.known_user_class?(class_name)
186
+
187
+ by_method = (ProjectMethodOwnership.memo(scope)[:ancestry][class_name] ||= {})
188
+ return by_method[method_sym] if by_method.key?(method_sym)
189
+
190
+ by_method[method_sym] = compute_ancestry_non_escaping?(class_name, method_sym, scope)
191
+ end
192
+
193
+ def compute_ancestry_non_escaping?(class_name, method_sym, scope)
194
+ return false if ProjectMethodOwnership.defines?(class_name, method_sym, :instance, scope)
195
+
196
+ if Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
197
+ return catalogued_declaration?(method_definition(class_name, method_sym, :instance, scope), method_sym)
198
+ end
199
+
200
+ external_ancestry_non_escaping?(class_name, method_sym, scope)
201
+ end
202
+
203
+ # The RBS definition, or nil — a malformed signature is a gap, and
204
+ # {ExternalAncestorResolution.method_definition} is the one place that rescues it.
205
+ def method_definition(class_name, method_sym, kind, scope)
206
+ ExternalAncestorResolution.method_definition(class_name, method_sym, kind, scope: scope)
207
+ end
208
+
209
+ # The first external ancestor that declares the method decides. One the environment does not know may
210
+ # declare it, so it declines rather than being skipped; one that does not declare it is skipped.
211
+ def external_ancestry_non_escaping?(class_name, method_sym, scope)
212
+ scope.external_ancestor_name_candidates(class_name).each do |candidates|
213
+ known = candidates.find { |candidate| Rigor::Reflection.rbs_class_known?(candidate, scope: scope) }
214
+ return false if known.nil?
215
+ return true if catalogued_owner?(known, method_sym)
216
+
217
+ definition = method_definition(known, method_sym, :instance, scope)
218
+ return catalogued_declaration?(definition, method_sym) if definition
219
+ end
220
+ false
221
+ end
222
+
223
+ def catalogued_declaration?(definition, method_sym)
224
+ return false if definition.nil? || !definition.respond_to?(:defined_in)
225
+
226
+ owner = definition.defined_in
227
+ !owner.nil? && catalogued_owner?(owner.to_s.delete_prefix("::"), method_sym)
228
+ end
229
+
230
+ def catalogued_owner?(name, method_sym)
231
+ methods = NON_ESCAPING[name] || MIXIN_NON_ESCAPING[name]
232
+ methods ? methods.include?(method_sym) : false
233
+ end
118
234
  end
119
235
 
120
236
  OBJECT_NON_ESCAPING = %i[tap then yield_self].freeze
@@ -149,9 +265,24 @@ module Rigor
149
265
  # `File.foreach(path) { case … when … then flag = true when … then return true if flag end }` classifies
150
266
  # `:unknown` and misses the loop-body re-narrowing, so a local written in one `when` arm reads its
151
267
  # pre-loop value in a sibling arm and a guarding condition folds to a spurious constant.
268
+ #
269
+ # The three are also `Enumerable[String]` over lines (rbs core declares it for `IO`;
270
+ # `data/core_overlay/string_io.rbs` for `StringIO`). An eager Enumerable method runs its block from inside
271
+ # the call, through `each`, so `io.each_with_index { … }` / `io.detect { … }` carry the same contract and
272
+ # take `ENUMERABLE_NON_ESCAPING` — minus {DEFERRED_ENUMERATOR_METHODS}, whose block outlives the call. On
273
+ # the `singleton(File)` / `singleton(IO)` side those names only ever meet `IO.select`, which takes no
274
+ # block and so never retains one. The key is the exact class name: a subclass (`Tempfile`, a user
275
+ # `StringIO` subclass) stays `:unknown`.
152
276
  IO_ITERATION = %i[each_line each each_byte each_char each_codepoint].freeze
153
277
  IO_SINGLETON_ITERATION = %i[foreach].freeze
154
278
 
279
+ # Enumerable methods that return an Enumerator holding the block and run it only when that Enumerator is
280
+ # consumed, so the block may run after locals it reads or writes have changed. They are NOT
281
+ # non-escaping. `ENUMERABLE_NON_ESCAPING` still lists them for the collection entries — a defect tracked
282
+ # as #1311; the stream entries below leave them out rather than inherit it.
283
+ DEFERRED_ENUMERATOR_METHODS = %i[chunk chunk_while slice_when slice_before slice_after].freeze
284
+ STREAM_ENUMERABLE_NON_ESCAPING = (ENUMERABLE_NON_ESCAPING - DEFERRED_ENUMERATOR_METHODS).freeze
285
+
155
286
  NON_ESCAPING = {
156
287
  "Array" => (ENUMERABLE_NON_ESCAPING + ARRAY_EXTRA).freeze,
157
288
  "Hash" => (ENUMERABLE_NON_ESCAPING + HASH_EXTRA).freeze,
@@ -160,11 +291,24 @@ module Rigor
160
291
  "Integer" => INTEGER_EXTRA,
161
292
  "Enumerator" => ENUMERABLE_NON_ESCAPING,
162
293
  "Enumerator::Lazy" => ENUMERABLE_NON_ESCAPING,
163
- "IO" => (IO_ITERATION + IO_SINGLETON_ITERATION).freeze,
164
- "File" => (IO_ITERATION + IO_SINGLETON_ITERATION).freeze,
165
- "StringIO" => IO_ITERATION
294
+ "IO" => (STREAM_ENUMERABLE_NON_ESCAPING | IO_ITERATION | IO_SINGLETON_ITERATION).freeze,
295
+ "File" => (STREAM_ENUMERABLE_NON_ESCAPING | IO_ITERATION | IO_SINGLETON_ITERATION).freeze,
296
+ "StringIO" => (STREAM_ENUMERABLE_NON_ESCAPING | IO_ITERATION).freeze
297
+ }.freeze
298
+
299
+ # Issue #1234 — the catalogued modules a project class reaches only through its ancestry, read only by
300
+ # {.ancestry_non_escaping?} after the project has been ruled out as the method's owner. `Enumerable`'s
301
+ # methods run the block from inside the call, through the includer's `each` — minus
302
+ # {DEFERRED_ENUMERATOR_METHODS}, whose Enumerator outlives it, and minus `each` itself, which
303
+ # `Enumerable` does not declare: the includer supplies it, so nothing here speaks for it. For that
304
+ # reason a receiver typed as the bare module is not a key of {NON_ESCAPING} either.
305
+ MIXIN_NON_ESCAPING = {
306
+ "Enumerable" => (STREAM_ENUMERABLE_NON_ESCAPING - %i[each]).freeze
166
307
  }.freeze
167
308
 
309
+ # Every name a {NON_ESCAPING} entry lists as iteration, for {.iterator_name?} and the ancestry gate.
310
+ ITERATOR_NAMES = (NON_ESCAPING.values.flatten.to_set - OBJECT_NON_ESCAPING).freeze
311
+
168
312
  # Methods that are documented to **retain** the block past the call. The block is stored or scheduled,
169
313
  # so outer narrowing facts on writeable captured locals cannot survive.
170
314
  ESCAPING = {