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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../analysis/path_expansion"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -42,6 +43,36 @@ module Rigor
42
43
  # anything.
43
44
  PATTERN = /(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])(#{FEATURE_ALTERNATION})\1/
44
45
 
46
+ # Issue #2174 — the gems Ruby 4.0 bundles (its `gems/bundled_gems` list, which an installation does not
47
+ # keep): installed beside the interpreter, but loaded only by an explicit `require`. Gems installed later
48
+ # into the same directory are deliberately not read from it.
49
+ BUNDLED_GEMS = %w[
50
+ abbrev base64 benchmark bigdecimal csv debug drb fiddle getoptlong irb logger matrix minitest mutex_m
51
+ net-ftp net-imap net-pop net-smtp nkf observer ostruct power_assert prime pstore racc rake rbs rdoc readline
52
+ reline repl_type_completor resolv-replace rexml rinda rss syslog test-unit typeprof win32ole
53
+ ].freeze
54
+
55
+ # Issue #2174 — the first path segments of the features Ruby ships: the `.rb` files and directories of the
56
+ # interpreter's library directory, the extensions and directories of its architecture directory, and the
57
+ # bundled gems' names (`net-ftp` → `net`). Read once, at load time and on the main Ractor, since
58
+ # `RbConfig::CONFIG` is not shareable; empty when the directories cannot be read.
59
+ SHIPPED_SEGMENTS = begin
60
+ require "rbconfig"
61
+ segments = Set.new
62
+ [RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["archdir"]].compact.each do |dir|
63
+ next unless File.directory?(dir)
64
+
65
+ Dir.children(dir).each { |entry| segments << entry.sub(/\.(?:rb|so|bundle|dll)\z/, "") }
66
+ end
67
+ BUNDLED_GEMS.each { |name| segments << name.split("-").first }
68
+ segments.to_set(&:freeze).freeze
69
+ rescue StandardError
70
+ Set.new.freeze
71
+ end
72
+
73
+ # A `require` of any literal feature, read as {PATTERN} reads one; {.features_of} keeps those Ruby ships.
74
+ ANY_FEATURE = %r{(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])([\w-]+(?:/[\w-]+)*)\1}
75
+
45
76
  module_function
46
77
 
47
78
  # The features a run over `configuration` requires: every file under the configured paths, plus
@@ -53,37 +84,51 @@ module Rigor
53
84
  def for_configuration(configuration, extra_files = [], sources: {})
54
85
  files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
55
86
  base: configuration.exclude_base)
56
- scan(files + Array(extra_files).map(&:to_s), sources: sources)
87
+ scan(files + Array(extra_files).map(&:to_s), sources: sources,
88
+ sig_paths: configured_sig_paths(configuration))
57
89
  rescue StandardError
58
90
  []
59
91
  end
60
92
 
93
+ # The project signature roots the environment loads: the configured `signature_paths:`, else `./sig` when it
94
+ # exists (`Environment.project_signature_roots`, which this leaf file does not load).
95
+ def configured_sig_paths(configuration)
96
+ configured = configuration.signature_paths
97
+ return Array(configured).map(&:to_s) if configured
98
+
99
+ File.directory?("sig") ? ["sig"] : []
100
+ end
101
+
61
102
  # @param files — source paths (Strings or Pathnames); an unreadable one contributes nothing.
62
103
  # @param sources — path => source text, scanned instead of the file; its paths are scanned even when
63
104
  # `files` does not list them.
64
- # @return the sorted, frozen features some file requires.
65
- def scan(files, sources: {})
105
+ # @param sig_paths — the project's signature roots, whose `.rbs` declaration headers count as the project's
106
+ # own names ({.relevant}).
107
+ # @return the sorted, frozen features some file requires, a shipped one kept only when {.relevant}.
108
+ def scan(files, sources: {}, sig_paths: [])
66
109
  sources = sources.to_h { |path, text| [File.expand_path(path.to_s), text] }
67
110
  paths = (Array(files).map { |path| source_key(File.expand_path(path.to_s), sources) } + sources.keys).uniq
