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
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ module RefinedDispatch
6
+ # Issue #1979 — whether a class whose superclass the tables do not know is `class K < <expr>` with `<expr>` a
7
+ # core constructor call (`Data.define(...)`, `Struct.new(...)`, `Class.new(<constant>)`) that cannot answer a
8
+ # given class or one of its subclasses, so a subclass walk may leave it out (`OwnMethod.each_subclass` with
9
+ # `possible_only:`).
10
+ #
11
+ # Each constant the expression names (the indexer's `[DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND, name]` entries)
12
+ # must be a class the environment knows that is neither the given class nor a subclass of it, and the project
13
+ # must not touch the name: no project class or module whose last segment is the name (Ruby resolves a constant
14
+ # through the enclosing classes' ancestors and mixins too, so a `Base::StandardError` a class inherits may be
15
+ # what `Class.new(StandardError)` names), no reopening, singleton `def`, refinement or method table keyed by
16
+ # the name itself, and no constant write of it. Any other shape answers false, as does a class a
17
+ # `K = Class.new(...)` write names.
18
+ module ConstructedClass
19
+ MEMO_KEY = :__rigor_constructed_class__
20
+ EMPTY = [].freeze
21
+ private_constant :MEMO_KEY, :EMPTY
22
+
23
+ module_function
24
+
25
+ # True when `name` cannot be `class_name` or a subclass of it. Files `name`'s class edge and a `constant:`
26
+ # edge per named constant on every call; a class declaration appearing fires the walk's `subclass_of:*`.
27
+ def elsewhere?(scope, name, class_name)
28
+ table = memo(scope)
29
+ named = constructed_names(scope, name, table)
30
+ if Analysis::DependencyRecorder.active?
31
+ Scope::ResolutionChain.record_class(scope, name)
32
+ named.each { |constant| Analysis::DependencyRecorder.read_name(:constant, constant) }
33
+ end
34
+ return false if named.empty?
35
+
36
+ by_class = (table[:verdicts][class_name] ||= {})
37
+ return by_class[named] if by_class.key?(named)
38
+
39
+ by_class[named] = named.all? { |constant| core_class_apart?(scope, constant, class_name.to_s, table) }
40
+ end
41
+
42
+ def core_class_apart?(scope, constant, class_name, table)
43
+ environment = scope.environment
44
+ return false unless environment&.class_known?(constant)
45
+ return false if project_name?(scope, constant, table)
46
+ return false unless scope.shadowing_constant_names(constant).empty?
47
+
48
+ ordering = environment.class_ordering(constant, class_name)
49
+ %i[disjoint superclass].include?(ordering) ||
50
+ (ordering == :unknown && !environment.class_known?(class_name))
51
+ end
52
+
53
+ # Whether the project touches `constant` by name: a declared or reopened class or module whose last segment
54
+ # it is, or a top-level table keyed by it (a singleton `def Data.define`, methods, envelope entries or marks,
55
+ # mixins, a refinement).
56
+ def project_name?(scope, constant, table)
57
+ return true if declared_segments(scope, table).include?(constant)
58
+
59
+ discovery = scope.discovery
60
+ [discovery.discovered_classes, discovery.discovered_superclasses, discovery.discovered_includes,
61
+ discovery.discovered_prepends, discovery.discovered_extends, discovery.discovered_methods,
62
+ discovery.discovered_parameter_envelopes, discovery.discovered_refinements].any? do |by_name|
63
+ by_name.key?(constant)
64
+ end
65
+ end
66
+
67
+ # The last segment of every class or module the project declares, reopenings included.
68
+ def declared_segments(scope, table)
69
+ table[:segments] ||= declared_names(scope).each_key.to_set { |key| key.to_s.split("::").last }.freeze
70
+ end
71
+
72
+ # The constants `name`'s superclass expression names, or an empty Array; memoised per envelope bucket,
73
+ # which a file's own discovery shares with the run's seed for every class it does not declare.
74
+ def constructed_names(scope, name, table)
75
+ bucket = scope.discovered_parameter_envelopes[name]
76
+ return EMPTY if bucket.nil?
77
+
78
+ names = table[:names]
79
+ return names[bucket] if names.key?(bucket)
80
+
81
+ names[bucket] =
82
+ if bucket.key?(Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK)
83
+ EMPTY
84
+ else
85
+ kind = Scope::DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND
86
+ bucket.each_key.filter_map { |key| key[1] if key.is_a?(Array) && key[0] == kind }.freeze
87
+ end
88
+ end
89
+
90
+ # The run-wide table of declared classes: the seeded `discovered_class_sources`, or a single-file run's
91
+ # `discovered_classes` where nothing seeded it.
92
+ def declared_names(scope)
93
+ sources = scope.discovery.discovered_class_sources
94
+ sources.empty? ? scope.discovery.discovered_classes : sources
95
+ end
96
+
97
+ # One thread-local slot keyed on the run-generation token the runner seeds (or, unseeded, the file's merged
98
+ # `discovered_def_nodes`, as `ExpressionTyper` falls back) and the environment: every table read here is
99
+ # fixed for a run, so each answer is computed once a run, and a new run replaces the slot.
100
+ def memo(scope)
101
+ slot = Thread.current[MEMO_KEY]
102
+ generation = scope.run_generation || scope.discovered_def_nodes
103
+ environment = scope.environment
104
+ unless slot && slot[0].equal?(generation) && slot[1].equal?(environment)
105
+ slot = [generation, environment, { names: {}.compare_by_identity, verdicts: {} }]
106
+ Thread.current[MEMO_KEY] = slot
107
+ end
108
+ slot[2]
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../builtins/cruby_definers"
4
+ require_relative "constructed_class"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -193,8 +194,7 @@ module Rigor
193
194
  # A module some code `prepend`s to an object's singleton class (or `extend`s an object with in a method
