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
@@ -28,7 +28,10 @@ module Rigor
28
28
  next [] if use.name.nil?
29
29
 
30
30
  name = use.name.delete_prefix("::")
31
- targets = use.within ? literal_targets(use, name) : [nil]
31
+ targets = if use.anywhere then anywhere_targets(name)
32
+ elsif use.within then literal_targets(use, name)
33
+ else [nil]
34
+ end
32
35
  targets.map do |target|
33
36
  Scan::Reference.new(as_written: target || name, nesting: [].freeze, from: nil,
34
37
  role: use.role, path: use.path, line: use.line,
@@ -50,11 +53,103 @@ module Rigor
50
53
  return [nil] if receiver.nil?
51
54
 
52
55
  local = @shadows[use.path]
56
+ runtime = use.receiver_open ? runtime_targets(receiver, name, use.inherit, local) : []
53
57
  own = declared_member(receiver, name, local)
54
- return [own] if own
55
- return [] unless use.inherit
58
+ return [own, *runtime].uniq if own
59
+ return runtime unless use.inherit
60
+ return (anywhere_targets(name) + runtime).uniq if unknown_ancestry?(receiver)
56
61
 
57
- plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) }.uniq + [nil]
62
+ (plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) } + runtime)
63
+ .uniq + [nil]
64
+ end
65
+
66
+ # Every declaration a literal `name` can name from a receiver the walk cannot bound (#1946): each one a
67
+ # scope's `name` (or a declared prefix of it) could be, and the top level.
68
+ def anywhere_targets(name)
69
+ segments = name.split("::")
70
+ found = segments.length.downto(1).flat_map do |count|
71
+ suffix = segments.first(count).join("::")
72
+ by_last_segment.fetch(segments[count - 1], []).select do |fqn|
73
+ fqn == suffix || fqn.end_with?("::#{suffix}")
74
+ end
75
+ end
76
+ found.uniq + [nil]
77
+ end
78
+
79
+ def by_last_segment
80
+ @by_last_segment ||= @by_fqn.keys.group_by { |fqn| fqn.split("::").last }
81
+ end
82
+
83
+ # The declarations a descendant of `receiver` holding `name` adds (#1878): a `self` standing for the
84
+ # enclosing declaration is whichever subclass, includer, prepender or extender the method runs on, and
85
+ # Ruby looks the name up there first. Each descendant is searched with its own ancestors unless the call
86
+ # passes `inherit = false`. The descendants are over-approximated as {#plausible_ancestors} is.
87
+ #
88
+ # A module some unknown receiver includes or extends (`k.include(Mix)`, an `include` in an `included do`
89
+ # block) has descendants the walk cannot list, so the name may be any declaration's (#1946). A class
90
+ # with an ancestor the walk cannot name (`K.include(mods.first)`) may be a descendant of any module, so
91
+ # it is searched too.
92
+ def runtime_targets(receiver, name, inherit, local)
93
+ return anywhere_targets(name) if open_descendants?(receiver)
94
+
95
+ (descendants_of(receiver) | unknown_ancestry_classes).flat_map do |descendant|
96
+ scopes = inherit ? [descendant, *plausible_ancestors(descendant)] : [descendant]
97
+ scopes.filter_map { |scope| declared_member(scope, name, local) }
98
+ end.uniq
99
+ end
100
+
101
+ def descendants_of(fqn)
102
+ @plausible_descendants ||= (@by_fqn.keys | ancestry.edges.keys)
103
+ .each_with_object(Hash.new { |h, k| h[k] = [] }) do |declared, out|
104
+ plausible_ancestors(declared).each { |ancestor| out[ancestor] << declared }
105
+ end
106
+ @plausible_descendants.fetch(fqn, [])
107
+ end
108
+
109
+ def open_descendants?(fqn)
110
+ ancestry.open.include?(fqn) || ancestry.open.any? { |mod| plausible_ancestors(mod).include?(fqn) }
111
+ end
112
+
113
+ # Whether `fqn` or one of its ancestors has an ancestor the walk cannot name, which may declare anything.
114
+ def unknown_ancestry?(fqn)
115
+ ([fqn] + plausible_ancestors(fqn)).any? { |scope| ancestry.unknown.include?(scope) }
116
+ end
117
+
118
+ def unknown_ancestry_classes
119
+ @unknown_ancestry_classes ||= ancestry.unknown.flat_map { |fqn| [fqn, *descendants_of(fqn)] }.uniq
120
+ end
121
+
122
+ # The {Scan::Ancestry} edges read (#1946): `edges` maps a class to the modules and superclass it gains
123
+ # outside its declaration; `open` holds the modules an unknown receiver gains, and `unknown` the classes
124
+ # that gain an ancestor the walk cannot name. A target is every scope its written name could be, declared
125
+ # or not (a gem class reopened as `class Foo::X` declares `Foo::X` alone); an ancestor, every scope that
126
+ # declares it.
127
+ AncestryIndex = Data.define(:edges, :open, :unknown)
128
+ private_constant :AncestryIndex
129
+
130
+ def ancestry
131
+ @ancestry ||= begin
132
+ edges = Hash.new { |h, k| h[k] = [] }
133
+ open = Set.new
134
+ unknown = Set.new
135
+ @ancestries.each do |edge|
136
+ targets = edge.target && written_scopes(edge.target)
137
+ ancestors = edge.ancestor && written_scopes(edge.ancestor).select { |fqn| @by_fqn.key?(fqn) }
138
+ if targets.nil? then open.merge(ancestors)
139
+ elsif ancestors.nil? then unknown.merge(targets)
140
+ else targets.each { |target| edges[target].concat(ancestors - [target]) }
141
+ end
142
+ end
143
+ AncestryIndex.new(edges: edges, open: open, unknown: unknown)
144
+ end
145
+ end
146
+
147
+ # Every scope a written reference could name: rooted, itself; otherwise its name under each enclosing scope.
148
+ def written_scopes(ref)
149
+ name = ref.as_written.delete_prefix("::")
150
+ return [name] if ref.rooted
151
+
152
+ ref.nesting.length.downto(0).map { |i| (ref.nesting.first(i) + [name]).join("::") }
58
153
  end