68
111
  found = Set.new
112
+ names = Set.new
69
113
  paths.each do |path|
70
- break if found.size == VENDORED_DIRS.size
71
-
72
- found.merge(sources.key?(path) ? features_of(sources[path]) : memoized_features_in(path))
114
+ features, declared = sources.key?(path) ? scan_source(sources[path]) : memoized_scan_in(path)
115
+ found.merge(features)
116
+ names.merge(declared)
73
117
  end
74
118
  prune_memo(paths)
75
- found.to_a.sort.freeze
119
+ relevant(found, names.merge(sig_names(sig_paths))).sort.freeze
76
120
  end
77
121
 
78
- # @return path (absolute) => the features that file requires, for every file under the configured paths
79
- # that requires any. A long-lived reader (the language server) keeps this and overlays its open buffers.
122
+ # @return path (absolute) => `[features, declared names]` ({.scan_source}) for every file under the
123
+ # configured paths that has either. A long-lived reader (the language server) keeps this, overlays its open
124
+ # buffers and passes the union to {.relevant}.
80
125
  def feature_files(configuration)
81
126
  listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
82
127
  base: configuration.exclude_base)
83
128
  files = listed.map { |path| File.expand_path(path.to_s) }
84
129
  result = files.each_with_object({}) do |path, acc|
85
- features = memoized_features_in(path)
86
- acc[path] = features unless features.empty?
130
+ scanned = memoized_scan_in(path)
131
+ acc[path] = scanned unless scanned.all?(&:empty?)
87
132
  end
88
133
  prune_memo(files)
89
134
  result
@@ -91,6 +136,42 @@ module Rigor
91
136
  {}
92
137
  end
93
138
 
139
+ # Issue #2174 — `features` with each shipped feature kept only when one of `names` (constant names the
140
+ # project's source or signatures declare a class or module under) is a root the feature's library declares
141
+ # ({RubyLibraryRoots}). Only such a feature can open a class (`RbsLoader#ruby_library_reopen?` asks it of a
142
+ # class every declaration of which is the project's), so dropping the rest changes no diagnostic, and keeps a
143
+ # `require "json"` edit in a project that reopens no library class out of the incremental snapshot's
144
+ # fingerprint and the language server's environment key. The listed features ({VENDORED_DIRS}) are kept.
145
+ def relevant(features, names)
146
+ return features.to_a if features.all? { |feature| VENDORED_DIRS.key?(feature) }
147
+
148
+ segments = names.empty? ? EMPTY_SEGMENTS : library_segments(names)
149
+ features.select { |feature| VENDORED_DIRS.key?(feature) || segments.include?(feature.split("/").first) }
150
+ end
151
+
152
+ # The feature segments of the libraries that declare one of `names` as a root. Loaded lazily: the roots read
153
+ # the `rbs` stdlib tree, which a run with no shipped `require` never needs.
154
+ def library_segments(names)
155
+ require_relative "ruby_library_roots"
156
+ roots = RubyLibraryRoots.shared
157
+ names.each_with_object(Set.new) { |name, acc| acc.merge(roots.fetch(name, EMPTY_SEGMENTS)) }
158
+ end
159
+
160
+ EMPTY_SEGMENTS = [].freeze
161
+
162
+ # The constant names the `.rbs` files under `sig_paths` declare a class or module under.
163
+ def sig_names(sig_paths)
164
+ Array(sig_paths).each_with_object(Set.new) do |root, acc|
165
+ next unless File.directory?(root.to_s)
166
+
167
+ RootedGlob.glob(root.to_s, "**/*.rbs").each do |path|
168
+ acc.merge(declared_names(File.binread(path)))
169
+ rescue SystemCallError, IOError
170
+ next
171
+ end
172
+ end
173
+ end
174
+
94
175
  # Paths are compared expanded, so a relative `--instead-of=lib/a.rb` replaces the configured
95
176
  # `/project/lib/a.rb`; a source whose real path matches (a symlinked directory) replaces it too. The real
