rigortype 0.4.3 → 0.4.5

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../type"
6
6
  require_relative "../reflection"
7
7
  require_relative "../ast"
8
+ require_relative "clause_widening"
8
9
  require_relative "../source/constant_path"
9
10
  require_relative "../source/node_children"
10
11
  require_relative "../source/node_walker"
@@ -41,6 +42,7 @@ require_relative "receiver_alias"
41
42
  require_relative "repeated_or_writes"
42
43
  require_relative "singleton_object_constant"
43
44
  require_relative "refined_dispatch"
45
+ require_relative "singleton_external_ahead"
44
46
  require_relative "stored_block_call"
45
47
  require_relative "struct_fold_safety"
46
48
  require_relative "unknown_store_widening"
@@ -321,6 +323,37 @@ module Rigor
321
323
  try_project_singleton_inference(receiver, node, arg_types, method_name: :[])
322
324
  end
323
325
 
326
+ # Issue #1889 — the type a block-form `inject` / `reduce` accumulator parameter holds across the block's runs,
327
+ # or nil when the call is not one {#try_block_inject_fold} folds. The RBS tier binds the parameter to the seed
328
+ # alone, so the statement pass and the ADR-56 write-back typed every run's accumulator as `0` for `inject(0)`,
329
+ # and a capture assigned from it (`m = a`) was written back as `0`.
330
+ #
331
+ # The fold records the answer as it types the call ({#record_inject_accumulator}): the constant arm the
332
+ # accumulator values the block receives (`0 | 1` for `[1, 2].inject(0) { |a, e| a + e }`), the nominal
333
+ # arm its seed joined with the converged accumulator. The statement pass types the call from the same scope
334
+ # just before it enters the block, so the recorded answer is read back rather than computed again — each
335
+ # computation types the body, and a nested `inject` multiplied that by its depth.
336
+ def inject_accumulator_type(call_node, receiver, arg_types)
337
+ block = call_node.block
338
+ return nil unless INJECT_METHODS.include?(call_node.name) && block.is_a?(Prism::BlockNode)
339
+
340
+ recorded = self.class.recorded_inject_accumulator(block, scope)
341
+ return recorded if recorded
342
+
343
+ try_block_inject_fold(call_node, receiver, arg_types)
344
+ self.class.recorded_inject_accumulator(block, scope)
345
+ end
346
+
347
+ INJECT_ACCUMULATOR_KEY = :__rigor_inject_accumulator__
348
+ private_constant :INJECT_ACCUMULATOR_KEY
349
+
350
+ # The accumulator type {#record_inject_accumulator} stored for `block` when the call was typed from `scope`
351
+ # itself (by identity), else nil.
352
+ def self.recorded_inject_accumulator(block, scope)
353
+ slot = Thread.current[INJECT_ACCUMULATOR_KEY]&.[](block)
354
+ slot && slot[0].equal?(scope) ? slot[1] : nil
355
+ end
356
+
324
357
  # ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
325
358
  # that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
326
359
  # recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
@@ -1047,12 +1080,15 @@ module Rigor
1047
1080
  #
1048
1081
  # The `case ... in` pattern-matching form (`CaseMatchNode`) and the predicate-less form (`case; when
1049
1082
  # c1; ...`) bypass the `===` analysis: pattern matching has richer semantics, and a predicate-less
1050
- # `case` reduces to a `if c1; ...; elsif c2` chain that statement-level narrowing already handles.
1083
+ # `case` reduces to a `if c1; ...; elsif c2` chain. Issue #1495 — like {#type_of_conditional}, that
1084
+ # chain delegates to the statement evaluator, which narrows each arm by its conditions' edges, so
1085
+ # `[case; when x.nil? then "n"; else x end]` reads `"n" | String` as the statement form does.
1051
1086
  # Issue #1429 — each arm is typed under the subject's clause narrowing (`Narrowing.case_when_scopes`), the
1052
1087
  # scope the statement evaluator runs the arm in: `when Symbol then n` on `n: Integer | Symbol` answers
1053
1088
  # `Symbol`, and the `else` arm reads the subject every earlier clause has ruled out.
1054
1089
  def type_of_case(node)
1055
- return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
1090
+ return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode)
1091
+ return scope.evaluate(node, tracer: tracer).first if node.predicate.nil?
1056
1092
 
1057
1093
  subject_type = type_of(node.predicate)
1058
1094
  candidates = []
@@ -1174,17 +1210,22 @@ module Rigor
1174
1210
  Type::Combinator.constant_of(nil)