59
154
 
60
155
  # `scope::name`, or the longest declared prefix of it, when one is declared.
@@ -79,16 +174,15 @@ module Rigor
79
174
  out = []
80
175
  until queue.empty?
81
176
  current = queue.shift
82
- @by_fqn.fetch(current, []).each do |decl|
83
- (decl.includes + [decl.superclass]).compact.each do |raw|
84
- plausible_resolutions(current, raw).each do |resolved|
85
- next if seen.include?(resolved)
177
+ resolved = @by_fqn.fetch(current, []).flat_map do |decl|
178
+ (decl.includes + [decl.superclass]).compact.flat_map { |raw| plausible_resolutions(current, raw) }
179
+ end
180
+ (resolved + ancestry.edges.fetch(current, [])).each do |ancestor|
181
+ next if seen.include?(ancestor)
86
182
 
87
- seen << resolved
88
- out << resolved
89
- queue << resolved
90
- end
91
- end
183
+ seen << ancestor
184
+ out << ancestor
185
+ queue << ancestor
92
186
  end
93
187
  end
94
188
  out.freeze
@@ -154,9 +248,12 @@ module Rigor
154
248
  # A literal-argument site contributes a real reference; a dynamic one taints a namespace (WD4).
155
249
  # @param shadows — declarations made in files outside the analysed paths. They are never nodes, but a
156
250
  # reference written in the SAME file resolves to them first, as Ruby would (#1732, see {#resolve_ref}).
157
- def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [],
251
+ # @param ancestries — {Scan::Ancestry} edges written outside a declaration, read only by the dynamic
252
+ # lookup (#1946).
253
+ def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [], ancestries: [],
158
254
  foreign: ->(_fqn) { false })
159
255
  @declarations = declarations
256
+ @ancestries = ancestries
160
257
  @root_fqns = root_fqns.to_set
161
258
  @foreign = foreign
162
259
  @by_fqn = declarations.group_by(&:fqn)
@@ -57,8 +57,21 @@ module Rigor
57
57
  # matching. A literal `name` keeps `within` too, and {Graph} looks it up from there (#1761). `inherit`
58
58
  # is false when the call passes `inherit = false`, which confines the lookup to the receiver itself;
59
59
  # otherwise the receiver's ancestors are searched as well. `role` is the referring file's (WD8).