96
177
  # path is asked only for a file that shares a source's basename, so a scan does not resolve every path.
@@ -114,8 +195,8 @@ module Rigor
114
195
  # build; a long-lived language server on every rebuild), so on the main Ractor each file's answer is kept
115
196
  # against its stat and the file is re-read only when the stat moved. A non-main Ractor may not touch the
116
197
  # module's state and reads the file every time.
117
- def memoized_features_in(path)
118
- return features_in(path) unless Ractor.current == Ractor.main
198
+ def memoized_scan_in(path)
199
+ return scan_in(path) unless Ractor.current == Ractor.main
119
200
 
120
201
  stat = File.stat(path)
121
202
  stamp = [stat.mtime.to_r, stat.ctime.to_r, stat.size, stat.ino]
@@ -123,13 +204,15 @@ module Rigor
123
204
  cached = memo[path]
124
205
  return cached.last if cached && cached.first == stamp
125
206
 
126
- features = features_in(path)
127
- memo[path] = [stamp, features]
128
- features
207
+ scanned = scan_in(path)
208
+ memo[path] = [stamp, scanned]
209
+ scanned
129
210
  rescue SystemCallError
130
- []
211
+ NOTHING
131
212
  end
132
213
 
214
+ NOTHING = [[].freeze, [].freeze].freeze
215
+
133
216
  # Keeps the memo to the files the latest scan was asked about, so a long-lived language server holds one
134
217
  # entry per project file rather than one per file it ever saw.
135
218
  def prune_memo(paths)
@@ -139,28 +222,51 @@ module Rigor
139
222
  @memo.select! { |path, _| keep.include?(path) }
140
223
  end
141
224
 
142
- # @return the features one file requires.
143
- def features_in(path)
144
- features_of(File.binread(path))
225
+ # @return `[features, declared names]` for one file ({.scan_source}).
226
+ def scan_in(path)
227
+ scan_source(File.binread(path))
145
228
  rescue SystemCallError, IOError
