rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -36,12 +36,55 @@ module Rigor
36
36
  # The probe is ALWAYS sequential and independent of the analysis's pool mode, so the invalidation decision
37
37
  # is identical whether the recheck ran pooled or sequential (the pooled-vs-sequential parity requirement).
38
38
  class PluginFactFingerprint
39
- # `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses
40
- # freely. `opaque_plugin_ids` names contributing-but-surfaceless plugins (see the class doc); when
41
- # non-empty the snapshot is never reused.
42
- Result = Data.define(:digest, :opaque_plugin_ids) do
39
+ # Why one plugin is opaque to the fingerprint. `kind` is one of:
40
+ # :no_surface the plugin contributes types and declares no fact, producer or hook;
41
+ # :undigestible a value it declares or publishes cannot be digested (a Proc, an IO, ...);
42
+ # :raised the hook or a producer raised while its value was being read.
43
+ # `input` names the input: `"producer worker_index"`, `"fact callback"`, `"hook incremental_state_fingerprint"`.
44
+ # `class_name` is the class of the value that could not be digested, or of the exception raised; an anonymous
45
+ # class reads "anonymous Class", and nil means the class itself could not be read.
46
+ OpaqueReason = Data.define(:kind, :input, :class_name) do
47
+ # The clause a note states about plugin `id`. The note groups :no_surface plugins itself, so this is only
48
+ # called for :undigestible and :raised reasons.
49
+ def describe(id)
50
+ return "plugin #{id}'s #{input} raised #{class_name}" if kind == :raised
51
+
52
+ "plugin #{id}'s #{input} has a value the incremental fingerprint cannot digest#{parenthesised_class}"
53
+ end
54
+
55
+ # The same verdict without the plugin id, for a `--cache-stats` line that already names it.
56
+ def summary
57
+ return "no incremental fingerprint surface" if kind == :no_surface
58
+ return "#{input} raised #{class_name}" if kind == :raised
59
+
60
+ "#{input} has a value the incremental fingerprint cannot digest#{parenthesised_class}"
61
+ end
62
+
63
+ private
64
+
65
+ def parenthesised_class
66
+ return "" if class_name.nil?
67
+
68
+ " (#{article(class_name)} #{class_name})"
69
+ end
70
+
71
+ def article(word)
72
+ word.match?(/\A[aeiou]/i) ? "an" : "a"
73
+ end
74
+ end
75
+
76
+ # `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses freely.
77
+ # `opaque_reasons` maps each opaque plugin id to its {OpaqueReason}, sorted by id. `fact_store_unreadable` is
78
+ # true when the fact store could not be read at all: no single plugin is to blame, so no reason names one.
79
+ # Either makes the snapshot un-reusable.
80
+ Result = Data.define(:digest, :opaque_reasons, :fact_store_unreadable) do
81
+ # The opaque plugin ids, sorted (the keys of {#opaque_reasons}).
82
+ def opaque_plugin_ids
83
+ opaque_reasons.keys
84
+ end
85
+
43
86
  def opaque?
44
- !opaque_plugin_ids.empty?
87
+ fact_store_unreadable || !opaque_reasons.empty?
45
88
  end
46
89
 
47
90
  # A warm recheck may reuse the snapshot only when this run is not opaque and its fact-surface digest
@@ -97,16 +140,20 @@ module Rigor
97
140
  end
98
141
 
99
142
  def digest_registry(registry)
100
- return Result.new(digest: nil, opaque_plugin_ids: [].freeze) if registry.nil? || registry.empty?
143
+ empty = registry.nil? || registry.empty?
144
+ return Result.new(digest: nil, opaque_reasons: {}.freeze, fact_store_unreadable: false) if empty
101
145
 
102
- opaque = []
103
- facts_by_plugin = facts_by_plugin(registry, opaque)
104
- parts = fact_parts(facts_by_plugin)
105
- registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts_by_plugin, parts, opaque) }
106
- # Sort the parts so the digest is independent of plugin registration / iteration order.
146
+ reasons = {}
147
+ facts, store_unreadable = facts_by_plugin(registry, reasons)
148
+ parts = fact_parts(facts)
149
+ registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts, parts, reasons) }
150
+ # Sort the parts so the digest is independent of plugin registration / iteration order. An unreadable
151
+ # store names no plugin: the snapshot is opaque as a whole, and per-plugin reasons would blame plugins
152
+ # whose facts were simply not read.
107
153
  Result.new(
108
154
  digest: Digest::SHA256.hexdigest(parts.sort.join("\x00")),
109
- opaque_plugin_ids: opaque.uniq.freeze
155
+ opaque_reasons: (store_unreadable ? {} : reasons.sort.to_h).freeze,
156
+ fact_store_unreadable: store_unreadable
110
157
  )