194
195
  # body), which puts it ahead of the object's class even where the class already includes it.
195
196
  def object_prepended?(scope, name)
196
- scope.discovered_parameter_envelopes[name]&.key?(Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK) ||
197
- false
197
+ scope.parameter_envelopes_of(name).key?(Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK)
198
198
  end
199
199
 
200
200
  # Is some in-effect row under a class no table declares by any spelling of it (the keys sharing its last
@@ -282,10 +282,16 @@ module Rigor
282
282
  # declaration with that superclass appearing or vanishing fires (`IncrementalSession#negative_affected`).
283
283
  # A class whose superclass the tables do not know may be a subclass of any class, so it joins the walk too
284
284
  # (`subclass_of:*`, which any class declaration appearing or vanishing fires).
285
- def each_subclass(scope, class_name)
285
+ #
286
+ # Issue #1979 — with `possible_only:`, a class whose superclass expression is a core constructor call that
287
+ # cannot answer `class_name` or a subclass of it (`ConstructedClass.elsewhere?`) is left out, with its
288
+ # subclasses.
289
+ def each_subclass(scope, class_name, possible_only: false)
286
290
  children = children_by_parent(scope)
287
291
  record_subclasses_read(class_name)
288
- queue = children.fetch(class_name.to_s, []) + unknown_superclass_classes(scope, class_name)
292
+ unknown = unknown_superclass_classes(scope, class_name)
293
+ unknown = unknown.reject { |name| ConstructedClass.elsewhere?(scope, name, class_name) } if possible_only
294
+ queue = children.fetch(class_name.to_s, []) + unknown
289
295
  seen = {}
290
296
  until queue.empty?
291
297
  subclass = queue.shift
@@ -300,10 +306,32 @@ module Rigor
300
306
 
301
307
  def unknown_superclass_classes(scope, class_name)
302
308
  Analysis::DependencyRecorder.read_name(:subclass_of, "*") if Analysis::DependencyRecorder.active?