146
- []
229
+ NOTHING
230
+ end
231
+
232
+ # @return `[features, declared names]`: what {.features_of} reads, and the constant names a `class` or
233
+ # `module` header in `source` spells, each segment on its own (`class Net::SMTP` gives `Net` and `SMTP`).
234
+ def scan_source(source)
235
+ [features_of(source), declared_names(source)].freeze
236
+ end
237
+
238
+ # A header on a line of its own, or inside a comment (an rbs-inline `# @rbs!` block declares
239
+ # `# class Matrix`). Over-matching a comment only keeps more features.
240
+ HEADER = /^[ \t]*(?:#[ \t]*)?(?:class|module)[ \t]+(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)/
241
+
242
+ def declared_names(source)
243
+ bytes = source.b
244
+ return [] unless bytes.include?("class") || bytes.include?("module")
245
+
246
+ bytes.scan(HEADER).flat_map { |(name)| name.split("::") }
247
+ .uniq.map { |name| name.dup.force_encoding(Encoding::UTF_8).freeze }.freeze
147
248
  end
148
249
 
149
- # @return the features `source` requires.
250
+ # @return the features `source` requires: the listed ones ({VENDORED_DIRS}), and, for issue #2174, every
251
+ # feature Ruby ships ({SHIPPED_SEGMENTS}), which `RbsLoader#ruby_library_reopen?` reads to tell a reopened
252
+ # library class from a project class of the same name. Only the listed ones become library tokens.
150
253
  def features_of(source)
151
- # A plain substring test first: almost no file mentions any listed feature, and it is far cheaper than
152
- # the regexp on a file that does not.
153
- return [] unless VENDORED_DIRS.each_key.any? { |feature| source.include?(feature) }
254
+ # A plain substring test first: a file with no `require` at all is the common case, and it is far cheaper
255
+ # than the regexp on a file that has none.
256
+ return [] unless source.include?("require")
154
257
 
155
258
  bytes = source.b
156
259
  named = []
157
260
  position = 0
158
- while (match = PATTERN.match(bytes, position))
261
+ while (match = ANY_FEATURE.match(bytes, position))
159
262
  named << match[2] unless commented?(bytes, match.begin(0))
160
263
  position = match.end(0)
161
264
  end
162
- # The table's own frozen keys, so the result is shareable across Ractors whatever the source's encoding.
163
- VENDORED_DIRS.each_key.select { |feature| named.include?(feature) }
265
+ # The table's own frozen keys, and frozen copies of the rest, so the result is shareable across Ractors
266
+ # whatever the source's encoding.
267
+ listed = VENDORED_DIRS.each_key.select { |feature| named.include?(feature) }
268
+ shipped = named.select { |feature| SHIPPED_SEGMENTS.include?(feature.split("/").first) }
269
+ (listed + shipped.map { |feature| feature.dup.force_encoding(Encoding::UTF_8).freeze }).uniq
164
270
  end
165
271
 
166
272
  # Whether the match at `offset` sits on a line whose first non-blank character is `#`: a whole-line comment.
@@ -176,7 +282,8 @@ module Rigor
176
282
  # The library tokens for `features` and for any listed feature the configuration's `libraries:` names.
177
283
  def tokens(features, configured_libraries)
178
284
  configured = Array(configured_libraries).map(&:to_s).select { |name| VENDORED_DIRS.key?(name) }
179
- (Array(features) | configured).sort.map { |feature| token(feature) }
285
+ listed = Array(features).select { |feature| VENDORED_DIRS.key?(feature) }
286
+ (listed | configured).sort.map { |feature| token(feature) }
180
287
  end
181
288
 
182
289
  def token(feature)
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+ require "rbconfig"
5
+ require_relative "missing_gem_constant_index"
6
+ require_relative "required_features"
7
+ require_relative "../rooted_glob"
8
+
9
+ module Rigor
10
+ class Environment
11
+ # Issue #2174 — which library Ruby ships declares each root constant name. Three sources are read, and no others:
12
+ #
13
+ # - every library of the `rbs` gem's `stdlib/` tree, loaded into this run or not, read from its `.rbs` files
14
+ # (`net-http` declares `Net`);
15
+ # - every top-level `.rb` file of the interpreter's library directory (`RbConfig::CONFIG["rubylibdir"]`), the
16
+ # default gems' and the standard library's entry files, read from their top-level declarations;
17
+ # - the entry file of each gem Ruby bundles ({RequiredFeatures::BUNDLED_GEMS}), found under the interpreter's
18
+ # gem directory. A gem installed there later is not one of them and is not read.
19
+ #
20
+ # Each root maps to the first path segments of the features that load it (`Matrix` → `matrix`, `Net` → `net`),
21
+ # which {RbsLoader#ruby_library_reopen?} intersects with the features the project's source requires
22
+ # ({RequiredFeatures.features_of}).
23
+ #
24
+ # Ownership is read, never guessed by camelizing a library name, for the reason {MissingGemConstantIndex}
25
+ # gives; that module's reader parses the `.rb` entry files. No library code runs. Everything fails open to
26
+ # "no library", which keeps the pre-#2174 reading. Core names (`Array`, `Kernel`) appear too, harmlessly:
27
+ # core signatures always load, so no project-only class carries one.
28
+ #
29
+ # The directories are resolved at load time, on the main Ractor: `RbConfig::CONFIG` and `Gem.default_dir`
30
+ # read non-shareable constants and raise `Ractor::IsolationError` inside a pool worker. The scan itself runs
31
+ # lazily (about 0.1 s), memoised per loader, and only for a class about to be reported.
32
+ module RubyLibraryRoots
33
+ STDLIB_ROOT = begin
34
+ ::RBS::Repository::DEFAULT_STDLIB_ROOT.to_s.dup.freeze
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ RUBY_LIB_DIR = begin
40
+ RbConfig::CONFIG["rubylibdir"].to_s.dup.freeze
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ RUBY_GEMS_DIR = begin
46
+ File.join(Gem.default_dir, "gems").freeze
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ module_function
52
+
53
+ # {.build}, built once per process on the main Ractor, where the language server and the incremental
54
+ # snapshot fingerprint ask it repeatedly; a non-main Ractor may not keep module state and builds its own.
55
+ def shared
56
+ return build unless Ractor.current == Ractor.main
57
+
58
+ @shared ||= build
59
+ end
60
+
61
+ # The frozen map of root constant name to the frozen set of feature segments that load it.
62
+ def build
63
+ roots = Hash.new { |hash, key| hash[key] = Set.new }
64
+ stdlib_roots(roots)
65
+ ruby_lib_roots(roots)
66
+ bundled_gem_roots(roots)
67
+ roots.each_value(&:freeze)
68
+ roots.default_proc = nil
69
+ roots.freeze
70
+ end
71
+
72
+ def stdlib_roots(roots)
73
+ return if STDLIB_ROOT.nil? || !File.directory?(STDLIB_ROOT)
74
+
75
+ Dir.children(STDLIB_ROOT).each do |library|
76
+ segment = library.split("-").first
77
+ RootedGlob.glob(File.join(STDLIB_ROOT, library), "*/**/*.rbs").each do |path|
78
+ stdlib_file_roots(path).each { |root| roots[root] << segment }
79
+ end
80
+ end
81
+ end
82
+
83
+ def stdlib_file_roots(path)
84
+ _, _, decls = ::RBS::Parser.parse_signature(File.read(path))
85
+ decls.filter_map { |decl| root_of(decl) if named_declaration?(decl) }
86
+ rescue StandardError
87
+ []
88
+ end
89
+
90
+ def ruby_lib_roots(roots)
91
+ return if RUBY_LIB_DIR.nil? || !File.directory?(RUBY_LIB_DIR)
92
+
93
+ RootedGlob.glob(RUBY_LIB_DIR, "*.rb").each do |path|
94
+ segment = File.basename(path, ".rb")
95
+ MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
96
+ end
97
+ end
98
+
99
+ def bundled_gem_roots(roots)
100
+ return if RUBY_GEMS_DIR.nil? || !File.directory?(RUBY_GEMS_DIR)
101
+
102
+ Dir.children(RUBY_GEMS_DIR).each do |basename|
103
+ gem_name = basename[/\A(.+?)-\d/, 1]
104
+ next if gem_name.nil? || !RequiredFeatures::BUNDLED_GEMS.include?(gem_name)
105
+
106
+ segment = gem_name.split("-").first
107
+ MissingGemConstantIndex.entry_files(File.join(RUBY_GEMS_DIR, basename), gem_name).each do |path|
108
+ MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
109
+ end
110
+ end
111
+ end
112
+
113
+ def named_declaration?(decl)
114
+ decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
115
+ end
116
+
117
+ def root_of(decl)
118
+ decl.name.to_s.delete_prefix("::").split("::").first
119
+ end
120
+ end
121
+ end
122
+ end
@@ -268,7 +268,7 @@ module Rigor
268
268
  # {Environment::RbsLoader} so constant lookups (and, in later v0.0.9 slices, other reflection
269
269
  # artefacts) consult the cache. Pass `nil` (the default) to skip caching for this environment.
270
270
  # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
271
- def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
271
+ def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, # rubocop:disable Metrics/AbcSize
272
272
  plugin_registry: nil, dependency_source_index: nil,
273
273
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
274
274
  source_rbs_synthesis_reporter: nil,
@@ -364,7 +364,9 @@ module Rigor
364
364
  # Issue #610 — the plugin-contributed subset is loaded LAST and may stand down against a colliding
365
365
  # generic arity. Only the bundled plugins' own `sig/` is deferred: the user's `signature_paths:`,
366
366
  # the bundle walk and `rbs collection install` all stay authoritative.
367
- deferred_signature_paths: plugin_sig_paths
367
+ deferred_signature_paths: plugin_sig_paths,
368
+ project_signature_paths: resolved_paths,
369
+ required_features: required_features || RequiredFeatures.scan(source_files, sig_paths: resolved_paths)
368
370
  )