111
158
  end
112
159
 
@@ -119,23 +166,25 @@ module Rigor
119
166
 
120
167
  # Appends one plugin's producer / hook parts to `parts` and marks it opaque when it contributes a type
121
168
  # (`dynamic_return` / `narrowing_facts`) yet declares NONE of (a)/(b)/(c).
122
- def collect_plugin_parts(plugin, facts_by_plugin, parts, opaque)
169
+ def collect_plugin_parts(plugin, facts_by_plugin, parts, reasons)
123
170
  id = safe_id(plugin)
124
171
  has_facts = facts_by_plugin.key?(id)
125
- has_producers = collect_producer_parts(plugin, id, parts, opaque)
126
- has_hook = collect_hook_part(plugin, id, parts, opaque)
172
+ has_producers = collect_producer_parts(plugin, id, parts, reasons)
173
+ has_hook = collect_hook_part(plugin, id, parts, reasons)
127
174
  surface = has_facts || has_producers || has_hook
128
- opaque << id if !surface && contributes_types?(plugin)
175
+ return if surface || !contributes_types?(plugin)
176
+
177
+ mark_opaque(reasons, id, OpaqueReason.new(kind: :no_surface, input: nil, class_name: nil))
129
178
  end
130
179
 
131
- # `{ plugin_id => ["name=digest", ...] }` for every ADR-9 published fact (channel a). A plugin that
132
- # published nothing does not appear.
180
+ # `[{ plugin_id => ["name=digest", ...] }, unreadable]` for every ADR-9 published fact (channel a). A plugin
181
+ # that published nothing does not appear.
133
182
  #
134
- # A fact the fingerprint cannot digest is an input it cannot see, so its publisher is opaque, whether or
135
- # not the publisher contributes a type itself: other plugins read its facts. A fact store that cannot be
136
- # read at all makes every plugin opaque. Both force the full run; dropping the facts instead would let a
137
- # plugin with another surface reuse a snapshot its facts no longer match.
138
- def facts_by_plugin(registry, opaque)
183
+ # A fact the fingerprint cannot digest is an input it cannot see, so its publisher is opaque, whether or not
184
+ # the publisher contributes a type itself: other plugins read its facts. A fact store that cannot be read at
185
+ # all makes the whole surface opaque. Both force the full run; dropping the facts instead would let a plugin
186
+ # with another surface reuse a snapshot its facts no longer match.
187
+ def facts_by_plugin(registry, reasons)
139
188
  result = Hash.new { |hash, key| hash[key] = [] }
140
189
  fact_store = registry.plugins.first&.services&.fact_store
141
190
  fact_store&.each_fact do |fact|
@@ -143,13 +192,12 @@ module Rigor
143
192
  begin
144
193
  result[plugin_id] << "#{fact.name}=#{digest_value(fact.value)}"
145
194
  rescue StandardError
146
- opaque << plugin_id
195
+ mark_opaque(reasons, plugin_id, undigestible("fact #{fact.name}", fact.value))
147
196
  end
148
197
  end
149
- result
198
+ [result, false]
150
199
  rescue StandardError
151
- registry.plugins.each { |plugin| opaque << safe_id(plugin) }
152
- Hash.new { |hash, key| hash[key] = [] }
200
+ [Hash.new { |hash, key| hash[key] = [] }, true]
153
201
  end
154
202
 
155
203
  # Channel (b): a stable signature of every declared producer's VALUE — a digest of the producer's own
@@ -161,28 +209,67 @@ module Rigor
161
209
  # a value-preserving edit keeps the recheck incremental. Returns true when the plugin declared at least
162
210
  # one producer (a surface); a producer whose value cannot be digested marks the plugin opaque.
163
211
  # rubocop:disable-next Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
164
- def collect_producer_parts(plugin, id, parts, opaque)
212
+ def collect_producer_parts(plugin, id, parts, reasons)
165
213
  producer_ids = plugin.class.producers.keys
166
214
  return false if producer_ids.empty?