303
- mark = Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK
304
- computed = scope.discovered_superclasses.filter_map { |child, parent| child if parent.nil? }
305
- meta = scope.discovered_parameter_envelopes.filter_map { |name, bucket| name if bucket.key?(mark) }
306
- (computed + meta).uniq - [class_name.to_s]
309
+ table = subclass_memo(scope)
310
+ all = table[:unknown] ||= begin
311
+ mark = Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK
312
+ computed = scope.discovered_superclasses.filter_map { |child, parent| child if parent.nil? }
313
+ meta = scope.discovered_parameter_envelopes.filter_map { |name, bucket| name if bucket.key?(mark) }
314
+ (computed + meta).uniq.freeze
315
+ end
316
+ name = class_name.to_s
317
+ all.include?(name) ? all - [name] : all
318
+ end
319
+
320
+ SUBCLASS_MEMO_KEY = :__rigor_own_method_subclasses__
321
+ private_constant :SUBCLASS_MEMO_KEY
322
+
323
+ # One thread-local slot keyed on the scope's discovery index (a frozen `Data`) and environment, the only
324
+ # inputs `children_by_parent` and the unknown-superclass list read; neither files a dependency edge
325
+ # (`each_subclass` files its own `subclass_of:` edges on every call), so a hit drops none.
326
+ def subclass_memo(scope)
327
+ slot = Thread.current[SUBCLASS_MEMO_KEY]
328
+ discovery = scope.discovery
329
+ environment = scope.environment
330
+ unless slot && slot[0].equal?(discovery) && slot[1].equal?(environment)
331
+ slot = [discovery, environment, {}]
332
+ Thread.current[SUBCLASS_MEMO_KEY] = slot
333
+ end
334
+ slot[2]
307
335
  end
308
336
 
309
337
  def record_subclasses_read(class_name)
@@ -313,10 +341,11 @@ module Rigor
313
341
  end
314
342
 
315
343
  def children_by_parent(scope)
316
- scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
317
- parent = scope.ancestor_name_candidates(child, raw_parent).find { |name| known_class?(scope, name) }
318
- (children[parent] ||= []) << child if parent
319
- end
344
+ subclass_memo(scope)[:children] ||=
345
+ scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
346
+ parent = scope.ancestor_name_candidates(child, raw_parent).find { |name| known_class?(scope, name) }
347
+ (children[parent] ||= []) << child if parent
348
+ end.each_value(&:freeze).freeze
320
349
  end
321
350
 
322
351
  def known_class?(scope, name)
@@ -65,9 +65,10 @@ module Rigor
65
65
  node.arguments.arguments[1]
66
66
  end
67
67
 
68
- # Runs the block with the evaluator inside a `while` / `until` / `for` body. The loop's rebind fixpoint widens
69
- # locals only, so a literal-name write there must not replace the binding with one pass's value
70
- # ({.apply}).
68
+ # Runs the block with the evaluator inside a `for` body or a `while` / `until` predicate. A `for` loop has no
69
+ # rebind fixpoint, and a predicate runs before every iteration but is walked once, so a literal-name write there
70
+ # must not replace the binding with one pass's value ({.apply}). A `while` / `until` body does not enter it: its
71
+ # fixpoint widens the ivar (issue #1930).
71
72
  def in_repeating_body
72
73
  depth = Thread.current[REPEATING_KEY] || 0
73
74
  Thread.current[REPEATING_KEY] = depth + 1
@@ -83,9 +84,8 @@ module Rigor
83
84
  end
84
85
 
85
86
  # `scope` after `node` runs. A literal name binds that ivar to the value's type (yielded), as `@n = v` does.
86
- # Inside a loop body ({.in_repeating_body}) the value and `Dynamic[top]` are joined into the current binding
87
- # instead: the loop's fixpoint widens locals only, so its single pass over an ivar types the value from one
88
- # iteration's locals (`instance_variable_set(:@n, i + 1)` stored `1`) and would leave that. Any other call joins
87
+ # Inside a `for` body or a loop predicate ({.in_repeating_body}) the value and `Dynamic[top]` are joined into the
88
+ # current binding instead: nothing widens the single pass's value across iterations there. Any other call joins
89
89
  # `Dynamic[top]` into every ivar `scope` binds: any one of them may now hold the value, and joining the
90
90
  # value's own type would report a method the old value has and the new one lacks. An ivar on its ADR-58