369
371
  # Issues #824 / #1075 — the rows ADR-112 WD5's consistency rule reports for members the project's
370
372
  # own `sig/` and an inline source both declare: an error per contradiction (or per refinement that
@@ -72,6 +72,12 @@ module Rigor
72
72
 
73
73
  module_function
74
74
 
75
+ # Whether `call_node` is spelled to reach the private `Kernel#loop` (see {.loop_may_complete?}), which runs its
76
+ # block until it breaks or raises.
77
+ def kernel_loop?(call_node)
78
+ call_node.name == :loop && kernel_spelled_receiver?(call_node.receiver)
79
+ end
80
+
75
81
  # Issue #1107 — whether a `loop` call can complete normally although its declared return is `bot`.
76
82
  #
77
83
  # `Kernel#loop` is `() { () -> void } -> bot` in core RBS, yet it rescues a `StopIteration` its block
@@ -86,7 +92,7 @@ module Rigor
86
92
  # promise. A project redefinition of `loop` itself is not excluded: widening its `bot` costs precision,
87
93
  # never a false positive.
88
94
  def loop_may_complete?(call_node)
89
- return false unless call_node.name == :loop && kernel_spelled_receiver?(call_node.receiver)
95
+ return false unless kernel_loop?(call_node)
90
96
 
