rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -4,6 +4,8 @@ require "prism"
4
4
 
5
5
  require_relative "project_patched_methods"
6
6
  require_relative "project_patched_definedness_guard"
7
+ require_relative "project_patched_visibility"
8
+ require_relative "project_patched_mixins"
7
9
  require_relative "../analysis/dependency_source_inference/return_type_heuristic"
8
10
  require_relative "../source/alias_names"
9
11
  require_relative "../source/constant_path"
@@ -20,7 +22,7 @@ module Rigor
20
22
  # only needs `class C; def m; end; end` shape recognition, not full inference. Parse errors degrade
21
23
  # to a fail-soft `:warning` `pre-eval.parse-error` diagnostic accumulated alongside the registry;
22
24
  # per ADR-17 § "Failure modes" a parse failure in a pre-eval file MUST NOT abort the rest of the run.
23
- module ProjectPatchedScanner
25
+ module ProjectPatchedScanner # rubocop:disable Metrics/ModuleLength
24
26
  # Frozen scan outcome carrying the populated registry and the per-file warnings the runner emits
25
27
  # at run start.
26
28
  class Result < Data.define(:registry, :diagnostics)
@@ -57,10 +59,13 @@ module Rigor
57
59
  diagnostics = []
58
60
  census = Set.new
59
61
  paths.each { |path| scan_file(path, collected, diagnostics, buffer, census) }
60
- entries = resolve_aliases(collected)
62
+ pending_mixins, collected = collected.partition { |item| item.is_a?(Mixins::Pending) }
63
+ overrides = {}
64
+ entries = resolve_aliases(collected, overrides)
61
65
  diagnostics.concat(duplicate_declaration_diagnostics(entries))
62
66
  Result.new(
63
- registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze),
67
+ registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze, overrides: overrides,
68
+ mixins: Mixins.resolve(pending_mixins, entries)),
64
69
  diagnostics: diagnostics
65
70
  )
66
71
  end
@@ -105,32 +110,50 @@ module Rigor
105
110
  # idiom (`def foo`, `alias_method :foo_without_x, :foo`, `alias_method :foo, :foo_with_x`) leaves `foo` the
106
111
  # alias. Bindings of one name in DIFFERENT files are what `pre-eval.duplicate-declaration` reports, and the
107
112
  # registry keeps the last of those, as Ruby does.
108
- def resolve_aliases(collected)
113
+ #
114
+ # A {Visibility::Pending} (issue #1708) changes the visibility of the binding its name has when it runs, in the
115
+ # file that bound it, so it is not a second declaration; a name no earlier patch binds on the class is one it
116
+ # inherits, recorded in `overrides` ({Visibility.apply}). `module_function :x` also binds the public singleton
117
+ # copy.
118
+ def resolve_aliases(collected, overrides = {})
109
119
  bound = {}
120
+ homes = {}
110
121
  per_file = collected.group_by(&:source_path).values.map do |items|
111
122
  final = {}
112
123
  items.each do |item|
124
+ if item.is_a?(Visibility::Pending)
125
+ next Visibility.apply(item, bound, homes, overrides) { |copy| bind(copy, bound, homes, final) }
126
+ end
127
+
113
128
  entry = item.is_a?(PendingAlias) ? alias_entry(item, bound) : item
114
129
  entry = entry.entry if entry.is_a?(GuardedDef)
115
130
  key = [entry.class_name, entry.method_name, entry.kind]
116
131
  next if item.is_a?(GuardedDef) && bound.key?(key)
117
132
 
118
- bound[key] = entry
119
- final.delete(key)
120
- final[key] = entry
133
+ bind(entry, bound, homes, final)
121
134
  end
122
- final.values
135
+ final
123
136
  end
124
- per_file.flatten(1)
137
+ per_file.flat_map(&:values)
125
138
  end
126
139
  private_class_method :resolve_aliases
127
140
 