167
215
 
168
216
  producer_ids.sort.each do |producer_id|
169
- parts << "prod\x1f#{id}\x1f#{producer_id}\x1f#{digest_value(plugin.producer_value(producer_id))}"
170
- rescue StandardError
171
- opaque << id
217
+ input = "producer #{producer_id}"
218
+ begin
219
+ value = plugin.producer_value(producer_id)
220
+ rescue StandardError => e
221
+ mark_opaque(reasons, id, raised(input, e))
222
+ next
223
+ end
224
+ digest_part(parts, reasons, id, input, "prod\x1f#{id}\x1f#{producer_id}", value)
172
225
  end
173
226
  true
174
227
  end
175
228
 
176
- # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the
177
- # plugin defines it (it has a surface).
178
- def collect_hook_part(plugin, id, parts, opaque)
229
+ # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the plugin
230
+ # defines it (it has a surface).
231
+ # rubocop:disable-next Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
232
+ def collect_hook_part(plugin, id, parts, reasons)
179
233
  return false unless plugin.respond_to?(:incremental_state_fingerprint)
180
234
 
181
- parts << "hook\x1f#{id}\x1f#{digest_value(plugin.incremental_state_fingerprint)}"
235
+ input = "hook incremental_state_fingerprint"
236
+ begin
237
+ value = plugin.incremental_state_fingerprint
238
+ rescue StandardError => e
239
+ mark_opaque(reasons, id, raised(input, e))
240
+ return true
241
+ end
242
+ digest_part(parts, reasons, id, input, "hook\x1f#{id}", value)
182
243
  true
244
+ end
245
+
246
+ # Appends `prefix\x1f<digest of value>` to `parts`, or marks the plugin opaque when the value cannot be digested.
247
+ def digest_part(parts, reasons, id, input, prefix, value)
248
+ parts << "#{prefix}\x1f#{digest_value(value)}"
183
249
  rescue StandardError
184
- opaque << id
185
- true
250
+ mark_opaque(reasons, id, undigestible(input, value))
251
+ end
252
+
253
+ # The first reason a plugin is found opaque wins: one reason per plugin id, as {Result#opaque_reasons} states.
254
+ def mark_opaque(reasons, id, reason)
255
+ reasons[id] ||= reason
256
+ end
257
+
258
+ def undigestible(input, value)
259
+ OpaqueReason.new(kind: :undigestible, input: input, class_name: class_name_of(value))
260
+ end
261
+
262
+ def raised(input, error)
263
+ OpaqueReason.new(kind: :raised, input: input, class_name: class_name_of(error))
264
+ end
265
+
266
+ # The name of `object`'s class, "anonymous Class" for an anonymous one, or nil when even the class is unreadable
267
+ # (a BasicObject has no `#class`).
268
+ def class_name_of(object)
269
+ klass = object.class
270
+ klass.name || "anonymous Class"
271
+ rescue StandardError
272
+ nil
186
273
  end
187
274
 
188
275
  # A digest of a fact / producer / hook value that depends on the value alone (issue #1574). A producer's
@@ -5,6 +5,141 @@ require_relative "scan"
5
5
  module Rigor
6
6
  module Analysis
7
7
  module Reachability