1175
1211
  end
1176
1212
 
1177
- rescue_types = rescue_chain_types(rescue_clause)
1213
+ rescue_types = rescue_chain_types(rescue_clause, node.statements)
1178
1214
  Type::Combinator.union(primary_type, *rescue_types)
1179
1215
  end
1180
1216
 
1181
1217
  # Issue #1360 — the evaluator binds `$!` in a rescue clause it enters; a clause typed here, such as one in a
1182
1218
  # `begin` passed as an argument, reads `$!`, `$@` and `$?` unbound rather than an enclosing clause's.
1183
- def rescue_chain_types(rescue_node)
1184
- arm_typer = typer_under(rescue_arm_scope)
1219
+ #
1220
+ # Issue #1839 — it also reads each name the `body` writes as `Dynamic[top]` ({ClauseWidening}), as the evaluator's
1221
+ # rescue clause does: `[begin; conn = make; work; rescue; conn; end]` holds the connection, not the entry's `nil`.
1222
+ def rescue_chain_types(rescue_node, body)
1223
+ arm_scope = rescue_arm_scope
1224
+ writes = ClauseWidening.writes([body])
1185
1225
  types = []
1186
1226
  current = rescue_node
1187
1227
  while current
1228
+ arm_typer = typer_under(ClauseWidening.widen(arm_scope, writes, current.statements) || arm_scope)
1188
1229
  types << arm_typer.send(:statements_or_nil, current.statements)
1189
1230
  current = current.subsequent
1190
1231
  end
@@ -1245,11 +1286,23 @@ module Rigor
1245
1286
  def type_of_range(node)
1246
1287
  left_static, left = static_range_endpoint(node.left)
1247
1288
  right_static, right = static_range_endpoint(node.right)
1248
- return Type::Combinator.constant_of(Range.new(left, right, node.exclude_end?)) if left_static && right_static
1289
+ if left_static && right_static
1290
+ range = static_range_value(left, right, node.exclude_end?)
1291
+ return Type::Combinator.constant_of(range) if range
1292
+ end
1249
1293
 
1250
1294
  nominal_range_for_endpoints(node.left, node.right)
1251
1295
  end
1252
1296
 
1297
+ # The `Range` the static endpoints denote, or nil when Ruby would refuse to build it (`1.."a"`,
1298
+ # `"a"..1` raise `ArgumentError: bad value for range`). The literal is a runtime error,
1299
+ # not a constant, so the fold declines and the caller types it as a plain Range.
1300
+ def static_range_value(left, right, exclude_end)
1301
+ Range.new(left, right, exclude_end)
1302
+ rescue ArgumentError
1303
+ nil
1304
+ end
1305
+
1253
1306
  # Derives `Nominal[Range, [T]]` from the endpoint expression types when at least one endpoint is
1254
1307
  # statically typeable. The element parameter is the union of the endpoint types (lifted from
1255
1308
  # `Constant<v>` to `Nominal<v.class>` so the carrier matches what `Range#each` would yield). Falls back
@@ -2549,6 +2602,10 @@ module Rigor
2549
2602
 
2550
2603
  def_node, owner = resolve_singleton_def_with_owner(receiver.class_name, method_name)
2551
2604
  return nil if def_node.nil?
2605
+ # Issue #1936 — a contested slot holds a `def` a later definer may have replaced
2606
+ # (`singleton_class.alias_method :x, :y` after `def self.x`), or one that may not have run: its body is not
2607
+ # proven to be the method Ruby calls, so the call falls through to `Dynamic`.
2608
+ return nil if scope.discovery.contested?(:discovered_singleton_def_nodes, [owner, method_name.to_sym])
2552
2609
 
2553
2610
  result = infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
2554
2611
  return result if result.nil?
@@ -2563,6 +2620,10 @@ module Rigor
2563
2620
  # `discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`, and this walk reads a
2564
2621
  # fourth table those three do not imply. Keying on what the answer actually depends on is the #682
2565
2622
  # lesson — a memo that outlives its inputs serves one scope's answer to another.
2623
+ #
2624
+ # It is the TYPING read only, so it also withholds (`[nil, nil]`, the call types `Dynamic`) where an external
2625
+ # ancestor may answer ahead of the definer found (#1572: `extend M; extend Enumerable` makes `C.to_a`
2626
+ # `Enumerable#to_a`, which the reader passes over). The existence reads keep the reader's own answer.
2566
2627
  def resolve_singleton_def_with_owner(class_name, method_name)