60
- DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within, :inherit, :role) do
61
- def initialize(scope: :namespace, within: nil, inherit: true, role: :production, **) = super
60
+ #
61
+ # `receiver_open` is true when `within` is the enclosing declaration standing for a runtime `self` that
62
+ # may be one of its descendants (#1878): a `def self.x = const_get(:X)` runs on whichever subclass it
63
+ # is called on, and a module's method on whichever class includes, prepends or extends it. {Graph}
64
+ # then looks the literal name up in those descendants as well.
65
+ #
66
+ # `anywhere` is true when the receiver is a `self` the walk cannot name at all — the block of an eval call
67
+ # on a local or another computed receiver (`k.class_eval { const_get(:E) }`, #1946). {Graph} then keeps
68
+ # every declaration the literal could name.
69
+ DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within, :inherit, :role,
70
+ :receiver_open, :anywhere) do
71
+ def initialize(scope: :namespace, within: nil, inherit: true, role: :production, receiver_open: false,
72
+ anywhere: false, **)
73
+ super
74
+ end
62
75
 
63
76
  # Whether this site's evidence reaches `fqn`.
64
77
  def taints?(fqn)
@@ -94,7 +107,18 @@ module Rigor
94
107
  end
95
108
  end
96
109
 
97
- Result = Data.define(:declarations, :references, :dynamic_uses)
110
+ # An ancestry edge written outside a declaration's header and body statements (#1946): `K.include(Mix)`,
111
+ # `K.send(:include, Mix)` and the `prepend` / `extend` / `public_send` / `__send__` forms, an `include`
112
+ # in an eval block on `K` or nested in a conditional, and the superclass of `K = Class.new(Base)`. `target`
113
+ # is the class the module joins, `ancestor` the module (or superclass); each is a {Reference} resolved in
114
+ # the call's nesting, or nil when it is not a constant the walk can name — a local receiver, an
115
+ # `include` whose `self` is unknown, or a computed argument (`K.include(mods.first)`). {Graph} reads
116
+ # these only to widen a `const_get` on `self`'s reach, never for ordinary constant resolution.
117
+ Ancestry = Data.define(:target, :ancestor, :path, :line)
118
+
119
+ Result = Data.define(:declarations, :references, :dynamic_uses, :ancestries) do
120
+ def initialize(ancestries: [].freeze, **) = super
121
+ end
98
122
 
99
123
  # Roles a referring file can have (ADR-102 WD8). A reference edge carries its referrer's role so
100
124
  # "used only by its own test" is a reportable category rather than a bucket boundary.
@@ -155,7 +179,7 @@ module Rigor
155
179
  walker = Walker.new(path: path, role: role || role_for(path, root: root))
156
180
  walker.walk(parsed.value, [])
157
181
  Result.new(declarations: walker.declarations.freeze, references: walker.references.freeze,
158
- dynamic_uses: walker.dynamic_uses.freeze)
182
+ dynamic_uses: walker.dynamic_uses.freeze, ancestries: walker.ancestries.freeze)
159
183
  end
160
184
 
161
185
  # The values a local variable can hold, read off its assignments in one local scope, for a
@@ -251,10 +275,51 @@ module Rigor
251
275
  module_exec instance_eval instance_exec].freeze
252
276
  private_constant :SELF_REBINDING
253
277
 
278
+ # The `*_eval` / `*_exec` family: on a constant receiver, the block's `self` IS that constant (#1878).
279
+ EVAL_FAMILY = %i[class_eval module_eval class_exec module_exec instance_eval instance_exec].freeze
280
+ private_constant :EVAL_FAMILY
281
+
282
+ # Calls that define a method from their block: `define_method`'s block is an instance method's body,
283
+ # `define_singleton_method`'s a singleton method's (#1946).
284
+ METHOD_DEFINERS = { define_method: :instance, define_singleton_method: :singleton }.freeze
285
+ private_constant :METHOD_DEFINERS
286
+
254
287
  # A call is a dynamic-resolution site, and its block may run with another `self` ({#rebinds_self?}).
255
288
  def record_call(node, nesting)
256
289
  record_dynamic_use(node, nesting)