91
97
  block = call_node.block
92
98
  return false if block.nil?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require_relative "../../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -49,7 +50,7 @@ module Rigor
49
50
  end
50
51
 
51
52
  # Every catalogue file under {DATA_ROOT}, in a stable order.
52
- def self.topic_paths = Dir[File.join(DATA_ROOT, "*.yml")]
53
+ def self.topic_paths = RootedGlob.glob(DATA_ROOT, "*.yml")
53
54
 
54
55
  def initialize(path:, mutating_selectors: {})
55
56
  @path = path
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Issue #2104 — the callee floor's census made transitive. A method that hands its parameter to another method
6
+ # which mutates it (`def stripes(acc) = poke(acc)`, `def poke(acc) = (acc[0] = "x")`) mutates that parameter as
7
+ # surely as one that writes it itself, so the caller's argument is floored after `stripes(acc)` too.
8
+ #
9
+ # This module is the closure over a parameter-flow graph, independent of how the graph is read. `own` answers a
10
+ # def's own census, `{ name => slot }` (a slot is a positional index or a keyword symbol); `edges` answers the
11
+ # {Edge}s along which a def hands one of its parameters to a resolved callee. A node is one parameter of one def,
12
+ # and the walk follows only the edges that hand on the parameter the node is, so a def's hand-offs of its OTHER
13
+ # parameters cost nothing. A node is mutated when its def mutates it or it hands itself to a mutated node: the
14
+ # least fixpoint, so a recursive or mutually recursive chain terminates and a cycle with no mutation on it mutates
15
+ # nothing.
16
+ #
17
+ # The walk reads at most {MAX_NODES} nodes per closure. A hand-off to a node past the cap is read as no hand-off,
18
+ # as the one-level floor reads a callee it cannot resolve: flooring there would drop a literal shape on evidence
19
+ # the walk never saw, and a floored `Array[Dynamic[top]]` then fails a declared tuple on correct code.
20
+ module CalleeMutationChain
21
+ # `name` and `slot` are the forwarding def's parameter, `callee` the def it is handed to, and `callee_names`
22
+ # the callee's parameters it may bind there.
23
+ Edge = Data.define(:name, :slot, :callee, :callee_names)
24
+
25
+ MAX_NODES = 32
26
+
27
+ module_function
28
+
29
+ def close(root, own:, edges:)
30
+ census = own.call(root)
31
+ handed = edges.call(root).reject { |edge| census.key?(edge.name) }
32
+ return census if handed.empty?
33
+
34
+ flow = Flow.new(own, edges)
35
+ handed.each { |edge| flow.visit(root, edge.name) }
36
+ flow.settle
37
+ handed.each_with_object(census.dup) do |edge, result|
38
+ result[edge.name] = edge.slot if flow.mutated?(root, edge.name)
39
+ end
40
+ end
41
+
42
+ # The parameter-flow graph one closure reads: per def, per parameter name, whether the node is mutated and the
43
+ # nodes it hands itself on to.
44
+ class Flow
45
+ Node = Struct.new(:mutated, :successors)
46
+
47
+ def initialize(own, edges)
48
+ @own = own
49
+ @edges = edges
50
+ @nodes = {}.compare_by_identity
51
+ @count = 0
52
+ end
53
+
54
+ def mutated?(def_node, name)
55
+ node = @nodes.dig(def_node, name)
56
+ !node.nil? && node.mutated
57
+ end
58
+
59
+ # Reads the nodes reachable from `def_node`'s `name`, breadth first, while fewer than {MAX_NODES} are read.
60
+ def visit(def_node, name)
61
+ queue = [[def_node, name]]
62
+ until queue.empty?
63
+ current, parameter = queue.shift
64
+ next if known?(current, parameter)
65
+ break if @count >= MAX_NODES
66
+
67
+ queue.concat(add(current, parameter))
68
+ end
69
+ end
70
+
71
+ def settle
72
+ changed = true
73
+ while changed
74
+ changed = false
75
+ each_node do |node|
76
+ next if node.mutated
77
+ next unless node.successors.any? { |callee, name| mutated?(callee, name) }
78
+
79
+ node.mutated = true
80
+ changed = true
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def known?(def_node, name)
88
+ by_name = @nodes[def_node]
89
+ !by_name.nil? && by_name.key?(name)
90
+ end
91
+
92
+ # Records the node and answers its successors. A node its def mutates needs none.
93
+ def add(def_node, name)
94
+ @count += 1
95
+ mutated = @own.call(def_node).key?(name)
96
+ successors = mutated ? [] : successors_of(def_node, name)
97
+ (@nodes[def_node] ||= {})[name] = Node.new(mutated, successors)
98
+ successors
99
+ end
100
+
101
+ def successors_of(def_node, name)
102
+ @edges.call(def_node).each_with_object([]) do |edge, successors|
103
+ next unless edge.name == name
104
+
105
+ edge.callee_names.each { |callee_name| successors << [edge.callee, callee_name] }
106
+ end
107
+ end
108
+
109
+ def each_node(&)
110
+ @nodes.each_value { |by_name| by_name.each_value(&) }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -6,6 +6,7 @@ require_relative "../source/node_walker"
6
6
  require_relative "block_parameter_binder"