2567
2628
  by_singleton = (class_graph_buckets[:singleton_def] ||= {}.compare_by_identity)
2568
2629
  cache = (by_singleton[scope.discovered_singleton_def_nodes] ||= {})
@@ -2570,7 +2631,10 @@ module Rigor
2570
2631
  key = method_name.to_sym
2571
2632
  return table[key] if table.key?(key)
2572
2633
 
2573
- table[key] = scope.singleton_def_through_ancestors(class_name, method_name)
2634
+ found = scope.singleton_def_through_ancestors(class_name, method_name)
2635
+ owner = found.last
2636
+ found = [nil, nil] if owner && SingletonExternalAhead.withhold?(scope, class_name, method_name, owner)
2637
+ table[key] = found
2574
2638
  end
2575
2639
 
2576
2640
  # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
@@ -3883,6 +3947,12 @@ module Rigor
3883
3947
  # default — or `Dynamic` when the shape is open or absent; `**kwrest`, `&block`, and destructured
3884
3948
  # positionals bind `Dynamic`. Binding `Dynamic` is monotone-safe (wider in → wider out), so the only
3885
3949
  # declines left are the shapes where positional CORRESPONDENCE itself is unknowable.
3950
+ #
3951
+ # The scope carries no `source_path:`: the body is re-typed for call sites in any file, so a rule that
3952
+ # asks "did this file write it?" by comparing `scope.source_path` with census paths answers wrongly
3953
+ # through it. Record a per-file write kind in the census instead, as {ScopeIndexer::CONSTANT_MEMO} does
3954
+ # (#1276). Setting `source_path` here was rejected: plugins read it, and the return memo would become
3955
+ # caller-dependent.
3886
3956
  def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
3887
3957
  params = def_node.parameters
3888
3958
  locals = bind_params_from_call_types(params, arg_types)
@@ -3893,7 +3963,7 @@ module Rigor
3893
3963
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
3894
3964
  # discovery index is inherited whole by reference (ADR-53 Track A); the hand-copied per-field list
3895
3965
  # this replaces had silently dropped `data_member_layouts` and `discovered_method_visibilities`.
3896
- Scope.new(
3966
+ body_scope = Scope.new(
3897
3967
  environment: scope.environment,
3898
3968
  locals: locals.freeze,
3899
3969
  self_type: receiver,
@@ -3911,6 +3981,7 @@ module Rigor
3911
3981
  # Issue #1358 — the callee runs in a frame of its own ({Scope#with_match_frame}).
3912
3982
  match_frame: MatchRebinding::Frame.new(def_node.body, def_node.parameters)
3913
3983
  )
3984
+ body_scope.with_repeated_or_writes(RepeatedOrWrites.straight_line_sites(def_node.body, body_scope))
3914
3985
  end
3915
3986
 
3916
3987
  # Issue #681 / #707 — the ONE place in `lib/` that answers "what `Module.nesting` was recorded for this
@@ -5436,13 +5507,25 @@ module Rigor
5436
5507
  return nil if acc.nil?
5437
5508
 
5438
5509
  captured = inject_captured_bindings(block, rest.map { |value| Type::Combinator.constant_of(value) })
5510
+ received = [acc]
5439
5511
  rest.each_with_index do |element_value, position|
5440
5512
  acc = inject_constant_step(block, acc, element_value, captured, position)
5441
5513
  return nil if acc.nil?
5514
+
5515
+ received << acc
5442
5516
  end
5517
+ received.pop if received.size > 1
5518
+ record_inject_accumulator(block, Type::Combinator.union(*received))
5443
5519
  acc
5444
5520
  end
5445
5521
 
5522
+ # Records `type` as the accumulator `block` receives when typed from this typer's scope
5523
+ # ({.recorded_inject_accumulator}). The keys are weak, so a finished file's nodes do not stay reachable, and
5524
+ # the values strong, so a record outlives a collection while its node does. Prism nodes compare by identity.
5525
+ def record_inject_accumulator(block, type)
5526
+ (Thread.current[INJECT_ACCUMULATOR_KEY] ||= ObjectSpace::WeakKeyMap.new)[block] = [scope, type].freeze
5527
+ end
5528
+
5446
5529
  # Issue #1332 — the #587 (b) captured-block binding ({#captured_block_bindings}) both inject arms lay under
5447
5530
  # every step, as the Tuple / Range per-element folds and the per-pair `HashShape` fold do. Without it each
5448
5531
  # step reads a captured variable at its entry binding, so a body that rebinds or mutates one (`n += 1`,
@@ -5550,7 +5633,9 @@ module Rigor
5550
5633
  result.nil? ? {} : { __inject_acc__: result }
5551
5634
  end
5552
5635
  )