91
91
  # declaration seed keeps its mark, which stops a nil in the seed from fuelling a nil-receiver diagnostic.
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../source/node_children"
6
+
5
7
  module Rigor
6
8
  module Inference
7
9
  # The constructs whose body `return` leaves only that body, not the enclosing method. A `return` anywhere else —
@@ -39,6 +41,20 @@ module Rigor
39
41
  DEFINE_CALLS.include?(name) || (name == :lambda && kernel_receiver?(call_node.receiver))
40
42
  end
41
43
 
44
+ # True when a `return` under `node` exits the enclosing method: one reachable without crossing a barrier node
45
+ # or a barrier call's block. Allocation-free and early-exiting, for a caller that asks it of every block body.
46
+ def exits_method?(node)
47
+ return false if node.nil? || node?(node)
48
+ return true if node.is_a?(Prism::ReturnNode)
49
+
50
+ barrier_block = node.block if node.is_a?(Prism::CallNode) && block_call?(node)
51
+ node.rigor_each_child do |child|
52
+ next if child.equal?(barrier_block)
53
+ return true if exits_method?(child)
54
+ end
55
+ false
56
+ end
57
+
42
58
  # True when a `lambda` call on `receiver` reaches `Kernel#lambda`: no receiver, `self`, `Kernel` or `::Kernel`.
43
59
  def kernel_receiver?(receiver)
44
60
  case receiver
@@ -66,6 +66,15 @@ module Rigor
66
66
  classifier.result
67
67
  end
68
68
 
69
+ # Issue #2021 — every contribution of the tree under `root`, for a file discovery reached only through a
70
+ # `require_relative` walk: another file the walk does not see may redefine what it defines, so none of its
71
+ # contributions is certain.
72
+ def every_contribution(root)
73
+ classifier = Classifier.new
74
+ classifier.possible(root)
75
+ classifier.result
76
+ end
77
+
69
78
  # Whether `node` was classified possible in `possible` (a {possible_nodes} answer).
70
79
  def possible?(possible, node)
71
80
  !possible.empty? && possible.include?(node)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../reflection"