7
7
  require_relative "element_read_widening"
8
8
  require_relative "index_write_widening"
9
+ require_relative "intervening_ivar_reset"
9
10
  require_relative "mutation_widening"
10
11
  require_relative "optimistic_origin"
11
12
  require_relative "receiver_alias"
@@ -109,16 +110,22 @@ module Rigor
109
110
  # `base_scope`), except that one still on its class-wide binding does not: ADR-58's declaration seed is
110
111
  # the union of every write in the class, this body's included, so nothing the body stores can move it,
111
112
  # and rebinding it would only drop the declaration mark that keeps its nil from being diagnostic fuel.
112
- # A nested block that rebinds `self` (`o.instance_eval`) writes another object's ivar, and a nested
113
- # `def` runs only when called; both still count, because an `instance_eval` without a receiver, or a
114
- # call to that `def` inside the body, does write this one.
113
+ # Issue #1402 — an instance variable written in a nested block that runs on an object provably not `self`
114
+ # (`Other.class_eval { @x = 1 }`, {InterveningIvarReset.foreign_self_block?}, its receiver typed in
115
+ # `base_scope`) is that object's, and does not count; one on a receiver that may be `self` does. A class
116
+ # variable or global written there counts as anywhere else: Ruby resolves a class variable lexically, and
117
+ # a global is the same one whatever `self` is. A nested `def` runs only when called; it still counts,
118
+ # because a call to it inside the body writes this one.
119
+ # @param foreign_self — the block itself runs on an object provably not `self`
120
+ # ({InterveningIvarReset.foreign_self_block?} of its call): no instance variable it writes is the caller's.
121
+ # Its locals, class variables and globals still count.
115
122
  # @param non_locals — the per-element fold's superset of `ivars`: the class variables and globals the body