5553
- converged[:__inject_acc__] || seed_acc
5636
+ result = converged[:__inject_acc__] || seed_acc
5637
+ record_inject_accumulator(block, Type::Combinator.union(seed_acc, result))
5638
+ result
5554
5639
  end
5555
5640
 
5556
5641
  def assemble_per_element_result(method_name, per_position, element_types)
@@ -223,6 +223,12 @@ module Rigor
223
223
  end || Type::Combinator.untyped
224
224
  end
225
225
 
226
+ # Issue #1596 — true when `name` runs code chosen by name or by a String, or a block or String it is given
227
+ # ({CODE_RUNNING_NAMES}, {BLOCK_OR_CODE_NAMES}), whatever its receiver.
228
+ def code_running_name?(name)
229
+ CODE_RUNNING_NAMES.include?(name) || BLOCK_OR_CODE_NAMES.include?(name)
230
+ end
231
+
226
232
  # True when `method_name` on a value of `type` may run foreign code ({.foreign_target?}).
227
233
  def type_method_foreign?(type, method_name, scope)
228
234
  targets = ProjectMethodOwnership.targets(type)
@@ -326,8 +332,7 @@ module Rigor
326
332
  private_class_method :scan, :guarded_ivar_write?, :scan_call, :receiver_targets, :foreign_target?,
327
333
  :universal_delegate_foreign?, :method_owner, :deferred_block_call?,
328
334
  :compound_write_foreign?,
329
- :compound_receiver_type, :compound_accessors, :compound_read_type, :variable_type,
330
- :type_method_foreign?
335
+ :compound_receiver_type, :compound_accessors, :compound_read_type, :variable_type
331
336
  end
332
337
 