4
+ require_relative "../rbs_type_translator"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module ScopeIndexer
9
+ # Issue #1406 — an ivar the class's RBS declares, as `@x: T` or through an `attr_reader x: T` /
10
+ # `attr_writer` / `attr_accessor` member (in `sig/`, or inline `attr_reader :x #: T`), reads the declared type
11
+ # in place of the census's join of the writes. The declaration is the author's contract for the field, so it
12
+ # wins over every write the census saw, #541's untyped `attr_writer` write included; whether the writes
13
+ # honour it is ADR-112 WD5's question (#1075), not this one's. It serves
14
+ # checking only: `rigor sig-gen` indexes with `declared_ivar_types: false` and keeps the census's answer.
15
+ #
16
+ # Only an ivar the census already holds is replaced, so a declaration adds no entry the file's writes did not.
17
+ # A declaration is not taken when it is `untyped` (what rbs-inline supplies for a bare `attr_reader :x`, which
18
+ # the type specification reads as a placeholder) or mentions a type variable (`@x: T` in `class Box[T]` is
19
+ # unsolved at a read), and the census entry stands.
20
+ module DeclaredIvarTypes
21
+ module_function
22
+
23
+ def apply!(accumulator, scope)
24
+ environment = scope&.environment
25
+ return if environment.nil?
26
+
27
+ accumulator.each do |class_name, entries|
28
+ next unless class_name.is_a?(String) # a guard: the side-table keys are deleted before this runs
29
+
30
+ declared = declared_ivars(class_name, environment)
31
+ next if declared.empty?
32
+
33
+ accumulator[class_name] = entries.to_h do |ivar, type|
34
+ [ivar, declared.fetch(ivar, type)]
35
+ end
36
+ end
37
+ end
38
+
39
+ # `{ivar name => Rigor::Type}` for the ivars `class_name`'s RBS instance definition declares with a usable
40
+ # type, or `{}`.
41
+ def declared_ivars(class_name, environment)
42
+ definition = Reflection.instance_definition(class_name, environment: environment)
43
+ return {} if definition.nil?
44
+
45
+ instance = Type::Combinator.nominal_of(class_name)
46
+ definition.instance_variables.each_with_object({}) do |(name, variable), acc|
47
+ type = translate(variable.type, instance, environment)
48
+ acc[name] = type if type
49
+ end
50
+ rescue StandardError # a guard: a definition that fails to build declares nothing
51
+ {}
52
+ end
53
+
54
+ def translate(rbs_type, instance, environment)
55
+ return nil unless rbs_type.free_variables.empty?
56
+
57
+ type = RbsTypeTranslator.translate(rbs_type, self_type: instance, instance_type: instance,
58
+ alias_expander: environment.rbs_loader)
59
+ type.is_a?(Type::Dynamic) ? nil : type
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../reflection"
6
+ require_relative "../method_parameter_binder"
7
+ require_relative "../global_write_census"
8
+ require_relative "unseen_ivar_writers"
9
+
10
+ module Rigor
11
+ module Inference
12
+ module ScopeIndexer
13
+ # Issue #1154 — the class-ivar census types an `@x = x` write whose right-hand side reads a parameter of the
14
+ # enclosing instance `def` as that parameter's DECLARED type: the method's RBS signature, from `sig/` or from
15
+ # an rbs-inline `# @rbs` / `#:` annotation, read through {MethodParameterBinder}, the binder the method body's
16
+ # own entry scope uses. Without it the census typed the rvalue with no local bindings, so a constructor field
17
+ # read `Dynamic[top]` in every sibling method even where `sig/` declared `initialize: (String x) -> void`.
18
+ #
19
+ # The census is flow-free, so a write is credited only where the parameter can hold nothing but its
20
+ # argument. {.collect} answers `{write node => declared type}` (an identity Hash) for the writes that pass
21
+ # every gate, and {EMPTY} otherwise; an absent write keeps the census's ordinary rvalue typing, so a declined
22
+ # case reads exactly what it read before:
23
+ #
24
+ # - the write's value is a bare read of the parameter (`@x = x`), not an expression over it;
25
+ # - the write is not inside a block or a lambda, so a block parameter of the same name and a block that runs
26
+ # under another `self` (`instance_eval`, `Class.new`, `define_method`) never borrow the method's parameter;
27
+ # - the method body never writes the parameter, in any form and at any depth (`x = Integer(x)`, `x ||= …`,
28
+ # a `rescue => x` or pattern target);
29
+ # - the owner is a class, not a module (a module's ivar belongs to whichever object includes it), and its
30
+ # RBS declares the method itself (`defined_in`), so an inherited signature never types a redefinition;
31
+ # - the signature has exactly one overload, whose parameter lists are typed (`(?)` declines) and mention no
32
+ # type variable (a class `Box[T]` or a method `[T]` variable is unsolved at the write);
33
+ # - the class's primary RBS declaration is the project's (`sig/` or rbs-inline), not core, stdlib or a gem's:
34
+ # a project reopening of a library class (`Thread::SizedQueue#initialize`) would otherwise borrow the
35
+ # library's parameter type for its own `def`;
36
+ # - the body neither calls `binding` nor passes code to `eval` / `instance_eval` / `class_eval` /
37
+ # `module_eval` / `instance_exec` as an argument, any of which can rewrite the parameter unseen;
38
+ # - nothing outside the class's own census may write the ivar ({UnseenIvarWriters}): no project ancestor, and
39
+ # no other object's code (`o.instance_variable_set(:@x, v)`);
40
+ # - the declared type is not `untyped`, which is what the write already records.
41
+ module DeclaredParamWrites
42
+ EMPTY = {}.compare_by_identity.freeze
43
+
44
+ SCOPE_BARRIERS = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
45
+ private_constant :SCOPE_BARRIERS
46
+
47
+ BLOCK_NODES = [Prism::BlockNode, Prism::LambdaNode].freeze
48
+ private_constant :BLOCK_NODES
49
+
50
+ OPAQUE_EVAL_CALLS = %i[eval instance_eval class_eval module_eval instance_exec class_exec
51
+ module_exec].to_set.freeze
52
+ private_constant :OPAQUE_EVAL_CALLS
53
+
54
+ WRITER_SCOPE_KEY = :__rigor_declared_param_writer_scope__
55
+ private_constant :WRITER_SCOPE_KEY
56
+
57
+ module_function
58
+
59
+ # Runs the block with `scope` as the one whose project tables the unseen-writer and single-file gates read, in
60
+ # place of the census's own scope. `rigor sig-gen` indexes each file under a scope that carries no project
61
+ # tables, so without it another file's writer, ancestor or reopening would go unseen.
62
+ def with_writer_scope(scope)
63
+ return yield if scope.nil?
64
+
65
+ previous = Thread.current[WRITER_SCOPE_KEY]
66
+ Thread.current[WRITER_SCOPE_KEY] = scope
67
+ begin
68
+ yield
69
+ ensure
70
+ Thread.current[WRITER_SCOPE_KEY] = previous
71
+ end
72
+ end
73
+
74
+ def writer_scope(scope) = Thread.current[WRITER_SCOPE_KEY] || scope
75
+
76
+ # `{InstanceVariableWriteNode => Rigor::Type}` for `def_node`'s creditable writes, or {EMPTY}. The caller
77
+ # passes only an instance `def`: a singleton body's `@x` is the class object's.
78
+ def collect(def_node, class_name, scope)
79
+ return EMPTY if def_node.body.nil? || def_node.parameters.nil?
80
+
81
+ written = Set.new
82
+ writes = []
83
+ return EMPTY if scan(def_node.body, written, writes, in_block: false) == :opaque
84
+
85
+ writes.reject! { |write| written.include?(write.value.name) }
86
+ return EMPTY if writes.empty?
87
+
88
+ types = declared_types(def_node, class_name, scope)
89
+ writers = writer_scope(scope)
90
+ credited = writes.each_with_object({}.compare_by_identity) do |write, acc|
91
+ type = types[write.value.name]
92
+ acc[write] = type if type && !UnseenIvarWriters.possible?(writers, class_name, write.name)
93
+ end
94
+ credited.empty? ? EMPTY : credited
95
+ rescue StandardError
96
+ EMPTY
97
+ end
98
+
99
+ # Collects the `@x = local` writes outside every block, and the name of every local the body writes in
100
+ # any form at any depth. A read at depth zero outside a block names the method's own local, which is the
101
+ # parameter when the body never writes the name.
102
+ #
103
+ # Answers `:opaque` when the body can rewrite a local through code the scan cannot read: a `binding` call, or
104
+ # an `eval`-family call given an argument ({OPAQUE_EVAL_CALLS}).
105
+ def scan(node, written, writes, in_block:)
106
+ return unless node.is_a?(Prism::Node)
107
+ return if SCOPE_BARRIERS.any? { |klass| node.is_a?(klass) }
108
+
109
+ case node
110
+ when Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
111
+ Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableTargetNode
112
+ written << node.name
113
+ when Prism::CallNode
114
+ return :opaque if opaque_call?(node)
115
+ end
116
+ writes << node if !in_block && local_read_write?(node)
117
+ inner = in_block || BLOCK_NODES.any? { |klass| node.is_a?(klass) }
118
+ node.rigor_each_child do |child|
119
+ return :opaque if scan(child, written, writes, in_block: inner) == :opaque
120
+ end
121
+ nil
122
+ end
123
+
124
+ # `binding` hands the frame's locals to code that may write them (`binding.eval("x = 's'")`,
125
+ # `Helper.rw(binding)`); a string passed to an `eval`-family call is code the scan cannot read.
126
+ def opaque_call?(node)
127
+ return true if node.name == :binding
128
+
129
+ OPAQUE_EVAL_CALLS.include?(node.name) && !(node.arguments&.arguments || []).empty?
130
+ end
131
+
132
+ def local_read_write?(node)
133
+ node.is_a?(Prism::InstanceVariableWriteNode) && node.value.is_a?(Prism::LocalVariableReadNode)
134
+ end
135
+
136
+ # The declared, non-`untyped` type of each parameter, or `{}` when a gate declines the method.
137
+ def declared_types(def_node, class_name, scope)
138
+ environment = scope&.environment
139
+ return {} if environment.nil?
140
+ return {} if environment.rbs_module?(class_name)
141
+ return {} unless Reflection.project_declared_class?(class_name, environment: environment)
142
+
143
+ rbs_method = Reflection.instance_method_definition(class_name, def_node.name, environment: environment)
144
+ return {} unless sole_typed_overload?(rbs_method, class_name)
145
+
146
+ binder = MethodParameterBinder.new(environment: environment, class_path: class_name, singleton: false)
147
+ binder.bind(def_node).reject { |_name, type| type.is_a?(Type::Dynamic) }
148
+ end
149
+
150
+ def sole_typed_overload?(rbs_method, class_name)
151
+ return false if rbs_method.nil? || rbs_method.defined_in.to_s.delete_prefix("::") != class_name
152
+
153
+ method_types = rbs_method.method_types
154
+ return false unless method_types.size == 1
155
+
156
+ function = method_types.first.type
157
+ function.respond_to?(:required_positionals) && function.free_variables.empty?
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../global_write_census"
6
+ require_relative "../reflective_ivar_write"
7
+
8
+ module Rigor
9
+ module Inference
10
+ module ScopeIndexer
11
+ # Issue #1154 — whether code the class-ivar census of `class_name` never walks may write the instance variable
12
+ # `ivar` on its instances, where a literal write in the same place would answer for it too. The census joins
13
+ # only the writes in the class's own bodies in one file, so a declared-parameter write ({DeclaredParamWrites}) is
14
+ # credited only when this answers false. The standard is parity with a literal write: what an unknown object's
15
+ # code may do is accepted, as it is for `@x = 1` (the {IvarWriterCensus} policy), and what the class or its
16
+ # ancestors do is not:
17
+ #
18
+ # - a project ancestor on the class's instance resolution chain (a superclass, or a module it or an ancestor
19
+ # includes or prepends) defines the writer `ivar=` (an `attr_writer` / `attr_accessor` or a `def`), or has an
20
+ # instance `def` whose body writes the ivar, in any form, or calls `instance_variable_set` on `self` (`super`
21
+ # from the class's `initialize`, or an inherited setter, runs it on the instance);
22
+ # - an ancestor the project does not declare is reached through a superclass edge and is not a core class (a
23
+ # gem's or a Ruby-written stdlib class's `initialize` may write any ivar), or the chain was cut;
24
+ # - the class or a project ancestor holds an open-world self writer in an instance body (`[:ivar_set_class, …]`,
25
+ # `def configure(&b) = instance_eval(&b)`);
26
+ # - a file other than this one writes the same ivar in an instance `def` of the class (`[:own_write, …]`): the
27
+ # census is per file, so a reopening's write would not be joined;
28
+ # - the project's {GlobalWriteCensus} records a named write to the ivar from outside the object
29
+ # (`o.instance_variable_set(:@x, v)`, `o.instance_eval { @x = v }`).
30
+ #
31
+ # An ancestor's `define_method` body is not read: the ancestor's own file census sees it, this file does not.
32
+ #
33
+ # Every read files its ADR-46 edge: each project ancestor's declaring files ({Scope::ResolutionChain.record_entry})
34
+ # and each of its `def`s read ({Scope#user_def_for}), and the `ivar_set:`, `ivar_set_class:` and `own_write:`
35
+ # name keys the incremental session fires when a file's entries move.
36
+ module UnseenIvarWriters
37
+ BARRIERS = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
38
+ private_constant :BARRIERS
39
+
40
+ module_function
41
+
42
+ def possible?(scope, class_name, ivar)
43
+ census = census(scope)
44
+ foreign_write?(census, ivar) || class_open_world?(census, class_name) ||
45
+ other_file_write?(census, class_name, ivar) || ancestor_write?(scope, census, class_name, ivar)
46
+ end
47
+
48
+ def census(scope)
49
+ census = scope.discovered_global_write_census
50
+ pre_eval = scope.environment&.project_patched_methods&.write_census
51
+ pre_eval.nil? || pre_eval.empty? ? census : census | pre_eval
52
+ end
53
+
54
+ def foreign_write?(census, ivar)
55
+ Analysis::DependencyRecorder.read_name(:ivar_set, ivar.to_s) if Analysis::DependencyRecorder.active?
56
+ census.include?([:ivar_set, ivar])
57
+ end
58
+
59
+ def class_open_world?(census, class_name)
60
+ # A guard: the entry comes from an instance body of the class (or an ancestor), whose declaring files the
61
+ # class's own resolution already re-checks; kept so the read states its own dependency.
62
+ Analysis::DependencyRecorder.read_name(:ivar_set_class, class_name) if Analysis::DependencyRecorder.active?
63
+ census.include?([:ivar_set_class, class_name])
64
+ end
65
+
66
+ # More than one file records an instance-`def` write of the ivar for the class: this file's census sees its
67
+ # own only. An entry no path tags (a census built outside the project pre-pass) counts as this file.
68
+ def other_file_write?(census, class_name, ivar)
69
+ if Analysis::DependencyRecorder.active?
70
+ Analysis::DependencyRecorder.read_name(:own_write, "#{class_name}##{ivar}")
71
+ end
72
+ paths = census.filter_map do |entry|
73
+ entry[3] if entry.size == 4 && entry[0] == :own_write && entry[1] == class_name && entry[2] == ivar
74
+ end
75
+ paths.uniq.size > 1
76
+ end
77
+
78
+ def ancestor_write?(scope, census, class_name, ivar)
79
+ chain = Scope::ResolutionChain.for(scope, class_name, :instance, :constants)
80
+ return true if chain.truncated?
81
+
82
+ setter = :"#{ivar.to_s.delete_prefix('@')}="
83
+ chain.entries.any? do |entry|
84
+ next false if entry.side != :instance || entry.name == class_name
85
+
86
+ # A guard: the class body's own `include` / `<` resolution already files this edge; kept so the read
87
+ # states its own dependency.
88
+ Scope::ResolutionChain.record_entry(scope, entry, :instance) if Analysis::DependencyRecorder.active?
89
+ next foreign_superclass?(scope, entry) if entry.external?
90
+
91
+ class_open_world?(census, entry.name) || scope.discovered_method?(entry.name, setter, :instance) ||
92
+ defs_write?(scope, entry.name, ivar)
93
+ end
94
+ end
95
+
96
+ # A superclass the project does not declare that the interpreter does not implement: a gem's class, or a
97
+ # stdlib one written in Ruby (`Logger` sets `@level` in `initialize`), may write any ivar of its subclass's
98
+ # instances. A module the project does not declare is left alone.
99
+ def foreign_superclass?(scope, entry)
100
+ return false unless entry.superclass_edge
101
+
102
+ loader = scope.environment&.rbs_loader
103
+ entry.candidates.empty? || entry.candidates.none? { |name| loader&.core_class?(name) }
104
+ end
105
+
106
+ def defs_write?(scope, owner, ivar)
107
+ (scope.discovered_def_nodes[owner] || {}).each_key.any? do |method_name|
108
+ node = scope.user_def_for(owner, method_name)
109
+ node.is_a?(Prism::DefNode) && writes?(node.body, ivar)
110
+ end
111
+ end
112
+
113
+ def reflective_name(node) = ReflectiveIvarWrite.literal_name(node)
114
+
115
+ def writes?(node, ivar)
116
+ return false unless node.is_a?(Prism::Node)
117
+ return false if BARRIERS.any? { |klass| node.is_a?(klass) }
118
+
119
+ case node
120
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
121
+ Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
122
+ return true if node.name == ivar
123
+ end
124
+ # A literal `instance_variable_set` of the ivar, or one whose name no literal spells.
125
+ return true if ReflectiveIvarWrite.self_write?(node) && [ivar, nil].include?(reflective_name(node))
126
+
127
+ node.rigor_each_child { |child| return true if writes?(child, ivar) }
128
+ false
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end