257
- @rebinding_blocks << node.block if node.block.is_a?(Prism::BlockNode) && rebinds_self?(node)
290
+ record_mixin(node, nesting)
291
+ return unless node.block.is_a?(Prism::BlockNode)
292
+
293
+ classify_block(node, nesting)
294
+ end
295
+
296
+ def classify_block(node, nesting)
297
+ block = node.block
298
+ if rebinds_self?(node)
299
+ @rebinding_blocks << block
300
+ frame = eval_frame(node, nesting)
301
+ @eval_frames[block] = frame if frame
302
+ elsif meta_new_call?(node)
303
+ @meta_new_blocks << block
304
+ elsif METHOD_DEFINERS.key?(node.name) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
305
+ @definer_blocks[block] = METHOD_DEFINERS.fetch(node.name)
306
+ end
307
+ end
308
+
309
+ # The `self` of an eval call's block (#1878, #1946): the receiver when it is a constant, `:unknown` when
310
+ # it is a local or another computed value, and nil — the reading the walk had before — on `self`,
311
+ # `self.class`, an implicit receiver or `Object`. `singleton_defs` is whether a plain `def` in the block
312
+ # defines a singleton method of the receiver (`instance_eval`) rather than an instance method.
313
+ def eval_frame(call, nesting)
314
+ return anonymous_subclass_frame(call, nesting) if call.name == :new
315
+ return nil unless EVAL_FAMILY.include?(call.name)
316
+
317
+ singleton_defs = %i[instance_eval instance_exec].include?(call.name)
318
+ receiver = call.receiver
319
+ return EvalFrame.new(receiver_reference(receiver, nesting), singleton_defs) if constant_receiver?(receiver)
320
+ return nil if receiver.nil? || self_receiver?(receiver) || constant_path?(receiver)
321
+
322
+ EvalFrame.new(:unknown, singleton_defs)
258
323
  end
259
324
 
260
325
  # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
@@ -287,9 +352,11 @@ module Rigor
287
352
  else []
288
353
  end
289
354
  bounds = [[nil, :namespace, nil]] if bounds.empty?
355
+ record_anywhere(node, name, reason)
290
356
  bounds.each do |prefix, scope, within|
291
357
  @dynamic_uses << DynamicUse.new(name: name, prefix: prefix, scope: scope, within: within,
292
358
  inherit: anchors.include?(TOP_LEVEL), role: @role,
359
+ receiver_open: !within.nil? && runtime_self?(node),
293
360
  site: site(node), path: @path, line: node.location.start_line,
294
361
  reason: reason)
295
362
  end
@@ -318,12 +385,55 @@ module Rigor
318
385
  end
319
386
  end
320
387
 
388
+ # A literal `const_get` on a `self` the walk cannot name at all reaches any declaration of that name.
389
+ def record_anywhere(node, name, reason)
390
+ return unless name && !name.start_with?("::") && unknown_self?(node)
391
+
392
+ @dynamic_uses << DynamicUse.new(name: name, prefix: nil, anywhere: true, role: @role, site: site(node),
393
+ path: @path, line: node.location.start_line, reason: reason)
394
+ end
395
+
396
+ # Whether a `const_get` on `self` can run on a descendant of the enclosing declaration (#1878): in a
397
+ # method, or a block that rebinds `self`, `self` is whichever subclass or includer the method was
398
+ # called on. Directly in a class body, or inside `Other.class_eval { }`, it is the declaration itself.
399
+ def runtime_self?(node)
400
+ node.name == :const_get && self_receiver?(node.receiver) && @eval_frame.nil? &&
401
+ !(@self_kind == :declaration && !@in_block && !@self_rebound)
402
+ end
403
+
404
+ # Whether a `const_get` on `self` runs on a receiver the walk cannot name at all (#1946): directly in the
405
+ # block of an eval call on a computed receiver, or in a method that block defines when the receiver form
406
+ # names the eval receiver ({#method_receiver?}).
407
+ def unknown_self?(node)
408
+ return false unless node.name == :const_get && self_receiver?(node.receiver)
409
+ return @eval_frame.receiver == :unknown && !node.receiver.is_a?(Prism::CallNode) if @eval_frame
410
+
411
+ !@eval_method.nil? && @eval_method.receiver == :unknown && method_receiver?(node.receiver)
412
+ end
413
+
414
+ # Whether `receiver` names the eval receiver in a method an eval block defines: `self.class` in an
415
+ # instance method (`self` is an instance of the receiver or of a descendant), an implicit receiver or
416
+ # `self` in a singleton method. An implicit `const_get` in an instance method raises `NoMethodError`.
417
+ def method_receiver?(receiver)
418
+ class_of_self = receiver.is_a?(Prism::CallNode)
419
+ @eval_method.kind == :instance ? class_of_self : !class_of_self
420
+ end
421
+
422
+ # Inside a method an eval block on a constant defines, the receiver form that names that constant reads
423
+ # it as a runtime `self` that may be any of its descendants (#1946).
424
+ def eval_method_references(receiver)
425
+ return [] unless @eval_method&.receiver.is_a?(Reference) && method_receiver?(receiver)
426
+
427
+ [@eval_method.receiver]
428
+ end
429
+
321
430
  # Where a `const_get` on `self` (implicit, `self`, or `self.class`) looks its name up: `:declaration`