333
338
  module GuardRebinding
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "reflective_ivar_write"
7
+ require_relative "refined_dispatch/own_method"
8
+ require_relative "self_call_ivar_writes"
9
+
10
+ module Rigor
11
+ module Inference
12
+ # The intervening-call reset of instance variables. An implicit-self or `self.` call may write any instance
13
+ # variable of `self`, so past one each instance variable whose binding is narrower than its class-ivar seed reads
14
+ # the union of the two again; a call proven to write only some ({SelfCallIvarWrites.of}, issue #1596) widens only
15
+ # those. A call on another receiver cannot reach `self`'s instance variables and widens nothing.
16
+ #
17
+ # {.widen} is the reset itself, which the statement evaluator applies past a statement call
18
+ # (`StatementEvaluator#invalidate_ivars_for_intervening_call`). Issue #1963 — {.after_operand} applies it past
19
+ # the calls in a condition operand, where a narrowing an earlier operand or clause made would otherwise survive
20
+ # them: `if @x.nil? && reset!` read `@x` as `nil` in the body although `reset!` assigns it.
21
+ module InterveningIvarReset
22
+ # Nodes whose body does not run where it is written, or runs on another `self`: a `def`, and a class or
23
+ # module body, where `@x` is the class's own instance variable.
24
+ NON_RUNNING_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
25
+ # Issue #1402 — the nodes that run code the program does not show on `self`'s behalf: `yield` runs the
26
+ # method's block, and `super` the superclass's method on `self`.
27
+ UNKNOWN_CODE_NODES = [Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode].freeze
28
+ # Issue #1402 — the names of a proc's call (`blk.call`, `blk.()`, `blk.yield`), which runs the block the method
29
+ # was given as `yield` does.
30
+ PROC_CALL_NAMES = Set[:call, :yield].freeze
31
+ # Issue #1402 — the calls whose block runs with `self` bound to the receiver: on a receiver that is provably
32
+ # another object ({.foreign_self_block?}), an instance variable the block writes, and a method it calls
33
+ # without a receiver, are that object's.
34
+ SELF_REBINDING_BLOCK_CALLS = Set[
35
+ :instance_eval, :instance_exec, :class_eval, :class_exec, :module_eval, :module_exec, :define_method,
36
+ :define_singleton_method
37
+ ].freeze
38
+ # The classes whose instances may be a class or module object: a `self` of one of them may be the receiver of
39
+ # `Foo.class_eval`, and an instance of one of them may be a `self` that is a class object.
40
+ CLASS_OBJECT_CLASSES = Set["Class", "Module", "Object", "BasicObject"].freeze
41
+ private_constant :NON_RUNNING_NODES, :UNKNOWN_CODE_NODES, :PROC_CALL_NAMES, :SELF_REBINDING_BLOCK_CALLS,
42
+ :CLASS_OBJECT_CLASSES
43
+
44
+ module_function
45
+
46
+ # True when `node` is a call that may write `self`'s instance variables: an implicit-self or `self.` call
47
+ # ({.self_call?}). Issue #1402 — and a `yield`, a `super`, or a proc's call on a local ({.proc_call?}), which
48
+ # run code the program does not show; {SelfCallIvarWrites.of} proves no write set for any of them.
49
+ def candidate?(node)
50
+ return true if UNKNOWN_CODE_NODES.any? { |klass| node.is_a?(klass) }
51
+ return false unless node.is_a?(Prism::CallNode)
52
+
53
+ self_call?(node) || proc_call?(node)
54
+ end
55
+
56
+ # Whether `node`, a call, has no receiver or `self` as its receiver.
57
+ def self_call?(node)
58
+ receiver = node.receiver
59
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
60
+ end
61
+
62
+ # Issue #1402 — `blk.call` / `blk.()` / `blk.yield` on a local: the method's own `&blk`, or a proc it was
63
+ # handed, runs code that may call back into `self`, as `yield` does. Read by name, since an unannotated
64
+ # `&blk` binds `Dynamic`.
65
+ def proc_call?(node)
66
+ PROC_CALL_NAMES.include?(node.name) && node.receiver.is_a?(Prism::LocalVariableReadNode)
67
+ end
68
+
69
+ # Issue #1402 — whether `call_node`'s block runs on an object that is provably not `self`: a
70
+ # {SELF_REBINDING_BLOCK_CALLS} call whose receiver, typed in `scope`, cannot be `self` ({.other_object?}).
71
+ # A receiver that may be `self` at run time (`obj = self; obj.instance_eval { … }`, a block parameter, an
72
+ # `Object` or `Dynamic` value, a class the analysis cannot order against `self`'s) is not foreign.
73
+ def foreign_self_block?(call_node, scope)
74
+ return false unless call_node.is_a?(Prism::CallNode) && SELF_REBINDING_BLOCK_CALLS.include?(call_node.name)
75
+ return false if self_call?(call_node)
76
+
77
+ other_object?(scope.type_of(call_node.receiver), scope)
78
+ rescue StandardError
79
+ false
80
+ end
81
+
82
+ # Whether a value of `type` cannot be `scope`'s `self`. Only an instance method of a class answers: `self` is
83
+ # then an instance of that class (one other than `Class`, `Module`, `Object` and `BasicObject`, and no module,
84
+ # declared in source or RBS), and a class object (`Singleton[X]`: `self.class`, a constant naming a class or
85
+ # module) is not one. Where `self` is a class object, or a module's instance (`module_function`, `extend
86
+ # self`), any receiver may be `self` — a subclass, the module itself — and none is foreign. An instance
87
+ # receiver is never foreign: its class may be a subclass of `self`'s that the environment cannot order.
88
+ def other_object?(type, scope)
89
+ self_type = scope.self_type
90
+ return false unless self_type.is_a?(Type::Nominal) && plain_class?(self_type.class_name, scope)
91
+
92
+ type.is_a?(Type::Singleton)
93
+ end
94
+
95
+ # Whether `class_name` names a class whose instances are no class or module object, and not a module.
96
+ def plain_class?(class_name, scope)
97
+ name = class_name.to_s.delete_prefix("::")
98
+ !CLASS_OBJECT_CLASSES.include?(name) && !RefinedDispatch::OwnMethod.module_name?(scope, name) &&
99
+ !module_subclass?(name, scope)
100
+ end
101
+
102
+ # Whether `name` may be a subclass of `Module` (`class Meta < Module`), whose instance is a module object.
103
+ # Read off the class's instance `Scope::ResolutionChain`, which resolves a superclass written relative to the
104
+ # subclass's namespace (`class Meta < Base` in `module NS`) as Ruby does: true when an entry is one the
105
+ # environment orders `:subclass` or `:equal` to `Module` (`Module` itself, `Class`, a project class whose
106
+ # signature declares it, an external superclass), when an external superclass names no class the
107
+ # environment knows (it may be `Module`), or when the chain is truncated or unsettled.
108
+ def module_subclass?(name, scope)
109
+ chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
110
+ return true if chain.truncated? || chain.unsettled?
111
+
112
+ environment = scope.environment
113
+ chain.search(scope) { |entry| module_entry?(entry, environment) || nil } ? true : false
114
+ end
115
+ private_class_method :module_subclass?
116
+
117
+ # Whether a chain entry makes the class a possible `Module` subclass: a project entry {.module_like?}, or an
118
+ # external superclass that names no class the environment knows, or one {.module_like?}. An external mixin
119
+ # (`include Enumerable`) does not.
120
+ def module_entry?(entry, environment)
121
+ return module_like?(entry.name.to_s, environment) unless entry.external?
122
+ return false unless entry.superclass_edge
123
+
124
+ known = entry.candidates.map { |candidate| candidate.to_s.delete_prefix("::") }
125
+ .select { |candidate| environment.class_known?(candidate) }
126
+ known.empty? || known.any? { |candidate| module_like?(candidate, environment) }
127
+ end
128
+ private_class_method :module_entry?
129
+
130
+ def module_like?(name, environment)
131
+ %i[subclass equal].include?(environment.class_ordering(name, "Module"))
132
+ end
133
+ private_class_method :module_like?
134
+ private_class_method :plain_class?
135
+
136
+ # Issue #1402 — `scope` past a statement call whose receiver or arguments run code the program does not
137
+ # show (`@items << yield`, `puts(yield)`, `log(blk.call)`, `items.each(&blk)`): {.widen} over every narrowed
138
+ # name, as past a statement `yield`. Every other call in them is the operand walk's.
139
+ def after_operands(call_node, scope)
140
+ return scope unless narrowed_ivars?(scope)
141
+
142
+ found = []
143
+ collect_calls(call_node.receiver, false, found, scope, unknown_only: true)
144
+ collect_calls(call_node.arguments, false, found, scope, unknown_only: true)
145
+ block = call_node.block
146
+ found << block if block.is_a?(Prism::BlockArgumentNode) && block.expression.is_a?(Prism::LocalVariableReadNode)
147
+ return scope if found.empty?
148
+
149
+ widen(scope, mark: true) { nil }
150
+ end
151
+
152
+ # Issue #1402 — `scope` past a call on another receiver whose literal block may make a {.candidate?} call
153
+ # (`[1].each { reset }`, `items.each { yield }`): {.widen} over the names those calls may write, as
154
+ # {.after_operand} widens past the calls in a condition. In a block that runs on another object
155
+ # ({.foreign_self_block?}) only a `yield`, a `super` or a proc's call counts: its implicit-self calls are that
156
+ # object's, while the method's block runs as it always does. A literal-name
157
+ # `instance_variable_set` on `self` ({ReflectiveIvarWrite.literal_name}) writes the one ivar it names, which
158
+ # the block's captured-binding write-back already joins, so it is not counted.
159
+ def after_block(call_node, scope)
160
+ block = call_node.block
161
+ return scope unless block.is_a?(Prism::BlockNode)
162
+ return scope unless narrowed_ivars?(scope)
163
+
164
+ calls = []
165
+ if foreign_self_block?(call_node, scope)
166
+ collect_calls(block, false, calls, scope, unknown_only: true)
167
+ else
168
+ collect_calls(block, false, calls, scope)
169
+ calls.reject! { |call| ReflectiveIvarWrite.literal_name(call) }
170
+ end
171
+ return scope if calls.empty?
172
+
173
+ widen(scope, mark: true) { proven_writes(calls, scope) }
174
+ end
175
+
176
+ # `scope` with each instance variable narrowed below its class-ivar seed widened back over the seed. The block
177
+ # answers the names the call may write, or nil for any; it runs at most once, and only when an instance
178
+ # variable is narrowed, since a proof costs a read of the callee. An instance variable whose binding carries
179
+ # the declaration mark keeps it (issue #1889). With `mark: true` a binding whose narrowing had ruled `nil` out
180
+ # takes the mark when widened, so a `nil` only the seed brings back is not fuel for
181
+ # `call.possible-nil-receiver` (ADR-58), as a declared one is not. A narrowing that kept `nil`
182
+ # (`return if @tag`, `@tag.nil?`) widens unmarked, so a read the program really makes on `nil` still reports.
183
+ def widen(scope, mark: false)
184
+ seed = class_ivar_seed(scope)
185
+ return scope if seed.nil? || seed.empty?
186
+
187
+ computed = false
188
+ writes = nil
189
+ seed.reduce(scope) do |widened, (name, seed_type)|
190
+ local_type = scope.ivar(name)
191
+ next widened if local_type.nil? || local_type == seed_type
192
+
193
+ unless computed
194
+ writes = yield
195
+ computed = true
196
+ end
197
+ next widened if writes && !writes.include?(name)
198
+
199
+ joined = Type::Combinator.union(local_type, seed_type)
200
+ widened = widened.with_ivar(name, joined)
201
+ marked = (mark && nil_free?(local_type)) || scope.declaration_sourced?(:ivar, name)
202
+ marked ? widened.seed_declaration_sourced_ivar(name, joined) : widened
203
+ end
204
+ end
205
+
206
+ # Issue #1963 — `scope` past the calls `node`, a condition operand, makes: {.widen} over the names every
207
+ # candidate call in it may write, or every name when one of them is not proven. A call's literal block may run,
208
+ # so its calls count; a `def`, a class or module body and (unless `run_lambdas`) a lambda literal run nothing
209
+ # on this `self` there.
210
+ #
211
+ # A binding whose narrowing ruled `nil` out takes the declaration mark when widened ({.widen}'s `mark:`): the
212
+ # reset forgets the narrowing without making the variable newly nil-reportable, since a call the proof cannot
213
+ # clear is far more often one that leaves the variable alone (`if @tag && items.any?` then `t = @tag;
214
+ # t.upcase`) than one that assigns `nil`. A `when` test passes `run_lambdas: true`, since its `===` runs a
215
+ # lambda literal's body.
216
+ def after_operand(node, scope, run_lambdas: false)
217
+ return scope unless narrowed_ivars?(scope)
218
+
219
+ calls = candidate_calls(node, run_lambdas, scope)
220
+ return scope if calls.empty?
221
+
222
+ widen(scope, mark: true) { proven_writes(calls, scope) }
223
+ end
224
+
225
+ # Whether a candidate call in `node` may write the instance variable `name`. A `when` test passes
226
+ # `run_lambdas: true`, since its `===` runs a lambda literal's body. A write to it in `node` is the caller's
227
+ # to check.
228
+ def may_write?(node, name, scope, run_lambdas: false)
229
+ calls = candidate_calls(node, run_lambdas, scope)
230
+ return false if calls.empty?
231
+
232
+ proven = proven_writes(calls, scope)
233
+ proven.nil? || proven.include?(name)
234
+ end
235
+
236
+ # The union of the names each call may write ({SelfCallIvarWrites.of}), or nil when one is not proven.
237
+ def proven_writes(calls, scope)
238
+ calls.reduce(Set.new) do |acc, call|
239
+ writes = SelfCallIvarWrites.of(call, scope)
240
+ break nil if writes.nil?
241
+
242
+ acc.merge(writes)
243
+ end
244
+ end
245
+
246
+ # The candidate calls under `node`, in source order.
247
+ def candidate_calls(node, run_lambdas, scope)
248
+ found = []
249
+ collect_calls(node, run_lambdas, found, scope)
250
+ found
251
+ end
252
+
253
+ # Issue #1402 — with `unknown_only: true` only the `yield`s, `super`s and proc calls ({.unknown_code?}) count,
254
+ # wherever they sit. A block that runs on another object ({.foreign_self_block?}) is read that way: its
255
+ # implicit-self calls are that object's, while the method's block runs as anywhere else.
256
+ def collect_calls(node, run_lambdas, found, scope, unknown_only: false)
257
+ return unless node.is_a?(Prism::Node)
258
+ return if NON_RUNNING_NODES.any? { |klass| node.is_a?(klass) }
259
+ return if !run_lambdas && node.is_a?(Prism::LambdaNode)
260
+
261
+ found << node if unknown_only ? unknown_code?(node) : candidate?(node)
262
+ node.compact_child_nodes.each do |child|
263
+ foreign = !unknown_only && child.is_a?(Prism::BlockNode) && foreign_self_block?(node, scope)
264
+ collect_calls(child, run_lambdas, found, scope, unknown_only: unknown_only || foreign)
265
+ end
266
+ end
267
+ private_class_method :collect_calls
268
+
269
+ # Issue #1402 — a `yield`, a `super` or a proc's call ({.proc_call?}).
270
+ def unknown_code?(node)
271
+ UNKNOWN_CODE_NODES.any? { |klass| node.is_a?(klass) } || (node.is_a?(Prism::CallNode) && proc_call?(node))
272
+ end
273
+ private_class_method :unknown_code?
274
+
275
+ def nil_free?(type)
276
+ Narrowing.narrow_non_nil(type) == type
277
+ end
278
+ private_class_method :nil_free?
279
+
280
+ # Whether any instance variable is bound narrower than its class-ivar seed: the only state {.widen} changes.
281
+ def narrowed_ivars?(scope)
282
+ seed = class_ivar_seed(scope)
283
+ return false if seed.nil? || seed.empty?
284
+
285
+ seed.any? do |name, seed_type|
286
+ local_type = scope.ivar(name)
287
+ !local_type.nil? && local_type != seed_type
288
+ end
289
+ end
290
+ private_class_method :narrowed_ivars?
291
+
292
+ def class_ivar_seed(scope)
293
+ self_type = scope.self_type
294
+ class_name =
295
+ case self_type
296
+ when Type::Nominal, Type::Singleton then self_type.class_name
297
+ end
298
+ class_name && scope.class_ivars_for(class_name)
299
+ end
300
+ private_class_method :class_ivar_seed
301
+ end
302
+ end
303
+ end
@@ -45,7 +45,7 @@ module Rigor
45
45
  # `X.send(:define_method, :m, blk)`), whose body may be a block the file wrote anywhere, an eval of a