116
123
  # rebinds count too, on the same terms, and so does an attribute setter called on `self` (`self.w = v`)
117
124
  # as a rebind of the instance variable it is named after, `@w`: the `attr_writer` / `attr_accessor`
118
125
  # convention stores there, and no write node shows it. A hand-written setter storing elsewhere is not
119
126
  # seen.
120
127
  # @return the captured names the body writes, each once, in first-write order.
121
- def writes(block_node, base_scope, ivars: false, non_locals: false)
128
+ def writes(block_node, base_scope, ivars: false, non_locals: false, foreign_self: false)
122
129
  body = block_node.body
123
130
  return NO_NAMES if body.nil?
124
131
 
@@ -126,20 +133,44 @@ module Rigor
126
133
  names = nil
127
134
  outliving = non_locals || ivars
128
135
  Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
129
- name =
130
- if LOCAL_WRITE_NODES.include?(descendant.class)
131
- captured_local_write(descendant, ancestors, base_scope) { introduced ||= introduced_locals(block_node) }
132
- elsif outliving
133
- if ReflectiveIvarWrite.unknown_target?(descendant)
134
- (names ||= []).concat(reflective_unknown_ivars(base_scope))
135
- end
136
- rebindable_outliving_write(descendant, base_scope, non_locals)
136
+ if LOCAL_WRITE_NODES.include?(descendant.class)
137
+ name = captured_local_write(descendant, ancestors, base_scope) do
138
+ introduced ||= introduced_locals(block_node)
137
139
  end
138
- (names ||= []) << name if name
140
+ (names ||= []) << name if name
141
+ elsif outliving
142
+ found = outliving_writes(descendant, ancestors, base_scope, non_locals, foreign_self)
143
+ (names ||= []).concat(found) unless found.empty?
144
+ end
139
145
  end
140
146
  names ? names.uniq : NO_NAMES
141
147
  end
142
148
 
149
+ # The non-local names `node` writes ({.reflective_unknown_ivars} for an `instance_variable_set` on `self` whose
150
+ # target no literal names, then {.rebindable_outliving_write}), less the instance variables when the write
151
+ # runs on an object provably not `self` (`foreign_self`, or a nested block {.under_foreign_self_block?}).
152
+ def outliving_writes(node, ancestors, base_scope, non_locals, foreign_self)
153
+ found = ReflectiveIvarWrite.unknown_target?(node) ? reflective_unknown_ivars(base_scope) : NO_NAMES
154
+ written = rebindable_outliving_write(node, base_scope, non_locals)
155
+ return found if written.nil? && found.empty?
156
+
157
+ names = written ? found + [written] : found
158
+ return names unless foreign_self || under_foreign_self_block?(ancestors, base_scope)
159
+
160
+ names.reject { |name| ivar_name?(name) }
161
+ end
162
+
163
+ # Issue #1402 — whether a block on the path `ancestors` runs on an object provably not `self`: its call is a
164
+ # {InterveningIvarReset.foreign_self_block?}, the receiver typed in `base_scope`.
165
+ def under_foreign_self_block?(ancestors, base_scope)
166
+ (1...ancestors.size).any? do |index|
167
+ child = ancestors[index]
168
+ parent = ancestors[index - 1]
169
+ child.is_a?(Prism::BlockNode) && parent.is_a?(Prism::CallNode) && parent.block.equal?(child) &&
170
+ InterveningIvarReset.foreign_self_block?(parent, base_scope)
171
+ end
172
+ end
173
+
143
174
  # Issue #1889 — every ivar `base_scope` binds: an `instance_variable_set` on `self` whose target no literal
144
175
  # names ({ReflectiveIvarWrite.unknown_target?}) may write any of them. One on its ADR-58 declaration seed
145
176
  # counts too, since the census behind the seed sees no reflective write; {.bind} keeps its mark.