322
431
  # when `self` is the enclosing class or module, `:top_level` when it is `Class` or a singleton class,
323
432
  # `:unknown` otherwise. A block's `self` is unknown — `included do`, `class_eval` and an instance-
324
433
  # evaluated DSL block (`configure { }`, an RSpec `describe` body) all rebind it, and the walk cannot
325
434
  # tell those from an `each` block. So is the `self` of a `def Other.x` or of a `class << Other` body.
326
435
  def self_lookup(receiver)
436
+ return :declaration if @eval_frame&.receiver.is_a?(Reference) && !receiver.is_a?(Prism::CallNode)
327
437
  return :unknown if @in_block || @self_rebound
328
438
 
329
439
  class_of_self = receiver.is_a?(Prism::CallNode)
@@ -345,7 +455,7 @@ module Rigor
345
455
  receiver = node.receiver
346
456
  namespaces =
347
457
  if self_receiver?(receiver)
348
- self_references(node, nesting)
458
+ eval_self_references(receiver) || (self_references(node, nesting) + eval_method_references(receiver))
349
459
  elsif constant_receiver?(receiver)
350
460
  [receiver_reference(receiver, nesting)]
351
461
  else
@@ -357,6 +467,12 @@ module Rigor
357
467
  inherit.is_a?(Prism::FalseNode) ? namespaces : namespaces + [TOP_LEVEL]
358
468
  end
359
469
 
470
+ # Inside `Other.class_eval { }` an implicit or `self` receiver is `Other` (#1878).
471
+ def eval_self_references(receiver)
472
+ frame = @eval_frame
473
+ [frame.receiver] if frame&.receiver.is_a?(Reference) && !receiver.is_a?(Prism::CallNode)
474
+ end
475
+
360
476
  # The enclosing declaration as a rooted {Reference}, so {Graph} anchors it as it anchors a constant
361
477
  # receiver and can search its ancestors. Inside a meta-new rvalue `self` is the declared class in its
362
478
  # block but the outer scope in its arguments; the walk does not tell the two apart, so both are taken.
@@ -373,9 +489,13 @@ module Rigor
373
489
  def rebinds_self?(call)
374
490
  return true if SELF_REBINDING.include?(call.name)
375
491
 
492
+ @owner.nil? && meta_new_call?(call)
493
+ end
494
+
495
+ def meta_new_call?(call)
376
496
  recv = call.receiver
377
497
  name = recv && Source::ConstantPath.qualified_name_or_nil(recv)&.delete_prefix("::")
378
- @owner.nil? && !name.nil? && META_NEW[name] == call.name
498
+ !name.nil? && META_NEW[name] == call.name
379
499
  end
380
500
 
381
501
  def self_receiver?(receiver)
@@ -456,11 +576,127 @@ module Rigor
456
576
  end
457
577
  end
458
578
 