46
46
  # String, whose code may do any of these, and a `const_set`, which may make a class a `class` body reopens;
47
47
  # - one name: a `def` of it anywhere (`def self.gets` at the top level defines it on `main`, which
48
- # {BlockCallTiming.project_defines_anywhere?} does not record), and a Symbol or String literal naming it,
48
+ # {BlockCallTiming.project_defines_anywhere?} does not count), and a Symbol or String literal naming it,
49
49
  # which a macro may define it through (`def_delegators :@io, :gets`, `attr_reader :gets`,
50
50
  # `undef_method :gets`), other than `&:gets` and the first argument of a call that only names a method
51
51
  # (`respond_to?(:gets)`, `send(:gets)`).
@@ -47,6 +47,25 @@ module Rigor
47
47
  # same call, another plugin's included, is not consulted. A `:lexical` entry (issue #1667) keeps the caller's
48
48
  # `self`, so its `self_type` is the calling scope's.
49
49
  def match_for(scope:, call_node:, receiver_type:)
50
+ entry = first_matching_entry(scope, call_node, receiver_type)
51
+ return nil if entry.nil?
52
+
53
+ receiver_name = singleton_receiver_class_name(receiver_type) || nominal_receiver_class_name(receiver_type)
54
+ entry_match(entry, receiver_name, scope, scope.environment)
55
+ end
56
+
57
+ # Whether `call_node`'s block runs as an instance method of the receiver class (issue #1768): its first matching
58
+ # entry binds `:receiver_instance`, so the block body's `self` is the instance {#match_for} narrows to and its
59
+ # `@x` writes are that instance's ivars, as a `def` body's are. A `:lexical` or named-`self_type` entry runs the
60
+ # block on another object, and no entry means no narrowing.
61
+ def runs_as_receiver_instance_method?(scope:, call_node:, receiver_type:)
62
+ entry = first_matching_entry(scope, call_node, receiver_type)
63
+ !entry.nil? && entry.self_type == Plugin::Macro::BlockAsMethod::SELF_TYPE_RECEIVER_INSTANCE
64
+ end
65
+
66
+ # The first registered entry that matches `call_node` on `receiver_type`, in (plugin registration, declaration)
67
+ # order, or nil.
68
+ def first_matching_entry(scope, call_node, receiver_type)
50
69
  return nil if receiver_type.nil?
51
70
 
52
71
  environment = scope&.environment
@@ -61,12 +80,7 @@ module Rigor
61
80
  # (plugin registration, declaration) order; the method-name membership is guaranteed
62
81
  # by the table key.
63
82
  entries = registry.contribution_index.block_entries_for(call_node.name)
64
- entries.each do |entry|
65
- next unless entry_matches?(entry, singleton_name, nominal_name, call_node, scope, environment)
66
-
67
- return entry_match(entry, singleton_name || nominal_name, scope, environment)
68
- end
69
- nil
83
+ entries.find { |entry| entry_matches?(entry, singleton_name, nominal_name, call_node, scope, environment) }
70
84
  end
71
85
 
72
86
  # The {Match} a matching entry contributes: a `:lexical` one keeps the calling scope's `self`, unknown where the