rigortype 0.4.4 → 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.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/docs/handbook/11-sig-gen.md +90 -17
- data/docs/manual/02-cli-reference.md +39 -9
- data/docs/manual/03-configuration.md +10 -8
- data/docs/manual/04-diagnostics.md +16 -5
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +2 -1
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +8 -1
- data/docs/manual/18-removing-dead-code.md +18 -7
- data/docs/manual/plugins/rigor-rspec.md +101 -1
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
- data/lib/rigor/analysis/check_rules.rb +228 -15
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +49 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +166 -24
- data/lib/rigor/analysis/path_expansion.rb +3 -1
- data/lib/rigor/analysis/reachability/graph.rb +90 -13
- data/lib/rigor/analysis/reachability/scan.rb +279 -39
- data/lib/rigor/analysis/rule_catalog.rb +17 -4
- data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
- data/lib/rigor/analysis/runner.rb +69 -65
- data/lib/rigor/analysis/severity_stamp.rb +11 -0
- data/lib/rigor/analysis/worker_session.rb +12 -1
- data/lib/rigor/cache/descriptor.rb +40 -7
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +26 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +32 -11
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +60 -15
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +29 -3
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +21 -1
- data/lib/rigor/inference/block_call_timing.rb +7 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/captured_locals.rb +44 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/definer_resolution.rb +87 -20
- data/lib/rigor/inference/expression_typer.rb +35 -5
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher.rb +67 -3
- data/lib/rigor/inference/module_function_state.rb +137 -42
- data/lib/rigor/inference/narrowing.rb +54 -10
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +120 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer.rb +675 -105
- data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +446 -139
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/lens/contract_reader.rb +45 -2
- data/lib/rigor/lens/document.rb +2 -1
- data/lib/rigor/lens/file_reader.rb +12 -2
- data/lib/rigor/lens/members.rb +16 -2
- data/lib/rigor/lens/run.rb +34 -7
- data/lib/rigor/mcp/server.rb +2 -2
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/protection/discovery_seed.rb +9 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +27 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +45 -2
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +167 -22
- data/lib/rigor/sig_gen/layout_index.rb +5 -2
- data/lib/rigor/sig_gen/observation_collector.rb +137 -11
- data/lib/rigor/sig_gen/renderer.rb +13 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +5 -3
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/constant.rb +10 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +4 -3
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +13 -1
|
@@ -7,6 +7,7 @@ require_relative "../incremental_run_slot"
|
|
|
7
7
|
require_relative "../dependency_source_inference/gem_resolver"
|
|
8
8
|
require_relative "../../cache/file_digest"
|
|
9
9
|
require_relative "../../cache/incremental_snapshot"
|
|
10
|
+
require_relative "../../rooted_glob"
|
|
10
11
|
|
|
11
12
|
module Rigor
|
|
12
13
|
module Analysis
|
|
@@ -66,8 +67,8 @@ module Rigor
|
|
|
66
67
|
# opted-in gems' resolved versions, the key's one input that is neither a file nor the configuration (issue
|
|
67
68
|
# #1814), are read at the mark and watched the same way.
|
|
68
69
|
#
|
|
69
|
-
# The mark also records
|
|
70
|
-
# analysis roots, the `pre_eval:` entries and the signature roots.
|
|
70
|
+
# The mark also records what each path a presence row can name is ({Cache::Descriptor::FileEntry.kind_of}):
|
|
71
|
+
# the lockfiles, the analysis roots, the `pre_eval:` entries and the signature roots.
|
|
71
72
|
def self.start(configuration:, roots:, cache_root:, fingerprint:)
|
|
72
73
|
return nil if Gem.win_platform?
|
|
73
74
|
|
|
@@ -79,7 +80,7 @@ module Rigor
|
|
|
79
80
|
lockfiles = lockfile_paths(configuration)
|
|
80
81
|
named = Array(roots) + configuration.pre_eval + (configuration.signature_paths || ["sig"])
|
|
81
82
|
presence = (lockfiles + named.map { |path| File.absolute_path(path.to_s) }).to_h do |path|
|
|
82
|
-
[path,
|
|
83
|
+
[path, Cache::Descriptor::FileEntry.kind_of(path)]
|
|
83
84
|
end
|
|
84
85
|
rows = dependency_rows(configuration)
|
|
85
86
|
return nil if rows.nil?
|
|
@@ -174,11 +175,12 @@ module Rigor
|
|
|
174
175
|
# - no lockfile the key or the fingerprint digests was written, created or removed, and no opted-in gem
|
|
175
176
|
# resolves to another version than at the mark (issue #1814);
|
|
176
177
|
# - no file `rows` or `pinned` names changed. A content row's file changed if its change time moved. An
|
|
177
|
-
# existence row asks only whether its path is there,
|
|
178
|
-
# went,
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
178
|
+
# existence row asks only whether its path is there, and a `:kind` row what it is (#1977), so a path the
|
|
179
|
+
# mark recorded changed if it came or went, or for a kind row turned from a directory into a file or back;
|
|
180
|
+
# an editor's lock file created and removed beside it, which moves its directory's change time, does not
|
|
181
|
+
# count. That needs no clock, so it holds on any filesystem; it cannot see a path that comes and goes
|
|
182
|
+
# again within the run. Any other presence row falls back to change times: its own when present, its
|
|
183
|
+
# nearest existing ancestor's when absent;
|
|
182
184
|
# - no directory a glob row lists gained or lost an entry, nor, for a stat-mode glob, did any file it
|
|
183
185
|
# matches change.
|
|
184
186
|
#
|
|
@@ -231,15 +233,20 @@ module Rigor
|
|
|
231
233
|
ns.nil? || device != @device || ns < @started_ns
|
|
232
234
|
end
|
|
233
235
|
|
|
234
|
-
#
|
|
235
|
-
# is there, the second whether it changed), so the row that comes first must not hide the
|
|
236
|
+
# A presence row and a content row for one path are different questions (the first asks only whether it
|
|
237
|
+
# is there, or what it is, the second whether it changed), so the row that comes first must not hide the
|
|
238
|
+
# other; nor may an existence row hide a kind row.
|
|
239
|
+
PRESENCE_COMPARATORS = %i[exists kind].freeze
|
|
240
|
+
private_constant :PRESENCE_COMPARATORS
|
|
241
|
+
|
|
236
242
|
def files_unchanged?(entries, strict:)
|
|
237
243
|
seen = Set.new
|
|
238
244
|
entries.none? do |entry|
|
|
239
|
-
|
|
245
|
+
presence = PRESENCE_COMPARATORS.include?(entry.comparator) ? entry.comparator : nil
|
|
246
|
+
next false unless seen.add?([entry.path, presence])
|
|
240
247
|
|
|
241
|
-
if
|
|
242
|
-
|
|
248
|
+
if presence
|
|
249
|
+
presence_changed?(entry.path, presence, strict: strict)
|
|
243
250
|
else
|
|
244
251
|
changed?(entry.path, strict: strict)
|
|
245
252
|
end
|
|
@@ -248,8 +255,8 @@ module Rigor
|
|
|
248
255
|
|
|
249
256
|
def globs_unchanged?(entries, strict:)
|
|
250
257
|
entries.none? do |entry|
|
|
251
|
-
listed =
|
|
252
|
-
matched = entry.mode == :stat ?
|
|
258
|
+
listed = RootedGlob.glob(entry.root, File.join(File.dirname(entry.pattern), ""))
|
|
259
|
+
matched = entry.mode == :stat ? RootedGlob.glob(entry.root, entry.pattern) : []
|
|
253
260
|
(listed + matched).any? { |path| changed?(path, strict: strict) }
|
|
254
261
|
end
|
|
255
262
|
end
|
|
@@ -257,14 +264,25 @@ module Rigor
|
|
|
257
264
|
# A lockfile is read for its bytes: one absent at the mark must still be absent, one present must still be
|
|
258
265
|
# there with its change time before the mark.
|
|
259
266
|
def lockfile_changed?(path)
|
|
260
|
-
return File.exist?(path) unless
|
|
267
|
+
return File.exist?(path) unless present_at_mark?(path)
|
|
261
268
|
|
|
262
269
|
changed?(path, strict: true, missing: true)
|
|
263
270
|
end
|
|
264
271
|
|
|
265
|
-
def
|
|
272
|
+
def present_at_mark?(path)
|
|
273
|
+
kind = @presence[path]
|
|
274
|
+
!kind.nil? && kind != Cache::Descriptor::FileEntry::KIND_ABSENT
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# `comparator` is `:exists` or `:kind`: whether the path came or went, or whether it is no longer the kind
|
|
278
|
+
# of thing the mark recorded.
|
|
279
|
+
def presence_changed?(path, comparator, strict:)
|
|
266
280
|
absolute = File.absolute_path(path)
|
|
267
|
-
|
|
281
|
+
if @presence.key?(absolute)
|
|
282
|
+
return File.exist?(absolute) != present_at_mark?(absolute) if comparator == :exists
|
|
283
|
+
|
|
284
|
+
return Cache::Descriptor::FileEntry.kind_of(absolute) != @presence[absolute]
|
|
285
|
+
end
|
|
268
286
|
|
|
269
287
|
nearest = absolute
|
|
270
288
|
nearest = File.dirname(nearest) until File.exist?(nearest) || File.dirname(nearest) == nearest
|
|
@@ -40,15 +40,19 @@ module Rigor
|
|
|
40
40
|
# unconditional snapshot rewrite.
|
|
41
41
|
# `rebaselined` (#1898) is true when the recheck found its served effect collections stale and answered with a
|
|
42
42
|
# full baseline instead; see {#rebaseline_recheck}.
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# `stale_readers` (#1553) are the files the recheck re-analysed because a plugin read one of them made went
|
|
44
|
+
# stale ({#plugin_read_affected}).
|
|
45
|
+
Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined,
|
|
46
|
+
:stale_readers) do
|
|
47
|
+
def initialize(rebaselined: false, stale_readers: Set.new, **rest)
|
|
45
48
|
super
|
|
46
49
|
end
|
|
47
50
|
|
|
48
|
-
# A recheck that changed, added, and removed nothing — the session state is
|
|
49
|
-
# snapshot that was restored, so persisting it again would only rewrite identical
|
|
51
|
+
# A recheck that changed, added, and removed nothing, and found no stale plugin read — the session state is
|
|
52
|
+
# byte-equivalent to the snapshot that was restored, so persisting it again would only rewrite identical
|
|
53
|
+
# bytes. A stale reader's refreshed rows differ (#1553): unsaved, every later run would re-analyse it.
|
|
50
54
|
def no_change?
|
|
51
|
-
changed.empty? && added.empty? && removed.empty?
|
|
55
|
+
changed.empty? && added.empty? && removed.empty? && stale_readers.empty?
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
|
|
@@ -99,6 +103,9 @@ module Rigor
|
|
|
99
103
|
@cache = {} # analyzed path => [Diagnostic]
|
|
100
104
|
@sources = {} # analyzed path => Set<source path it read from>
|
|
101
105
|
@digests = {} # analyzed path => ADR-87 packed stat-digest entry at last analysis
|
|
106
|
+
# Issue #1583 — discovered-not-analysed path => the same packed entry, for a run over path arguments that
|
|
107
|
+
# #684 widened to the configured `paths:`. Empty on every run over the configured paths.
|
|
108
|
+
@discovery_digests = {}
|
|
102
109
|
@analyzed = [] # the project files analyzed last round
|
|
103
110
|
@dependents = {} # inverted @sources (file-level)
|
|
104
111
|
# ADR-46 slice 4 — symbol-granularity tracking.
|
|
@@ -128,6 +135,10 @@ module Rigor
|
|
|
128
135
|
# and a missing invalidation edge is impossible by construction — the reason this is a table diff
|
|
129
136
|
# and not the caller→callee edge recording #204 first sketched.
|
|
130
137
|
@param_table = {}
|
|
138
|
+
# Issue #1553 — analysed path => the plugin reads ({Plugin::IoBoundary.attributing}) its last analysis made:
|
|
139
|
+
# the frozen `Cache::Descriptor::FileEntry` / `GlobEntry` rows, as each read observed its input. A recheck
|
|
140
|
+
# re-analyses every file one of whose rows no longer validates ({#plugin_read_affected}).
|
|
141
|
+
@plugin_reads = {}
|
|
131
142
|
reset_carried_state
|
|
132
143
|
# ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
|
|
133
144
|
# `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
|
|
@@ -151,6 +162,49 @@ module Rigor
|
|
|
151
162
|
current_files
|
|
152
163
|
end
|
|
153
164
|
|
|
165
|
+
# Issue #1583 (and #1560) — whether a file the run discovers but does not analyse was edited, added or
|
|
166
|
+
# removed since the snapshot. Such a file feeds the cross-file index the analysed files resolve against
|
|
167
|
+
# (a constant, a method, an ancestor), but no recorded edge or digest of the analysed set sees it move.
|
|
168
|
+
# A move re-baselines the session: discovery-only files are not tracked per symbol, so the closure
|
|
169
|
+
# cannot be narrowed, and the full analysis is over the argument set alone. A session over the
|
|
170
|
+
# configured `paths:` discovers nothing beyond what it analyses and pays nothing here.
|
|
171
|
+
def discovery_moved?
|
|
172
|
+
current = current_discovery_only_files
|
|
173
|
+
return true unless current.size == @discovery_digests.size
|
|
174
|
+
|
|
175
|
+
current.any? do |path|
|
|
176
|
+
entry = @discovery_digests[path]
|
|
177
|
+
entry.nil? || !Cache::FileDigest.stat_fresh?(path, entry)
|
|
178
|
+
end
|
|
179
|
+
rescue StandardError
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A baseline's freshness entries: each analysed file's, and (issue #1583) each discovered-not-analysed file's.
|
|
184
|
+
def record_baseline_digests
|
|
185
|
+
@digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
|
|
186
|
+
@discovery_digests = current_discovery_only_files.to_h { |path| [path, pack_digest(path)] }
|
|
187
|
+
forget_unbound_digests
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def restore_digests(payload)
|
|
191
|
+
@digests = payload.digests
|
|
192
|
+
@discovery_digests = payload.discovery_digests # Issue #1583 — `{}` in a payload built without it.
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Issue #1583 — the discovered-not-analysed files of a run over this session's paths
|
|
196
|
+
# ({Runner#discovery_only_file_set}). Asked of a runner WITHOUT the editor buffer: a buffered run does not
|
|
197
|
+
# widen, so a buffered session (editor mode option B, #146) would read an empty set against the snapshot's
|
|
198
|
+
# and re-baseline on every keystroke. Globbing without it keeps the comparison the one the snapshot's
|
|
199
|
+
# writer made, so a sibling edit still declines option B and an unchanged project still serves it.
|
|
200
|
+
def current_discovery_only_files
|
|
201
|
+
return [] if @paths.nil?
|
|
202
|
+
|
|
203
|
+
Runner.new(configuration: @configuration, cache_store: nil, environment: @environment)
|
|
204
|
+
.discovery_only_file_set(@paths)
|
|
205
|
+
end
|
|
206
|
+
private :discovery_moved?, :record_baseline_digests, :restore_digests, :current_discovery_only_files
|
|
207
|
+
|
|
154
208
|
# The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
|
|
155
209
|
attr_reader :seed_bundles
|
|
156
210
|
|
|
@@ -216,8 +270,8 @@ module Rigor
|
|
|
216
270
|
@effects_identity = current_effects_identity
|
|
217
271
|
@effect_plugin_digest = effect_plugin_digest_of(runner)
|
|
218
272
|
@cache = per_file(runner.per_file_diagnostics)
|
|
219
|
-
@
|
|
220
|
-
|
|
273
|
+
@plugin_reads = plugin_reads_of(runner, @analyzed)
|
|
274
|
+
record_baseline_digests
|
|
221
275
|
@run_level_rows = runner.run_level_rows
|
|
222
276
|
diagnostics
|
|
223
277
|
end
|
|
@@ -231,6 +285,8 @@ module Rigor
|
|
|
231
285
|
added = current - previous
|
|
232
286
|
removed = previous - current
|
|
233
287
|
changed = changed_paths(current & previous)
|
|
288
|
+
return rebaseline_recheck(changed, added, removed) if discovery_moved?
|
|
289
|
+
|
|
234
290
|
# ADR-67 WD6c lift — recompute the whole-project inferred-param table BEFORE deciding the closure,
|
|
235
291
|
# and diff it against the snapshot's copy: an entry that moved (because a caller's argument type
|
|
236
292
|
# changed, a caller appeared, or one vanished) invalidates the CALLEE's file and its symbol
|
|
@@ -251,7 +307,8 @@ module Rigor
|
|
|
251
307
|
|
|
252
308
|
absorb(runner, current, analyze_set, removed)
|
|
253
309
|
@param_table = fresh_params
|
|
254
|
-
recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner
|
|
310
|
+
recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner,
|
|
311
|
+
stale_readers: @stale_plugin_readers)
|
|
255
312
|
end
|
|
256
313
|
|
|
257
314
|
# Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
|
|
@@ -304,9 +361,10 @@ module Rigor
|
|
|
304
361
|
# records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
|
|
305
362
|
# whole project — what a `sig/` edit already costs through the snapshot fingerprint.
|
|
306
363
|
def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
|
|
364
|
+
@stale_plugin_readers = plugin_read_affected
|
|
307
365
|
return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
|
|
308
366
|
|
|
309
|
-
gated_closure(changed, added, removed, param_files, param_pairs)
|
|
367
|
+
(gated_closure(changed, added, removed, param_files, param_pairs) | @stale_plugin_readers).freeze
|
|
310
368
|
end
|
|
311
369
|
|
|
312
370
|
# {#affected_closure} for an edit whose synthesized RBS is byte-identical in every file it touched.
|
|
@@ -350,17 +408,47 @@ module Rigor
|
|
|
350
408
|
closure.freeze
|
|
351
409
|
end
|
|
352
410
|
|
|
411
|
+
# Issue #1553 — every analysed file whose last analysis made a plugin read that no longer validates: a file
|
|
412
|
+
# read through {Plugin::IoBoundary#read_file} or probed through `#file?` / `#directory?` was edited,
|
|
413
|
+
# removed, or created after a read that found it absent, or a `#list_directory` / `#glob` listing moved.
|
|
414
|
+
# The ADR-46 edges record type and declaration reads only, so none of these moves a reader into the
|
|
415
|
+
# closure, and its cached rows were computed from the input as it was. The reader's own text is unchanged,
|
|
416
|
+
# so it joins the closure alone: a plugin read made while analysing a dependent is credited to that
|
|
417
|
+
# dependent. Each distinct row is validated once however many files made it (one `File.stat` for a
|
|
418
|
+
# content row whose stat tuple did not move, one re-glob for a listing row).
|
|
419
|
+
def plugin_read_affected
|
|
420
|
+
verdicts = {}
|
|
421
|
+
@plugin_reads.each_with_object(Set.new) do |(path, rows), stale|
|
|
422
|
+
next unless rows.any? { |row| !verdicts.fetch(row) { verdicts[row] = plugin_read_fresh?(row) } }
|
|
423
|
+
|
|
424
|
+
stale << path
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def plugin_read_fresh?(row)
|
|
429
|
+
if row.is_a?(Cache::Descriptor::GlobEntry)
|
|
430
|
+
Cache::Descriptor::GlobEntry.fresh?(row)
|
|
431
|
+
else
|
|
432
|
+
Cache::Descriptor.new(files: [row]).fresh?
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Issue #1553 — `runner`'s per-file plugin reads for `paths`, each file's rows de-duplicated and frozen.
|
|
437
|
+
def plugin_reads_of(runner, paths)
|
|
438
|
+
runner.analysis_reads.slice(*paths).transform_values { |rows| rows.uniq.freeze }
|
|
439
|
+
end
|
|
440
|
+
|
|
353
441
|
# Issue #1906 — a file that rewrites a constant's singleton names without declaring it
|
|
354
|
-
# (`D.singleton_class.alias_method :own, :keep`) contributes opaque singleton
|
|
355
|
-
# but a reader's class edge names only the files that declared the constant
|
|
356
|
-
# class-declaration diff does not move while the constant stays in the file
|
|
357
|
-
# call becoming a `class D` reopening). So a declaration-unstable edit to such a
|
|
358
|
-
# segment>` for each constant that, before the edit, both sat in the file's
|
|
359
|
-
# singleton envelope entry or an unpositioned singleton side the file recorded (a
|
|
360
|
-
# side unpositioned, and becoming literal reads it again). A file that only
|
|
361
|
-
# (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the
|
|
362
|
-
# rewrite needs no key: the class-declaration diff fires if it is new to the file, and the
|
|
363
|
-
# names the file if the file declared it.
|
|
442
|
+
# (`D.singleton_class.alias_method :own, :keep`, or, since #1941, `def D.own`) contributes opaque singleton
|
|
443
|
+
# envelopes to the constant's bucket, but a reader's class edge names only the files that declared the constant
|
|
444
|
+
# when the reader last ran, and the class-declaration diff does not move while the constant stays in the file
|
|
445
|
+
# (`:own` becoming `:keep`, or the call becoming a `class D` reopening). So a declaration-unstable edit to such a
|
|
446
|
+
# file fires `class:<last segment>` for each constant that, before the edit, both sat in the file's
|
|
447
|
+
# singleton-source set and held a singleton envelope entry or an unpositioned singleton side the file recorded (a
|
|
448
|
+
# computed name there leaves the side unpositioned, and becoming literal reads it again). A file that only
|
|
449
|
+
# includes into a constant (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the
|
|
450
|
+
# edit makes the file rewrite needs no key: the class-declaration diff fires if it is new to the file, and the
|
|
451
|
+
# reader's class edge names the file if the file declared it.
|
|
364
452
|
def singleton_rewrite_affected(unstable)
|
|
365
453
|
names = unstable.flat_map { |path| singleton_rewritten_constants(@seed_bundles[path]) }.uniq
|
|
366
454
|
return Set.new if names.empty?
|
|
@@ -669,7 +757,7 @@ module Rigor
|
|
|
669
757
|
# the gate could not see. {#run_incremental} re-analyses the project and {#run_buffer_recheck} declines,
|
|
670
758
|
# the way each treats a moved plugin fact surface. A run that changed nothing is exempt: under an
|
|
671
759
|
# untrusted gate every earlier edit already re-analysed the whole project, so the snapshot it serves is
|
|
672
|
-
# whole, and a null run stays one.
|
|
760
|
+
# whole, and a null run stays one. A recheck that found a stale plugin read (#1553) is a change.
|
|
673
761
|
def source_rbs_unverified?(result)
|
|
674
762
|
source_rbs_gate.untrusted? && !result.no_change? && !result.reused.empty?
|
|
675
763
|
end
|
|
@@ -889,7 +977,7 @@ module Rigor
|
|
|
889
977
|
@analyzed = payload.analyzed
|
|
890
978
|
@cache = payload.cache
|
|
891
979
|
@sources = payload.sources
|
|
892
|
-
|
|
980
|
+
restore_digests(payload)
|
|
893
981
|
# ADR-85 WD2 — restore the per-file discovery bundles if present (absent in a pre-#85 snapshot → empty,
|
|
894
982
|
# so the recheck's discovery re-walks every file: a cold-quality index, always sound). The SCHEMA bump
|
|
895
983
|
# makes a genuinely stale-shaped bundle unreadable rather than mis-folded.
|
|
@@ -903,6 +991,7 @@ module Rigor
|
|
|
903
991
|
restore_structural_declarations(payload)
|
|
904
992
|
@return_summaries = payload.return_summaries || {}
|
|
905
993
|
@param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
|
|
994
|
+
@plugin_reads = payload.plugin_reads # Issue #1553 — `{}` in a payload built without it.
|
|
906
995
|
# Issues #796 / #794 — absent (nil) in a snapshot written before schema 22 is impossible (the SCHEMA
|
|
907
996
|
# gate rejects one), so nil here means the writing run genuinely had nothing to say.
|
|
908
997
|
@run_level_rows = payload.run_level_rows
|
|
@@ -942,7 +1031,9 @@ module Rigor
|
|
|
942
1031
|
effect_collections: marshal_safe_effect_collections,
|
|
943
1032
|
effects_identity: @effects_identity,
|
|
944
1033
|
run_level_rows: @run_level_rows,
|
|
945
|
-
effect_plugin_digest: @effect_plugin_digest
|
|
1034
|
+
effect_plugin_digest: @effect_plugin_digest,
|
|
1035
|
+
plugin_reads: @plugin_reads,
|
|
1036
|
+
discovery_digests: @discovery_digests
|
|
946
1037
|
)
|
|
947
1038
|
end
|
|
948
1039
|
|
|
@@ -1054,9 +1145,12 @@ module Rigor
|
|
|
1054
1145
|
whole_project = current.all? { |path| analyze_set.include?(path) }
|
|
1055
1146
|
@seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: whole_project)
|
|
1056
1147
|
fresh_by_file = per_file(runner.per_file_diagnostics)
|
|
1148
|
+
# Issue #1553 — a re-analysed file's reads are this run's: a read it no longer makes stops guarding it.
|
|
1149
|
+
fresh_reads = plugin_reads_of(runner, analyze_set)
|
|
1057
1150
|
analyze_set.each do |path|
|
|
1058
1151
|
@cache[path] = fresh_by_file[path] || []
|
|
1059
1152
|
@digests[path] = pack_digest(path)
|
|
1153
|
+
fresh_reads.key?(path) ? @plugin_reads[path] = fresh_reads[path] : @plugin_reads.delete(path)
|
|
1060
1154
|
end
|
|
1061
1155
|
forget_unbound_digests
|
|
1062
1156
|
absorb_dependency_graph(runner)
|
|
@@ -1105,6 +1199,7 @@ module Rigor
|
|
|
1105
1199
|
@cache.delete(path)
|
|
1106
1200
|
@digests.delete(path)
|
|
1107
1201
|
@sources.delete(path)
|
|
1202
|
+
@plugin_reads.delete(path)
|
|
1108
1203
|
@symbol_sources.delete(path)
|
|
1109
1204
|
@ancestry_sources.delete(path)
|
|
1110
1205
|
@missing.delete(path)
|
|
@@ -1194,7 +1289,53 @@ module Rigor
|
|
|
1194
1289
|
keys.concat(class_move_keys(moved_classes))
|
|
1195
1290
|
keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
|
|
1196
1291
|
Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
|
|
1197
|
-
defined_name_affected(changed + removed, summary) |
|
|
1292
|
+
defined_name_affected(changed + removed, summary) | mark_affected(changed + removed, summary) |
|
|
1293
|
+
global_write_reporters
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
# Issue #1833 — the consumers whose answer read a class mark that moved in this edit. A reader of a class's
|
|
1297
|
+
# envelope bucket records `mark:<last segment>` (`Scope#parameter_envelopes_of`), whichever way it answered, so
|
|
1298
|
+
# a file that adds or drops only a mark (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a
|
|
1299
|
+
# method body, a `refine` block's refined mark) re-checks it although the file declares nothing it read. A
|
|
1300
|
+
# reader that scans every bucket for an object-extended module (`SourceArity#object_extension_may_shadow?`)
|
|
1301
|
+
# records `mark:*`, which fires when an object-extended mark moves. The before-state is each touched file's
|
|
1302
|
+
# seed bundle, the after-state the scan's merged table.
|
|
1303
|
+
#
|
|
1304
|
+
# Issue #1661 — and `mixin:<qualified name>` for a class or module whose unpositioned mixin edges moved
|
|
1305
|
+
# ({Incremental.changed_unpositioned_owners}): an external chain entry records it for each name it can denote
|
|
1306
|
+
# (`ResolutionChain#record_hooks`), so a new file that taints `Comparable` with `include(*MODS)` re-checks the
|
|
1307
|
+
# readers of a chain through it. It is not `mark:`, which a plain reopening of a module moves (the bucket's
|
|
1308
|
+
# module mark) and which would re-check every reader of `Kernel` on any `module Kernel` reopening.
|
|
1309
|
+
def mark_affected(paths, summary)
|
|
1310
|
+
def_index = summary && summary[:def_index]
|
|
1311
|
+
before = paths.map { |path| @seed_bundles.dig(path, :parameter_envelopes) }
|
|
1312
|
+
moved = Incremental.changed_class_marks(before, [def_index && def_index[:parameter_envelopes]])
|
|
1313
|
+
tainted = Incremental.changed_unpositioned_owners(
|
|
1314
|
+
paths.map { |path| @seed_bundles.dig(path, :unpositioned_mixins) },
|
|
1315
|
+
[def_index && def_index[:unpositioned_mixins]]
|
|
1316
|
+
)
|
|
1317
|
+
return Set.new if moved.empty? && tainted.empty?
|
|
1318
|
+
|
|
1319
|
+
keys = moved.map { |(class_name, _mark)| "mark:#{class_name.split('::').last}" }.uniq
|
|
1320
|
+
keys.concat(mixin_keys(tainted))
|
|
1321
|
+
extended = Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK
|
|
1322
|
+
keys << "mark:*" if moved.any? { |(_class_name, mark)| mark == extended }
|
|
1323
|
+
Incremental.negative_closure(keys, @negative_dependents)
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# Issue #1661 — the `mixin:` keys an owner of a moved unpositioned edge fires. The table keys a receiver-form
|
|
1327
|
+
# owner by the spelling written (`class File; Constants.include(*MODS); end` is `"Constants"`), not the constant
|
|
1328
|
+
# Ruby resolves it to, while the chain files the qualified candidate (`mixin:File::Constants`). The table does
|
|
1329
|
+
# not carry the nesting, and every lexical-prefix candidate of the owner ends in `::<owner>`, so the owner fires
|
|
1330
|
+
# its own key and every recorded `mixin:` key ending in `::<owner>`. A relative spelling over-fires to the
|
|
1331
|
+
# readers of every module sharing its trailing segments; a qualified one fires only its suffix matches.
|
|
1332
|
+
def mixin_keys(owners)
|
|
1333
|
+
keys = owners.map { |name| "mixin:#{name}" }
|
|
1334
|
+
suffixes = owners.map { |name| "::#{name}" }
|
|
1335
|
+
@negative_dependents.each_key do |key|
|
|
1336
|
+
keys << key if key.start_with?("mixin:") && suffixes.any? { |suffix| key.end_with?(suffix) }
|
|
1337
|
+
end
|
|
1338
|
+
keys.uniq
|
|
1198
1339
|
end
|
|
1199
1340
|
|
|
1200
1341
|
# The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
|
|
@@ -1429,9 +1570,10 @@ module Rigor
|
|
|
1429
1570
|
end
|
|
1430
1571
|
|
|
1431
1572
|
# A {Recheck} over `deltas` (`[changed, added, removed]`), counting `runner`'s template units.
|
|
1432
|
-
def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false)
|
|
1573
|
+
def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false, stale_readers: Set.new)
|
|
1433
1574
|
changed, added, removed = deltas.map(&:to_set)
|
|
1434
1575
|
Recheck.new(diagnostics: diagnostics, changed: changed, added: added, removed: removed, affected: affected,
|
|
1576
|
+
stale_readers: stale_readers,
|
|
1435
1577
|
reused: reused, templates: runner.template_unit_paths.size, rebaselined: rebaselined)
|
|
1436
1578
|
end
|
|
1437
1579
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../rooted_glob"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
module Analysis
|
|
5
7
|
# ADR-87 WD4 — project file-set expansion shared by the {Analysis::Runner} (miss path) and the
|
|
@@ -27,7 +29,7 @@ module Rigor
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def directory_files(dir, exclude_patterns, base: Dir.pwd)
|
|
30
|
-
reject_excluded(
|
|
32
|
+
reject_excluded(RootedGlob.glob(dir, RUBY_GLOB), exclude_patterns, base: base)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
|
|
@@ -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.
|
|
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,
|
|
@@ -54,29 +57,101 @@ module Rigor
|
|
|
54
57
|
own = declared_member(receiver, name, local)
|
|
55
58
|
return [own, *runtime].uniq if own
|
|
56
59
|
return runtime unless use.inherit
|
|
60
|
+
return (anywhere_targets(name) + runtime).uniq if unknown_ancestry?(receiver)
|
|
57
61
|
|
|
58
62
|
(plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) } + runtime)
|
|
59
63
|
.uniq + [nil]
|
|
60
64
|
end
|
|
61
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
|
+
|
|
62
83
|
# The declarations a descendant of `receiver` holding `name` adds (#1878): a `self` standing for the
|
|
63
84
|
# enclosing declaration is whichever subclass, includer, prepender or extender the method runs on, and
|
|
64
85
|
# Ruby looks the name up there first. Each descendant is searched with its own ancestors unless the call
|
|
65
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.
|
|
66
92
|
def runtime_targets(receiver, name, inherit, local)
|
|
67
|
-
|
|
93
|
+
return anywhere_targets(name) if open_descendants?(receiver)
|
|
94
|
+
|
|
95
|
+
(descendants_of(receiver) | unknown_ancestry_classes).flat_map do |descendant|
|
|
68
96
|
scopes = inherit ? [descendant, *plausible_ancestors(descendant)] : [descendant]
|
|
69
97
|
scopes.filter_map { |scope| declared_member(scope, name, local) }
|
|
70
98
|
end.uniq
|
|
71
99
|
end
|
|
72
100
|
|
|
73
101
|
def descendants_of(fqn)
|
|
74
|
-
@plausible_descendants ||= @by_fqn.keys
|
|
102
|
+
@plausible_descendants ||= (@by_fqn.keys | ancestry.edges.keys)
|
|
103
|
+
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |declared, out|
|
|
75
104
|
plausible_ancestors(declared).each { |ancestor| out[ancestor] << declared }
|
|
76
105
|
end
|
|
77
106
|
@plausible_descendants.fetch(fqn, [])
|
|
78
107
|
end
|
|
79
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("::") }
|
|
153
|
+
end
|
|
154
|
+
|
|
80
155
|
# `scope::name`, or the longest declared prefix of it, when one is declared.
|
|
81
156
|
def declared_member(scope, name, local)
|
|
82
157
|
segments = name.split("::")
|
|
@@ -99,16 +174,15 @@ module Rigor
|
|
|
99
174
|
out = []
|
|
100
175
|
until queue.empty?
|
|
101
176
|
current = queue.shift
|
|
102
|
-
@by_fqn.fetch(current, []).
|
|
103
|
-
(decl.includes + [decl.superclass]).compact.
|
|
104
|
-
|
|
105
|
-
|
|
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)
|
|
106
182
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
end
|
|
111
|
-
end
|
|
183
|
+
seen << ancestor
|
|
184
|
+
out << ancestor
|
|
185
|
+
queue << ancestor
|
|
112
186
|
end
|
|
113
187
|
end
|
|
114
188
|
out.freeze
|
|
@@ -174,9 +248,12 @@ module Rigor
|
|
|
174
248
|
# A literal-argument site contributes a real reference; a dynamic one taints a namespace (WD4).
|
|
175
249
|
# @param shadows — declarations made in files outside the analysed paths. They are never nodes, but a
|
|
176
250
|
# reference written in the SAME file resolves to them first, as Ruby would (#1732, see {#resolve_ref}).
|
|
177
|
-
|
|
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: [],
|
|
178
254
|
foreign: ->(_fqn) { false })
|
|
179
255
|
@declarations = declarations
|
|
256
|
+
@ancestries = ancestries
|
|
180
257
|
@root_fqns = root_fqns.to_set
|
|
181
258
|
@foreign = foreign
|
|
182
259
|
@by_fqn = declarations.group_by(&:fqn)
|