579
+ # #1946 — the {Walker}'s reading of ancestry written outside a declaration's own statements, as
580
+ # {Ancestry} edges: receiver-form and `send`-spelled mixin calls, a mixin call whose `self` is not the
581
+ # enclosing declaration body, and the superclass of `K = Class.new(Base)`.
582
+ module AncestrySites
583
+ private
584
+
585
+ # An anonymous `Class.new(Base) { }` block's `self` is a subclass of `Base` that declares no constant of
586
+ # its own, so a `const_get` on it is read at `Base` (#1946).
587
+ def anonymous_subclass_frame(call, nesting)
588
+ superclass = call.arguments&.arguments&.first
589
+ return nil unless call.receiver.is_a?(Prism::ConstantReadNode) && call.receiver.name == :Class &&
590
+ constant_receiver?(superclass)
591
+
592
+ EvalFrame.new(receiver_reference(superclass, nesting), false)
593
+ end
594
+
595
+ def constant_path?(node)
596
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
597
+ end
598
+
599
+ # Mixin calls: the ones a class body writes, and their `send` spellings.
600
+ MIXINS = %i[include prepend extend].freeze
601
+ private_constant :MIXINS
602
+
603
+ SENDS = %i[send public_send __send__].freeze
604
+ private_constant :SENDS
605
+
606
+ # Records an {Ancestry} for a mixin call the declaration's own statements do not carry (#1946). A
607
+ # receiver-less `include` directly in a class body is already one of its `includes` when every argument
608
+ # is a constant; a computed one (`include Mods.pick`) is an ancestor the walk cannot name. A `self`
609
+ # argument is the `self` the call is written in (`Foo.include(self)` in `module Mix`), not the receiver.
610
+ def record_mixin(node, nesting)
611
+ modules = mixin_arguments(node)
612
+ return if modules.nil? || modules.empty?
613
+ return if node.receiver.nil? && !SENDS.include?(node.name) && @body_statements.include?(node) &&
614
+ modules.all? { |arg| mixin_reference(arg, nesting) }
615
+
616
+ target = mixin_target(node, nesting)
617
+ modules.each do |arg|
618
+ ancestor = arg.is_a?(Prism::SelfNode) ? self_target(node, nesting) : mixin_reference(arg, nesting)
619
+ next if target.nil? && ancestor.nil?
620
+
621
+ @ancestries << Ancestry.new(target: target, ancestor: ancestor, path: @path,
622
+ line: node.location.start_line)
623
+ end
624
+ end
625
+
626
+ # The module arguments of a mixin call, or nil when it is not one.
627
+ def mixin_arguments(node)
628
+ args = node.arguments&.arguments || []
629
+ return args if MIXINS.include?(node.name)
630
+ return nil unless SENDS.include?(node.name)
631
+
632
+ verb = args.first
633
+ name = verb.unescaped if verb.is_a?(Prism::SymbolNode) || verb.is_a?(Prism::StringNode)
634
+ # Compared as strings: `to_sym` raises on a literal that is not valid in its encoding (`send("\xFF")`).
635
+ MIXINS.any? { |mixin| mixin.name == name } ? args.drop(1) : nil
636
+ end
637
+
638
+ def mixin_reference(arg, nesting)
639
+ return nil unless constant_path?(arg) && Source::ConstantPath.qualified_name_or_nil(arg)
640
+
641
+ receiver_reference(arg, nesting)
642
+ end
643
+
644
+ # The class a mixin call adds to: a constant receiver, or for `self` and an implicit receiver whatever
645
+ # `self` is — the eval receiver in an eval block, the enclosing declaration in its body. Anything else,
646
+ # the top level (`Object`, whose descendants are every class) included, is unknown (nil).
647
+ def mixin_target(node, nesting)
648
+ receiver = node.receiver
649
+ return mixin_reference(receiver, nesting) unless receiver.nil? || receiver.is_a?(Prism::SelfNode)
650
+
651
+ self_target(node, nesting)
652
+ end
653
+
654
+ # What `self` is at `node` as a {Reference}, where the walk can name it; nil otherwise.
655
+ def self_target(node, nesting)
656
+ frame = @eval_frame
657
+ return frame&.receiver.is_a?(Reference) ? frame.receiver : nil if frame || @eval_method
658
+ return nil if @in_block || @self_rebound || @self_kind != :declaration || nesting.empty?
659
+
660
+ rooted_reference(nesting.join("::"), node)
661
+ end
662
+
663
+ def rooted_reference(fqn, node)
664
+ Reference.new(as_written: fqn, nesting: [].freeze, from: nil, role: @role, path: @path,
665
+ line: node.location.start_line, rooted: true)
666
+ end
667
+
668
+ # `K = Class.new(Base)` makes `Base` an ancestor of `K` (#1946). It stays out of the declaration's
669
+ # `superclass`, which ordinary constant resolution reads: the engine does not resolve a constant
670
+ # through a `Class.new` superclass either, and the two are pinned to agree.
671
+ def record_class_new_superclass(call, fqn, nesting)
672
+ superclass = call.arguments&.arguments&.first
673
+ return unless call.receiver.name == :Class && superclass
674
+
675
+ ancestor = if superclass.is_a?(Prism::SelfNode) then self_target(call, nesting)
676
+ else mixin_reference(superclass, nesting)
677
+ end
678
+ @ancestries << Ancestry.new(target: rooted_reference(fqn, call), ancestor: ancestor,
679
+ path: @path, line: call.location.start_line)
680
+ end
681
+
682
+ # The receiver-less statements directly in a declaration body.
683
+ def body_statements(body)
684
+ body.child_nodes.select { |stmt| stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? }
685
+ .to_set.compare_by_identity
686
+ end
687
+ end
688
+
689
+ # The `self` of an eval block ({DynamicSites#eval_frame}): a {Reference}, or `:unknown`.
690
+ EvalFrame = Data.define(:receiver, :singleton_defs)
691
+ # A method an eval block defines on its receiver, and whether it is an `:instance` or `:singleton` method.
692
+ EvalMethod = Data.define(:receiver, :kind)
693
+
459
694
  # Single-pass walker. Tracks `nesting` as the stack of enclosing declaration names.