8
+ # ADR-102 WD4 — how {Graph} reads a `constantize` / `const_get` site: a literal name becomes a reference
9
+ # looked up where Ruby looks it up, and an interpolated one on a known receiver is anchored there.
10
+ module DynamicLookup
11
+ private
12
+
13
+ # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
14
+ # unknown. Keeping this distinct from {Graph#tainted} is what stops the tier being a blanket namespace
15
+ # poison — Rigor knows the argument's shape, and a type-free indexer does not.
16
+ #
17
+ # A literal `const_get` on a known receiver is looked up where Ruby looks it up (#1761): the receiver,
18
+ # then its ancestors unless the call passes `inherit = false`, then the top level unless it does. A
19
+ # receiver holding the name is the answer — resolving it anywhere else too would make a dead same-named
20
+ # constant reachable; past the receiver see {#literal_targets}. A receiver that does not resolve as a
21
+ # whole is unknown — `Outer::Missing` peeled to `Outer` may alias anything, and `const_get` never
22
+ # searches `Outer` — so it falls back to the top level as an unknown receiver does.
23
+ #
24
+ # The reference carries the referring file's role (WD8): a spec's `const_get("Foo")` keeps `Foo`
25
+ # test-reachable, as its written `Foo` would, rather than making it live in production.
26
+ def literal_dynamic_references(dynamic_uses)
27
+ dynamic_uses.flat_map do |use|
28
+ next [] if use.name.nil?
29
+
30
+ name = use.name.delete_prefix("::")
31
+ targets = use.within ? literal_targets(use, name) : [nil]
32
+ targets.map do |target|
33
+ Scan::Reference.new(as_written: target || name, nesting: [].freeze, from: nil,
34
+ role: use.role, path: use.path, line: use.line,
35
+ rooted: !target.nil? || use.name.start_with?("::"))
36
+ end
37
+ end
38
+ end
39
+
40
+ # The declarations a literal `name` can name when looked up from `use.within`, a member's owner peeled
41
+ # as {#resolve} peels it; nil stands for the top level. The receiver declaring it is the answer. Past
42
+ # the receiver the walk only approximates Ruby: an ancestor named as written may resolve elsewhere
43
+ # (`class Api::Thing < Base` inherits `::Base`, not `Api::Base`), so every scope an ancestor could be
44
+ # that declares the name is kept, and the top level with them. Keeping a dead constant reachable is
45
+ # the cheaper mistake than listing a live one. A receiver that does not resolve is the top level; with
46
+ # `inherit = false` a resolved receiver that does not declare the name is nothing (Ruby raises
47
+ # `NameError` there).
48
+ def literal_targets(use, name)
49
+ receiver = use.receiver { |ref| resolve_ref(ref) }
50
+ return [nil] if receiver.nil?
51
+
52
+ local = @shadows[use.path]
53
+ runtime = use.receiver_open ? runtime_targets(receiver, name, use.inherit, local) : []
54
+ own = declared_member(receiver, name, local)
55
+ return [own, *runtime].uniq if own
56
+ return runtime unless use.inherit
57
+
58
+ (plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) } + runtime)
59
+ .uniq + [nil]
60
+ end
61
+
62
+ # The declarations a descendant of `receiver` holding `name` adds (#1878): a `self` standing for the
63
+ # enclosing declaration is whichever subclass, includer, prepender or extender the method runs on, and
64
+ # Ruby looks the name up there first. Each descendant is searched with its own ancestors unless the call
65
+ # passes `inherit = false`. The descendants are over-approximated as {#plausible_ancestors} is.
66
+ def runtime_targets(receiver, name, inherit, local)
67
+ descendants_of(receiver).flat_map do |descendant|
68
+ scopes = inherit ? [descendant, *plausible_ancestors(descendant)] : [descendant]
69
+ scopes.filter_map { |scope| declared_member(scope, name, local) }
70
+ end.uniq
71
+ end
72
+
73
+ def descendants_of(fqn)
74
+ @plausible_descendants ||= @by_fqn.keys.each_with_object(Hash.new { |h, k| h[k] = [] }) do |declared, out|
75
+ plausible_ancestors(declared).each { |ancestor| out[ancestor] << declared }
76
+ end
77
+ @plausible_descendants.fetch(fqn, [])
78
+ end
79
+
80
+ # `scope::name`, or the longest declared prefix of it, when one is declared.
81
+ def declared_member(scope, name, local)
82
+ segments = name.split("::")
83
+ segments.length.downto(1) do |count|
84
+ candidate = "#{scope}::#{segments.first(count).join('::')}"
85
+ return candidate if declared?(candidate, nil, local)
86
+ end
87
+ nil
88
+ end
89
+
90
+ # Every declaration an ancestor of `fqn` could be: each as-written superclass or mixin resolved at
91
+ # every enclosing scope that declares it, not only the innermost, and transitively.
92
+ def plausible_ancestors(fqn)
93
+ (@plausible_ancestors ||= {})[fqn] ||= plausible_ancestor_walk(fqn)
94
+ end
95
+
96
+ def plausible_ancestor_walk(fqn)
97
+ seen = Set[fqn]
98
+ queue = [fqn]
99
+ out = []
100
+ until queue.empty?
101
+ current = queue.shift
102
+ @by_fqn.fetch(current, []).each do |decl|
103
+ (decl.includes + [decl.superclass]).compact.each do |raw|
104
+ plausible_resolutions(current, raw).each do |resolved|
105
+ next if seen.include?(resolved)
106
+
107
+ seen << resolved
108
+ out << resolved
109
+ queue << resolved
110
+ end
111
+ end
112
+ end
113
+ end
114
+ out.freeze
115
+ end
116
+
117
+ def plausible_resolutions(subclass_fqn, raw)
118
+ segments = subclass_fqn.split("::")
119
+ (segments.length - 1).downto(0).filter_map do |i|
120
+ candidate = (segments[0, i] + [raw]).join("::")
121
+ candidate if candidate != subclass_fqn && @by_fqn.key?(candidate)
122
+ end
123
+ end
124
+
125
+ # The namespaces a `const_get` on `anchors` searches before the top level: the receiver, then, with the
126
+ # default `inherit = true`, every scope its superclass chain and mixins could be (#1761) — a taint is
127
+ # safe to over-reach.
128
+ def receiver_scopes(use, anchors)
129
+ return anchors unless use.inherit
130
+
131
+ anchors.flat_map { |anchor| [anchor, *plausible_ancestors(anchor)] }.uniq
132
+ end
133
+
134
+ # An interpolated site on a known receiver, with the receiver resolved into absolute `prefix`es.
135
+ def anchored(use)
136
+ return [use] if use.within.nil? || use.prefix.nil?
137
+
138
+ anchors = use.anchors { |ref| resolve_ref(ref) }
139
+ receiver_scopes(use, anchors).map { |scope| use.with(prefix: "#{scope}::#{use.prefix}", within: nil) }
140
+ end
141
+ end
142
+
8
143
  # ADR-102 — the cross-file half: resolves every as-written reference to a declaration, then marks from the