141
+ def bind(entry, bound, homes, final)
142
+ key = [entry.class_name, entry.method_name, entry.kind]
143
+ bound[key] = entry
144
+ homes[key] = final
145
+ final.delete(key)
146
+ final[key] = entry
147
+ end
148
+ private_class_method :bind
149
+
128
150
  def alias_entry(item, bound)
129
151
  target = bound[[item.class_name, item.old_name, item.kind]]
130
152
  ProjectPatchedMethods::Entry.new(
131
153
  class_name: item.class_name, method_name: item.new_name, kind: item.kind,
132
154
  source_path: item.source_path, source_line: item.source_line,
133
- return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name
155
+ return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name,
156
+ visibility: target ? target.visibility : :public
134
157
  )
135
158
  end
136
159
  private_class_method :alias_entry
@@ -148,8 +171,7 @@ module Rigor
148
171
  return
149
172
  end
150
173
 
151
- Thread.current[:rigor_guarded_defs] = DefinednessGuard.defs(parse_result.value)
152
- walk_node(parse_result.value, [], false, path, entries)
174
+ walk_file(parse_result.value, path, entries)
153
175
  # Issue #1367 — the file's `global.*` write facts: a patch file may alias a special or give a class the
154
176
  # method a setter asks for, as any project file may.
155
177
  census.merge(GlobalWriteCensus.scan(parse_result.value))
@@ -164,9 +186,19 @@ module Rigor
164
186
  )
165
187
  ensure
166
188
  Thread.current[:rigor_guarded_defs] = nil
189
+ Thread.current[:rigor_def_visibilities] = nil
167
190
  end
168
191
  private_class_method :scan_file
169
192
 
193
+ # Walks one parsed file with its definedness guards and `def` visibilities (issue #1708) in reach of
194
+ # {record_def_node}.
195
+ def walk_file(root, path, entries)
196
+ Thread.current[:rigor_guarded_defs] = DefinednessGuard.defs(root)
197
+ Thread.current[:rigor_def_visibilities] = Visibility.defs(root)
198
+ walk_node(root, [], false, path, entries)
199
+ end
200
+ private_class_method :walk_file
201
+
170
202
  def parse_error_diagnostic(path, errors)
171
203
  first = errors.first
172
204
  line = first.respond_to?(:location) ? first.location&.start_line || 1 : 1
@@ -205,6 +237,11 @@ module Rigor
205
237
  when Prism::CallNode
206
238
  record_alias(node, Source::AliasNames.alias_method_call_names(node), qualified_prefix,
207
239
  in_singleton_class, source_path, entries)
240
+ if Thread.current[:rigor_def_visibilities]&.[](node).equal?(Visibility::STATEMENT)
241
+ pending = Visibility.pending(node, qualified_prefix, in_singleton_class, source_path)
242
+ entries << pending if pending
243
+ entries.concat(Mixins.pending(node, qualified_prefix, in_singleton_class))
244
+ end
208
245
  walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
209
246
  else
210
247
  walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
@@ -251,7 +288,9 @@ module Rigor
251
288
  source_path: source_path, source_line: line,
252
289
  return_type: return_type
253
290
  )
291
+ entry, *copies = Visibility.applied(entry, Thread.current[:rigor_def_visibilities]&.[](node) || :public)
254
292
  entries << (Thread.current[:rigor_guarded_defs]&.key?(node) ? GuardedDef.new(entry: entry) : entry)
293
+ entries.concat(copies)
255
294
  end
256
295
  private_class_method :record_def_node