460
695
  class Walker
461
696
  include DynamicSites
697
+ include AncestrySites
462
698
 
463
- attr_reader :declarations, :references, :dynamic_uses
699
+ attr_reader :declarations, :references, :dynamic_uses, :ancestries
464
700
 
465
701
  def initialize(path:, role:)
466
702
  @path = path
@@ -468,6 +704,10 @@ module Rigor
468
704
  @declarations = []
469
705
  @references = []
470
706
  @dynamic_uses = []
707
+ @ancestries = []
708
+ # The receiver-less statements directly in the declaration body being walked, which its own
709
+ # `includes` already carry. See {#record_mixin}.
710
+ @body_statements = Set.new.compare_by_identity
471
711
  # The declaration a meta-new rvalue is being walked for, or nil. See {#walk_constant_write}.
472
712
  @owner = nil
473
713
  # The node whose subtree holds the local variables in scope: the file, a method, or a class body.
@@ -478,6 +718,15 @@ module Rigor
478
718
  # `class_eval` or anonymous `Class.new` block. See {#rebinds_self?}.
479
719
  @self_rebound = false
480
720
  @rebinding_blocks = Set.new.compare_by_identity
721
+ # The {EvalFrame} of each `*_eval` block, and the one whose `self` the walk is directly in (nil when
722
+ # `self` is not an eval receiver). See {#eval_frame}.
723
+ @eval_frames = {}.compare_by_identity
724
+ @eval_frame = nil
725
+ # Inside a method an eval block defines (a `def`, or a `define_method` / `define_singleton_method`
726
+ # block), the eval receiver and the method's kind; nil elsewhere. See {#eval_method_references}.
727
+ @eval_method = nil
728
+ @meta_new_blocks = Set.new.compare_by_identity
729
+ @definer_blocks = {}.compare_by_identity
481
730
  # What `self` is here, for {#self_lookup}: `:declaration` in a class or module body (and at the top
482
731
  # level), `:singleton_method` in a `def self.x` or a def in a `class << self` body,
483
732
  # `:instance_method` in any other def, `:singleton_class` in a `class << self` body outside its
@@ -545,14 +794,18 @@ module Rigor
545
794
 
546
795
  # The body's nesting names the declaration itself, so a credit inherited from a meta-new rvalue
547
796
  # this declaration sits in must not leak into it.
548
- owner = @owner
549
- saved = [@self_rebound, @in_block, @module_body]
797
+ walk_body(node.body, nesting + [name], module_body)
798
+ end
799
+
800
+ def walk_body(body, nesting, module_body)
801
+ saved = [@owner, @self_rebound, @in_block, @module_body, @body_statements]
550
802
  @owner = nil
551
803
  @self_rebound = @in_block = false
552
804
  @module_body = module_body
553
- in_local_scope(node.body, :declaration) { walk(node.body, nesting + [name]) }
554
- @owner = owner
555
- @self_rebound, @in_block, @module_body = saved
805
+ @body_statements = body_statements(body)
806
+ in_local_scope(body, :declaration) { walk(body, nesting) }
807
+ ensure
808
+ @owner, @self_rebound, @in_block, @module_body, @body_statements = saved
556
809
  end
557
810
 
558
811
  # A meta-new rvalue — `Class.new(Base) { ... }`, `Struct.new(:a) do ... end` — is the declaration's
@@ -585,6 +838,7 @@ module Rigor
585
838
  fqn = (nesting + [node.name.to_s]).join("::")