9
144
  # root set. Pure data; no engine coupling, so `rigor check`'s diagnostic stream is untouched by
10
145
  # construction rather than by a gate (WD1).
@@ -15,6 +150,8 @@ module Rigor
15
150
  # believe in. The two walks are separate implementations (this one is name-level and needs no types), so
16
151
  # `spec/rigor/analysis/reachability/graph_spec.rb` pins them to the same answers on shared fixtures.
17
152
  class Graph
153
+ include DynamicLookup
154
+
18
155
  Candidate = Data.define(:fqn, :path, :line)
19
156
 
20
157
  # `test_only` is its own list, not a flag on `candidates`: a candidate is by definition unreachable, so
@@ -40,7 +177,6 @@ module Rigor
40
177
  def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [],
41
178
  foreign: ->(_fqn) { false })
42
179
  @declarations = declarations
43
- @references = references + literal_dynamic_references(dynamic_uses)
44
180
  @root_fqns = root_fqns.to_set
45
181
  @foreign = foreign
46
182
  @by_fqn = declarations.group_by(&:fqn)
@@ -48,8 +184,9 @@ module Rigor
48
184
  @shadows.default = Set.new.freeze
49
185
  @owned = @by_fqn.keys.reject { |fqn| @foreign.call(fqn) }.to_set
50
186
  @ancestors = {}
187
+ @references = references + literal_dynamic_references(dynamic_uses)
51
188
  # `Foo.const_get("V#{k}")` bounds its reach by `Foo`, a reference like any other (#1734).
52
- @dynamic_uses = dynamic_uses.flat_map { |use| use.anchored { |ref| resolve_ref(ref) } }
189
+ @dynamic_uses = dynamic_uses.flat_map { |use| anchored(use) }
53
190
  end
54
191
 
55
192
  def report
@@ -87,19 +224,6 @@ module Rigor
87
224
  namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
88
225
  end
89
226
 
90
- # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
91
- # unknown. Keeping this distinct from the taint below is what stops the tier being a blanket namespace
92
- # poison — Rigor knows the argument's shape, and a type-free indexer does not.
93
- def literal_dynamic_references(dynamic_uses)
94
- dynamic_uses.filter_map do |use|
95
- next if use.name.nil?
96
-
97
- Scan::Reference.new(as_written: use.name.sub(/\A::/, ""), nesting: [].freeze, from: nil,
98
- role: :production, path: use.path, line: use.line,
99
- rooted: use.name.start_with?("::"))
100
- end
101
- end
102
-
103
227
  # `{fqn => reason}` for every declaration in `fqns` a dynamic site could still be naming. A site with a
104
228
  # literal prefix taints that namespace and everything under it; a site with no prefix at all cannot be
105
229
  # bounded, so it taints nothing rather than everything — poisoning the whole project would empty the