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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +28 -5
- data/docs/manual/02-cli-reference.md +23 -4
- data/docs/manual/03-configuration.md +21 -8
- data/docs/manual/04-diagnostics.md +44 -2
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +32 -3
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +26 -9
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
- data/lib/rigor/analysis/check_rules.rb +68 -57
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental.rb +12 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +207 -40
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +139 -15
- data/lib/rigor/analysis/reachability/scan.rb +374 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/rule_catalog.rb +6 -1
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +6 -4
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
- data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
- data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
- data/lib/rigor/analysis/runner.rb +62 -18
- data/lib/rigor/analysis/severity_stamp.rb +34 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/cache/descriptor.rb +40 -6
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +56 -12
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/config_audit.rb +111 -15
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +79 -10
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/block_call_timing.rb +5 -0
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +69 -6
- data/lib/rigor/inference/definer_resolution.rb +16 -7
- data/lib/rigor/inference/expression_typer.rb +117 -29
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/module_function_state.rb +80 -13
- data/lib/rigor/inference/narrowing.rb +184 -68
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +416 -1
- data/lib/rigor/inference/scope_indexer.rb +565 -61
- data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
- data/lib/rigor/inference/statement_evaluator.rb +307 -60
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +133 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +802 -0
- data/lib/rigor/lens/members.rb +203 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +322 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +41 -0
- data/lib/rigor/mcp/server.rb +61 -20
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/project_environment.rb +23 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +33 -17
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/sig_gen/layout_index.rb +36 -0
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/writer.rb +47 -34
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- 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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
143
|
+
empty = registry.nil? || registry.empty?
|
|
144
|
+
return Result.new(digest: nil, opaque_reasons: {}.freeze, fact_store_unreadable: false) if empty
|
|
101
145
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
parts = fact_parts(
|
|
105
|
-
registry.plugins.each { |plugin| collect_plugin_parts(plugin,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
126
|
-
has_hook = collect_hook_part(plugin, id, parts,
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
def facts_by_plugin(registry,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
#
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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|
|
|
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
|