586
839
  @declarations << Declaration.new(fqn: fqn, path: @path,
587
840
  line: node.location.start_line, superclass: nil, includes: [].freeze)
841
+ record_class_new_superclass(call, fqn, nesting)
588
842
  fqn
589
843
  end
590
844
 
@@ -592,23 +846,35 @@ module Rigor
592
846
  # top level of the body is inspected: a mixin applied inside a conditional or a nested def is not a
593
847
  # static ancestor edge.
594
848
  def mixin_names(body)
595
- body.child_nodes.filter_map do |stmt|
596
- next unless stmt.is_a?(Prism::CallNode) && %i[include prepend extend].include?(stmt.name)
597
- next if stmt.receiver
849
+ body.child_nodes.flat_map do |stmt|
850
+ next [] unless stmt.is_a?(Prism::CallNode) && %i[include prepend extend].include?(stmt.name)
851
+ next [] if stmt.receiver
598
852
 
599
- arg = stmt.arguments&.arguments&.first
600
- arg && Source::ConstantPath.qualified_name_or_nil(arg)
853
+ (stmt.arguments&.arguments || []).filter_map { |arg| Source::ConstantPath.qualified_name_or_nil(arg) }
601
854
  end
602
855
  end
603
856
 
604
857
  def in_local_scope(scope, self_kind = self_kind_in(scope))
605
- saved = [@local_scope, @block_params, @self_kind]
858
+ saved = [@local_scope, @block_params, @self_kind, @eval_frame, @eval_method]
859
+ @eval_method = eval_def(scope)
860
+ @eval_frame = nil
606
861
  @local_scope = scope
607
862
  @block_params = Set.new.freeze
608
863
  @self_kind = self_kind
609
864
  yield
610
865
  ensure
611
- @local_scope, @block_params, @self_kind = saved
866
+ @local_scope, @block_params, @self_kind, @eval_frame, @eval_method = saved
867
+ end
868
+
869
+ # The method a `def` directly in an eval block defines on the eval receiver (#1946): a plain `def` is an
870
+ # instance method, or a singleton one under `instance_eval`; a `def self.x` is a singleton method.
871
+ def eval_def(scope)
872
+ frame = @eval_frame
873
+ return nil unless frame && scope.is_a?(Prism::DefNode)
874
+
875
+ if scope.receiver.nil? then EvalMethod.new(frame.receiver, frame.singleton_defs ? :singleton : :instance)
876
+ elsif scope.receiver.is_a?(Prism::SelfNode) then EvalMethod.new(frame.receiver, :singleton)
877
+ end
612
878
  end
613
879
 
614
880
  # The {#self_lookup} kind inside `scope`, from the kind around it. Only a receiver-less def or a `def
@@ -642,14 +908,30 @@ module Rigor
642
908
  end
643
909
 
644
910
  def in_block(block)
645
- saved = [@block_params, @self_rebound, @in_block]
911
+ saved = [@block_params, @self_rebound, @in_block, @eval_frame, @eval_method]
646
912
  names = block.parameters ? LocalValues.bindings(block.parameters) : []
647
913
  @block_params = (@block_params | names).freeze unless names.empty?
648
- @self_rebound ||= @rebinding_blocks.include?(block)
914
+ enter_block_self(block)
649
915
  @in_block = true
650
916
  yield
651
917
  ensure
652
- @block_params, @self_rebound, @in_block = saved
918
+ @block_params, @self_rebound, @in_block, @eval_frame, @eval_method = saved
919
+ end
920
+
921
+ # A block whose `self` is another object drops the eval reading around it: an eval block sets its own
922
+ # (#1878), and a `Class.new` / `Struct.new` block's `self` is the class it builds. A `define_method` /
923
+ # `define_singleton_method` block directly in an eval block is a method of the eval receiver (#1946).
924
+ def enter_block_self(block)
925
+ if @rebinding_blocks.include?(block)
926
+ @self_rebound = true
927
+ @eval_frame = @eval_frames[block]
928
+ @eval_method = nil
929
+ elsif @meta_new_blocks.include?(block)
930
+ @eval_frame = @eval_method = nil
931
+ elsif @eval_frame && @definer_blocks.key?(block)
932
+ @eval_method = EvalMethod.new(@eval_frame.receiver, @definer_blocks.fetch(block))
933
+ @eval_frame = nil
934
+ end
653
935
  end
654
936
 
655
937
  def site(node)