257
296
 
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/literals"
6
+ require_relative "../source/node_children"
7
+ require_relative "module_function_state"
8
+ require_relative "project_patched_mixins"
9
+
10
+ module Rigor
11
+ module Inference
12
+ module ProjectPatchedScanner
13
+ # Issue #1708 — the visibility a `pre_eval:` file gives each `def` it writes in a class, module or `class <<`
14
+ # body, so a private patch does not answer a call with an explicit receiver. Ruby 4.0.5:
15
+ # `module Kernel; private def kp = 1; end; 1.kp` raises NoMethodError (private method 'kp' called for an
16
+ # instance of Integer), while a bare `kp` runs.
17
+ #
18
+ # The readings, over each body's direct statements in order:
19
+ #
20
+ # - a bare `private`, `protected`, `public` or `module_function` (receiverless or on `self`) sets the default
21
+ # for the `def`s after it in that body;
22
+ # - `private def x`, `protected def x`, `public def x`, `module_function def x` set that `def`'s, and
23
+ # `private_class_method def self.x` / `public_class_method def self.x` a singleton `def`'s;
24
+ # - a `def self.x` is public whatever the default says, as in Ruby;
25
+ # - a `def` nested in a statement (an `if`, a `begin`) takes the default, unless the statement holds a bare
26
+ # call of its own, which may or may not run: then it and every `def` after it are public, the reading that
27
+ # answers more calls.
28
+ #
29
+ # A `def` this does not reach (one in a block, a `rescue` clause, or a body nested in a conditional, a block or a
30
+ # method, which may not run) is public. A named call with Symbol arguments (`private :x`) is the scanner's to
31
+ # apply, since it may name a `def` of an earlier file ({.pending}).
32
+ module Visibility
33
+ # The receiverless calls that set an instance `def`'s visibility, and what they set. `module_function` makes
34
+ # the instance method private and adds a public singleton copy.
35
+ INSTANCE_CALLS = {
36
+ private: :private, protected: :protected, public: :public, module_function: :module_function
37
+ }.freeze
38
+ # The calls that set a singleton `def`'s visibility.
39
+ SINGLETON_CALLS = { private_class_method: :private, public_class_method: :public }.freeze
40
+
41
+ # The nodes a nested `def` does not take the body's default under: another body, or a block.
42
+ OPAQUE = [
43
+ Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode, Prism::BlockNode,
44
+ Prism::LambdaNode
45
+ ].freeze
46
+ private_constant :OPAQUE
47
+
48
+ # The bodies {.read_body} reads when they are a direct statement of a body it reads.
49
+ BODIES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
50
+ private_constant :BODIES
51
+
52
+ # A named visibility call (`private :x`, `module_function :x`, `private_class_method :x`) in a body, which
53
+ # {ProjectPatchedScanner.resolve_aliases} applies to the binding its name has when it runs. `visibility` is
54
+ # `:private`, `:protected`, `:public` or `:module_function`.
55
+ Pending = Data.define(:class_name, :names, :kind, :visibility, :source_path, :source_line)
56
+
57
+ module_function
58
+
59
+ # The {Pending} change a named visibility call in a body makes, or nil. The `def`-argument form
60
+ # (`private def x`) is {.defs}'s; a computed name is skipped, leaving the method public.
61
+ def pending(node, qualified_prefix, in_singleton_class, source_path)
62
+ return nil if qualified_prefix.empty?
63
+
64
+ visibility, side = call(node)
65
+ return nil if visibility.nil? || node.arguments.nil?
66
+
67
+ names = node.arguments.arguments.filter_map { |arg| Source::Literals.symbol_or_string(arg) }
68
+ return nil if names.empty?
69
+
70
+ Pending.new(
71
+ class_name: qualified_prefix.join("::"), names: names,
72
+ kind: side == :singleton || in_singleton_class ? :singleton : :instance, visibility: visibility,
73
+ source_path: source_path, source_line: node.location&.start_line || 1
74
+ )
75
+ end
76
+
77
+ # The value {.defs} records for a call written as a direct statement of a body or of the file's top level, the
78
+ # only calls whose named visibility change ({.pending}) or mixin edge ({Mixins.pending}) the scanner applies to
79
+ # the body's class. A call nested in a conditional, a block, a method body or a `Class.new` may not run, or
80
+ # runs on another class, so it changes nothing: the reading that answers more calls.
81
+ STATEMENT = Object.new.freeze
82
+
83
+ # @return the visibility of each `def` (by identity) the file's bodies give one other than public:
84
+ # `:private`, `:protected` or `:module_function`; and {STATEMENT} for each call that is a direct statement,
85
+ # and for the include an idempotence guard runs ({Mixins.guarded}).
86
+ def defs(root)
87
+ found = {}.compare_by_identity
88
+ # The file's top level, and through {.read_body} every class, module or `class <<` body written as a direct
89
+ # statement of it or of another body so read: the bodies that run whenever the file loads. A body nested in
90
+ # a conditional, a block or a method may not run, so none of its calls or toggles is read and its `def`s
91
+ # are public.
92
+ read_body(root.statements, found, top_level: true) if root.is_a?(Prism::ProgramNode)
93
+ found
94
+ end
95
+
96
+ # Applies a {Pending} change to the binding each of its names has: `bound` maps `[class_name, name, kind]` to
97
+ # that binding and `homes` to the per-file table holding it, which is updated in place, so the change is not a
98
+ # second declaration. A name the class does not bind is one it inherits (`public :kp` in `class Integer` over
99
+ # `module Kernel; private def kp`), which Ruby copies onto the class with the new visibility: `overrides` gets
100
+ # `key => visibility` for {ProjectPatchedMethods#lookup_through_ancestors} to apply to the body it finds farther
101
+ # on. Yields each copy that binds (`module_function :x`'s singleton), at the call's line, for the caller to
102
+ # bind.
103
+ def apply(pending, bound, homes, overrides)
104
+ pending.names.each do |name|
105
+ key = [pending.class_name, name, pending.kind]
106
+ entry = bound[key]
107
+ next overrides[key] = pending.visibility if entry.nil?
108
+
109
+ changed, *copies = applied(entry, pending.visibility)
110
+ bound[key] = homes[key][key] = changed
111
+ copies.each { |copy| yield copy.with(source_path: pending.source_path, source_line: pending.source_line) }
112
+ end
113
+ end
114
+
115
+ # `entry` as `visibility` leaves it, then any copy that binds: `module_function` makes an instance method
116
+ # private and binds a public singleton copy, as Ruby does.
117
+ def applied(entry, visibility)
118
+ return [entry.with(visibility: visibility)] unless visibility == :module_function
119
+
120
+ copies = [entry.with(visibility: :private)]
121
+ copies << entry.with(kind: :singleton, visibility: :public) if entry.kind == :instance
122
+ copies
123
+ end
124
+
125
+ # The visibility call `node` is, as `[visibility, side]`, or nil: `side` is `:instance` for the
126
+ # {INSTANCE_CALLS} and `:singleton` for the {SINGLETON_CALLS}.
127
+ def call(node)
128
+ return nil unless node.is_a?(Prism::CallNode)
129
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
130
+
131
+ if (visibility = INSTANCE_CALLS[node.name])
132
+ [visibility, :instance]
133
+ elsif (visibility = SINGLETON_CALLS[node.name])
134
+ [visibility, :singleton]
135
+ end
136
+ end
137
+
138
+ # The include an idempotence guard runs (`X.include(M) unless X.include?(M)`) is read as a direct statement.
139
+ def mark_guarded(stmt, found, top_level)
140
+ guarded = Mixins.guarded(stmt, top_level)
141
+ found[guarded] = STATEMENT if guarded
142
+ end
143
+ private_class_method :mark_guarded
144
+
145
+ # `top_level` is true for the file's own statements only; a body it reads is a class or module body.
146
+ def read_body(body, found, top_level: false) # rubocop:disable Metrics/PerceivedComplexity
147
+ default = :public
148
+ direct_statements(body).each do |stmt|
149
+ found[stmt] = STATEMENT if stmt.is_a?(Prism::CallNode)
150
+ visibility, side = call(stmt)
151
+ if visibility && ModuleFunctionState.bare?(stmt)
152
+ default = visibility if side == :instance
153
+ elsif visibility
154
+ mark_arguments(stmt, visibility, side, found)
155
+ elsif stmt.is_a?(Prism::DefNode)
156
+ mark(stmt, default, found) if stmt.receiver.nil?
157
+ elsif BODIES.any? { |kind| stmt.is_a?(kind) }
158
+ read_body(stmt.body, found)
159
+ else
160
+ mark_guarded(stmt, found, top_level)
161
+ default = :public if bare_call_nested?(stmt)
162
+ mark_nested(stmt, default, found)
163
+ end
164
+ end
165
+ end
166
+ private_class_method :read_body
167
+
168
+ def direct_statements(body)
169
+ body = body.statements if body.is_a?(Prism::BeginNode)
170
+ body.is_a?(Prism::StatementsNode) ? body.body : []
171
+ end
172
+ private_class_method :direct_statements
173
+
174
+ def mark_arguments(call, visibility, side, found)
175
+ call.arguments&.arguments&.each do |arg|
176
+ next unless arg.is_a?(Prism::DefNode)
177
+ next unless (side == :singleton) == arg.receiver.is_a?(Prism::SelfNode)
178
+
179
+ mark(arg, visibility, found)
180
+ end
181
+ end
182
+ private_class_method :mark_arguments
183
+
184
+ def mark(def_node, visibility, found)
185
+ found[def_node] = visibility unless visibility == :public
186
+ end
187
+ private_class_method :mark
188
+
189
+ def mark_nested(node, default, found)
190
+ return unless node.is_a?(Prism::Node)
191
+ return if OPAQUE.any? { |kind| node.is_a?(kind) } && !node.is_a?(Prism::DefNode)
192
+
193
+ if node.is_a?(Prism::DefNode)
194
+ mark(node, default, found) if node.receiver.nil?
195
+ return
196
+ end
197
+ node.rigor_each_child { |child| mark_nested(child, default, found) }
198
+ end
199
+ private_class_method :mark_nested
200
+
201
+ def bare_call_nested?(node)
202
+ return false unless node.is_a?(Prism::Node)
203
+ return false if OPAQUE.any? { |kind| node.is_a?(kind) }
204
+
205
+ visibility, = call(node)
206
+ return true if visibility && ModuleFunctionState.bare?(node)
207
+
208
+ node.compact_child_nodes.any? { |child| bare_call_nested?(child) }
209
+ end
210
+ private_class_method :bare_call_nested?
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scope_indexer"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Issue #1614 — the cross-file discovery seed the paths that type a project WITHOUT a `check` run hand their
8
+ # per-file scopes: {Protection::DiscoverySeed} (the protection scan, `rigor coverage`) and
9
+ # {ParameterInferenceCollector} (the ADR-67 call-site pre-pass, which `check` itself runs under
10
+ # `parameter_inference:`). Each used to keep its own list of tables, and each dropped some that
11
+ # `Analysis::Runner#project_scope_seed_tables` carries, so a cross-file constant read `30` under `check` and
12
+ # `Dynamic` under coverage, and a parameter whose call sites pass it went uninferred.
13
+ #
14
+ # This module is the one list both paths read. {Analysis::Runner#project_scope_seed_tables} stays the
15
+ # reference; `spec/rigor/inference/project_seed_tables_spec.rb` compares the two over a project that fills
16
+ # every table, and requires each table the runner seeds and this one does not to be in {NOT_SEEDED}, with its
17
+ # reason.
18
+ module ProjectSeedTables
19
+ module_function
20
+
21
+ # The def-index slots that seed a per-file scope as they are, each with the {Scope::DiscoveryIndex} member it
22
+ # fills.
23
+ SLOTS = {
24
+ def_nodes: :discovered_def_nodes, def_nestings: :discovered_def_nestings,
25
+ singleton_def_nodes: :discovered_singleton_def_nodes, def_sources: :discovered_def_sources,
26
+ singleton_def_sources: :discovered_singleton_def_sources, superclasses: :discovered_superclasses,
27
+ header_nestings: :discovered_header_nestings, includes: :discovered_includes,
28
+ prepends: :discovered_prepends, extends: :discovered_extends, unpositioned_mixins: :unpositioned_mixins,
29
+ class_sources: :discovered_class_sources, method_visibilities: :discovered_method_visibilities,
30
+ methods: :discovered_methods, parameter_envelopes: :discovered_parameter_envelopes,
31
+ data_member_layouts: :data_member_layouts, struct_member_layouts: :struct_member_layouts,
32
+ deferred_ranges: :discovered_deferred_ranges, refinements: :discovered_refinements,
33
+ global_write_census: :discovered_global_write_census
34
+ }.freeze
35
+
36
+ # The tables `Runner#project_scope_seed_tables` can seed that {.from_index} leaves out, and why. Parity is over
37
+ # the table LIST: the runner's opt-in additions to a table's content are not reproduced here.
38
+ # `discovered_refinements` is the project's alone (the runner unions in the refinements of gems under
39
+ # `dependencies.source_inference:`), and no `discovery_seed:` base is merged in (the runner can start from one).
40
+ NOT_SEEDED = {
41
+ run_generation: "the identity token of a check run's return memo; these paths run no check",
42
+ constant_sources: "read by dependency recording and the #663 pre_eval retraction; a seed carries it only " \
43
+ "when asked (DiscoverySeed.tables_for_buffer(constant_sources: true), the editor publish)",
44
+ param_inferred_types: "the parameter-inference output itself; each caller adds its own " \
45
+ "(DiscoverySeed.build, coverage under parameter_inference:, the collector's last round)"
46
+ }.freeze
47
+
48
+ # The seed tables over `paths`, from one parse of each file.
49
+ def for_paths(paths)
50
+ from_index(ScopeIndexer.discovered_project_index_for_paths(paths))
51
+ end
52
+
53
+ # Maps a `{ classes:, def_index: }` discovery index onto {Scope::DiscoveryIndex} member names. Empty tables are
54
+ # dropped so an empty seed stays `{}`; a paired member and its sibling are dropped only together.
55
+ #
56
+ # `in_source_constants` is the project's literal constant table alone: the `pre_eval:` publication a `check` run
57
+ # joins to it types each listed file's writes against the RBS environment, which these paths do not build first.
58
+ def from_index(index)
59
+ def_index = index.fetch(:def_index)
60
+ tables = { discovered_classes: index.fetch(:classes) }
61
+ SLOTS.each { |slot, member| tables[member] = def_index.fetch(slot) }
62
+ tables.merge!(constant_tables(def_index.fetch(:constant_values), def_index.fetch(:constant_writes)))
63
+ tables.merge!(def_index.fetch(:siblings))
64
+ Scope::DiscoveryIndex.compact_pairs(
65
+ tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
66
+ )
67
+ end
68
+
69
+ def constant_tables(values, writes)
70
+ {
71
+ in_source_constants: values,
72
+ published_constant_names: published_constant_names(values),
73
+ constant_writers: constant_writers(writes),
74
+ constant_shadowers: constant_shadowers(writes)
75
+ }
76
+ end
77
+
78
+ # Issue #644 — the last segments of the published constant table, the run-wide half of
79
+ # `Scope#published_constant?`.
80
+ def published_constant_names(values)
81
+ values.keys.to_set { |name| name.split("::").last }.freeze
82
+ end
83
+
84
+ # Issue #617 — the censused names that bind, grouped by last segment (`Scope#bound_constant_names`). A name some
85
+ # write other than a memo `||=` touches always binds. A memo-only name binds once memos of its segment span two
86
+ # files: either may load first and set the constant the other then reads, so only a memo no other file shares
87
+ # keeps the idiom's reading. Files are counted expanded, because discovery can walk one file under two
88
+ # spellings (`lib/b.rb`, `./lib/b.rb`).
89
+ def constant_writers(writes)
90
+ memo_files = memo_files_by_segment(writes)
91
+ writes.each_with_object({}) do |(name, by_path), index|
92
+ segment = name.split("::").last
93
+ next if memo_only?(by_path) && memo_files.fetch(segment).size < 2
94
+
95
+ (index[segment] ||= []) << name
96
+ end.each_value(&:freeze).freeze
97
+ end
98
+
99
+ # Issue #1290 — the censused names some write other than a memo `||=` assigns, grouped by last segment
100
+ # (`Scope#shadowing_constant_names`): the ones that exist whether or not an outer constant of the name does, and
101
+ # so stop the lexical ladder above it. A memo-only name is left out however many files memoize it, unlike in
102
+ # {.constant_writers}: `X ||= v` resolves `X` through the very lookup whose lower rung the stop would replace,
103
+ # so wherever that rung answers, the memo finds it and never creates the name.
104
+ def constant_shadowers(writes)
105
+ writes.each_with_object({}) do |(name, by_path), index|
106
+ (index[name.split("::").last] ||= []) << name unless memo_only?(by_path)
107
+ end.each_value(&:freeze).freeze
108
+ end
109
+
110
+ def memo_files_by_segment(writes)
111
+ writes.each_with_object({}) do |(name, by_path), files|
112
+ set = (files[name.split("::").last] ||= Set.new)
113
+ by_path.each { |path, descriptor| set << File.absolute_path(path) if memo_descriptor?(descriptor) }
114
+ end
115
+ end
116
+
117
+ def memo_only?(by_path) = by_path.each_value.all? { |descriptor| memo_descriptor?(descriptor) }
118
+ def memo_descriptor?(descriptor) = descriptor == ScopeIndexer::CONSTANT_MEMO
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Issue #2127 — whether a call to a PROJECT method may run the block it passes more than once, read off the
10
+ # callee's body. {BlockRepetition.may_repeat?} answers only for the core iterators {ClosureEscapeAnalyzer}
11
+ # catalogues, so `def yielder = (yield; yield)` or the idiomatic wrapper `def each = @items.each { |i| yield i }`
12
+ # counted as running the block once, and a `return` in the block that only a later run reaches (`each { return
13
+ # false if (seen += 1) > 3 }`) never joined the method's return type.
14
+ #
15
+ # Only the later-run `return` pass asks this (`StatementEvaluator#collect_repeated_block_returns`); the block's
16
+ # entry scopes and the ADR-56 write-back keep {BlockRepetition}'s rule. That pass only widens the method's
17
+ # return type, so the rule here errs toward "repeats". The callee repeats when its body, outside a nested `def`
18
+ # (a `yield` in a class body is a syntax error inside a method):
19
+ #
20
+ # - holds two or more sites that run the block (`yield`, or `call` / `yield` / `===` / `[]` on the `&blk`
21
+ # parameter), or one inside a loop, a block or a lambda, which may run it many times; or
22
+ # - hands the block on (`each(&blk)`, `each(&)`, `...` forwarding) or reads the `&blk` parameter any other way
23
+ # (stores it, passes it as an argument), so code the walk does not see may run it any number of times.
24
+ #
25
+ # A callee the analysis cannot resolve to a project `def` (an unknown receiver, a method defined by
26
+ # metaprogramming) answers false, as before.
27
+ module ProjectYielder
28
+ module_function
29
+
30
+ BLOCK_RUN_CALLS = Set[:call, :yield, :===, :[]].freeze
31
+ REPEATING_NODES = Set[Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode,
32
+ Prism::LambdaNode].freeze
33
+ private_constant :BLOCK_RUN_CALLS, :REPEATING_NODES
34
+
35
+ def may_run_block_repeatedly?(call_node, receiver_type, scope)
36
+ def_node = callee_def(call_node, receiver_type, scope)
37
+ return false unless def_node.is_a?(Prism::DefNode) && def_node.body
38
+
39
+ repeats?(def_node)
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ # Issue #2301 — whether the callee hands the block it is called with on to another call (`g(x, &)`,
45
+ # `g(x, &blk)`), outside a nested `def`. The block-return pass types such a forward's target `yield` as the
46
+ # caller's block ({ExpressionTyper#forwarded_block_return_type}), and the target may run it any number of
47
+ # times, so the caller's block is typed as one that may repeat. Answers false where the callee does not resolve.
48
+ def hands_block_on?(call_node, receiver_type, scope)
49
+ def_node = callee_def(call_node, receiver_type, scope)
50
+ return false unless def_node.is_a?(Prism::DefNode) && def_node.body
51
+
52
+ hand_off_in?(def_node.body, def_node.parameters&.block&.name)
53
+ rescue StandardError
54
+ false
55
+ end
56
+
57
+ def hand_off_in?(node, block_name)
58
+ return false if node.is_a?(Prism::DefNode)
59
+ return true if node.is_a?(Prism::BlockArgumentNode) && forwards_block?(node, block_name)
60
+
61
+ node.compact_child_nodes.any? { |child| hand_off_in?(child, block_name) }
62
+ end
63
+
64
+ # The `def` an instance call on a project class, a singleton call on one, or an implicit-self call to a
65
+ # top-level method resolves to.
66
+ def callee_def(call_node, receiver_type, scope)
67
+ name = call_node.name
68
+ found =
69
+ case receiver_type
70
+ when Type::Nominal then scope.user_def_through_ancestors(receiver_type.class_name, name).first
71
+ when Type::Singleton then scope.singleton_def_for(receiver_type.class_name, name)
72
+ end
73
+ found || (call_node.receiver.nil? ? scope.top_level_def_for(name) : nil)
74
+ end
75
+
76
+ def repeats?(def_node)
77
+ block_param = def_node.parameters&.block
78
+ counter = { sites: 0, repeats: false }
79
+ walk(def_node.body, block_param&.name, false, counter)
80
+ counter[:repeats] || counter[:sites] >= 2
81
+ end
82
+
83
+ def walk(node, block_name, repeating, counter)
84
+ return if counter[:repeats] || node.is_a?(Prism::DefNode)
85
+
86
+ skipped = visit(node, block_name, repeating, counter)
87
+ inner = repeating || REPEATING_NODES.include?(node.class)
88
+ node.compact_child_nodes.each do |child|
89
+ walk(child, block_name, inner, counter) unless child.equal?(skipped)
90
+ end
91
+ end
92
+
93
+ # Counts `node` and answers the child the walk must not visit: the `blk` receiver of a `blk.call`, which is a
94
+ # run, not a hand-off.
95
+ def visit(node, block_name, repeating, counter)
96
+ case node
97
+ when Prism::YieldNode then run_site(repeating, counter)
98
+ when Prism::ForwardingArgumentsNode then counter[:repeats] = true
99
+ when Prism::BlockArgumentNode then counter[:repeats] = true if forwards_block?(node, block_name)
100
+ when Prism::CallNode
101
+ return unless block_run_call?(node, block_name)
102
+
103
+ run_site(repeating, counter)
104
+ node.receiver
105
+ when Prism::LocalVariableReadNode then counter[:repeats] = true if block_name && node.name == block_name
106
+ end
107
+ end
108
+
109
+ def run_site(repeating, counter)
110
+ counter[:sites] += 1
111
+ counter[:repeats] = true if repeating
112
+ end
113
+
114
+ # `blk.call` and its spellings.
115
+ def block_run_call?(node, block_name)
116
+ receiver = node.receiver
117
+ !block_name.nil? && receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == block_name &&
118
+ BLOCK_RUN_CALLS.include?(node.name)
119
+ end
120
+
121
+ # `&blk` or an anonymous `&` handed to another call.
122
+ def forwards_block?(node, block_name)
123
+ expression = node.expression
124
+ expression.nil? || (expression.is_a?(Prism::LocalVariableReadNode) && expression.name == block_name)
125
+ end
126
+ end
127
+ end
128
+ end