rigortype 0.4.0 → 0.4.1
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 +4 -3
- data/docs/handbook/08-understanding-errors.md +3 -2
- data/docs/install.md +87 -15
- data/docs/manual/02-cli-reference.md +2 -2
- data/docs/manual/03-configuration.md +5 -0
- data/docs/manual/08-skills.md +4 -3
- data/docs/manual/12-caching.md +43 -4
- data/docs/manual/14-rails-quickstart.md +19 -11
- data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
- data/lib/rigor/analysis/check_rules.rb +118 -87
- data/lib/rigor/analysis/dependency_recorder.rb +200 -18
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
- data/lib/rigor/analysis/incremental_session.rb +273 -49
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
- data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
- data/lib/rigor/analysis/run_cache_key.rb +27 -8
- data/lib/rigor/analysis/run_stats.rb +2 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
- data/lib/rigor/analysis/runner.rb +337 -46
- data/lib/rigor/analysis/shadow_harness.rb +27 -0
- data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
- data/lib/rigor/analysis/template_unit_paths.rb +6 -4
- data/lib/rigor/analysis/template_units.rb +15 -5
- data/lib/rigor/analysis/worker_session.rb +4 -3
- data/lib/rigor/cache/descriptor.rb +40 -1
- data/lib/rigor/cache/file_digest.rb +28 -0
- data/lib/rigor/cache/incremental_snapshot.rb +70 -8
- data/lib/rigor/cache/store.rb +63 -3
- data/lib/rigor/cache/value_digest.rb +325 -0
- data/lib/rigor/cli/check_command.rb +83 -13
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/effects_report.rb +2 -2
- data/lib/rigor/cli/unused_command.rb +3 -3
- data/lib/rigor/cli.rb +1 -1
- data/lib/rigor/config_audit.rb +3 -2
- data/lib/rigor/config_path.rb +29 -0
- data/lib/rigor/configuration/severity_profile.rb +5 -0
- data/lib/rigor/configuration.rb +6 -5
- data/lib/rigor/effects/plugin_facts.rb +6 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
- data/lib/rigor/environment/lockfile_resolver.rb +10 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
- data/lib/rigor/environment/rbs_loader.rb +1 -1
- data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
- data/lib/rigor/environment.rb +13 -70
- data/lib/rigor/inference/block_parameter_binder.rb +29 -13
- data/lib/rigor/inference/captured_locals.rb +7 -1
- data/lib/rigor/inference/declaration_walk/context.rb +195 -0
- data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
- data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
- data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
- data/lib/rigor/inference/declaration_walk.rb +7 -0
- data/lib/rigor/inference/def_node_resolver.rb +3 -2
- data/lib/rigor/inference/definer_resolution.rb +266 -0
- data/lib/rigor/inference/expression_typer.rb +136 -45
- data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
- data/lib/rigor/inference/guard_rebinding.rb +53 -12
- data/lib/rigor/inference/macro_block_self_type.rb +14 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
- data/lib/rigor/inference/module_function_state.rb +213 -0
- data/lib/rigor/inference/multi_target_binder.rb +21 -16
- data/lib/rigor/inference/narrowing.rb +147 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
- data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
- data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
- data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
- data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
- data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
- data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
- data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
- data/lib/rigor/inference/scope_indexer.rb +1443 -295
- data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
- data/lib/rigor/inference/statement_evaluator.rb +80 -14
- data/lib/rigor/plugin/base.rb +28 -8
- data/lib/rigor/plugin/io_boundary.rb +82 -4
- data/lib/rigor/plugin/isolation.rb +1 -1
- data/lib/rigor/plugin/trust_policy.rb +3 -3
- data/lib/rigor/protection/discovery_seed.rb +18 -9
- data/lib/rigor/protection/mutation_cache.rb +3 -3
- data/lib/rigor/reflection/constant_ancestors.rb +96 -47
- data/lib/rigor/reflection/constant_path.rb +22 -7
- data/lib/rigor/reflection.rb +2 -3
- data/lib/rigor/scope/discovery_index.rb +218 -2
- data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
- data/lib/rigor/scope/resolution_chain.rb +884 -0
- data/lib/rigor/scope.rb +295 -322
- data/lib/rigor/sig_gen/alias_index.rb +1 -1
- data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
- data/lib/rigor/sig_gen/generator.rb +22 -16
- data/lib/rigor/type/combinator.rb +25 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor/plugin/io_boundary.rbs +1 -0
- data/sig/rigor/scope.rbs +43 -1
- data/skills/rigor-project-init/SKILL.md +37 -11
- data/skills/rigor-project-init/references/02-configure.md +7 -2
- metadata +25 -1
|
@@ -43,10 +43,39 @@ module Rigor
|
|
|
43
43
|
# - `#cache_descriptor` — flushes the accumulated entries into a fresh {Cache::Descriptor} for the
|
|
44
44
|
# contribution that built it. URL fetches contribute `ConfigEntry` rows keyed `"url:#{url}"` with the
|
|
45
45
|
# response body's SHA-256 so contributions invalidate when the remote document changes.
|
|
46
|
+
# - `#replay(descriptor)` — records a {Cache::Descriptor}'s rows as though this boundary had observed
|
|
47
|
+
# them (#1558). A producer served from its own cache ran no block and read nothing, so
|
|
48
|
+
# {Plugin::Base#cache_for} replays the served entry's stored dependency rows here; the run-result
|
|
49
|
+
# descriptor and any enclosing producer's descriptor then carry the served value's inputs.
|
|
46
50
|
class IoBoundary
|
|
47
51
|
URL_TIMEOUT_SECONDS = 10
|
|
48
52
|
URL_MAX_BYTES = 10 * 1024 * 1024
|
|
49
53
|
|
|
54
|
+
ATTRIBUTION_KEY = :rigor_io_boundary_attribution
|
|
55
|
+
private_constant :ATTRIBUTION_KEY
|
|
56
|
+
|
|
57
|
+
# ADR-45 WD2 (#1507) — runs the block with `sink` (an Array) receiving every file and listing row ANY
|
|
58
|
+
# boundary records on this thread meanwhile, in addition to the boundary's own table, and returns the
|
|
59
|
+
# block's value. `Analysis::Runner` wraps each file's analysis in one when it records dependencies, so the
|
|
60
|
+
# `--incremental` session can keep the reads one file's analysis made beside that file's cached answer: a
|
|
61
|
+
# later run that serves the answer without re-analysing the file still has to validate what it read.
|
|
62
|
+
#
|
|
63
|
+
# A thread variable rather than a fiber-local, so a read a plugin makes from inside an `Enumerator` still
|
|
64
|
+
# lands in the sink. The boundary's own table is unchanged by it, including its first-existence-row-stands
|
|
65
|
+
# rule: the sink records what each read observed. Only reads are credited: a value a plugin memoised reaches
|
|
66
|
+
# later files without one, so the first file to trigger the read is the only one credited with it.
|
|
67
|
+
def self.attributing(sink)
|
|
68
|
+
previous = Thread.current.thread_variable_get(ATTRIBUTION_KEY)
|
|
69
|
+
Thread.current.thread_variable_set(ATTRIBUTION_KEY, sink)
|
|
70
|
+
yield
|
|
71
|
+
ensure
|
|
72
|
+
Thread.current.thread_variable_set(ATTRIBUTION_KEY, previous)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.attribute(entry)
|
|
76
|
+
Thread.current.thread_variable_get(ATTRIBUTION_KEY)&.push(entry)
|
|
77
|
+
end
|
|
78
|
+
|
|
50
79
|
attr_reader :policy, :plugin_id
|
|
51
80
|
|
|
52
81
|
def initialize(policy:, plugin_id:, http_client: DefaultHttpClient.new)
|
|
@@ -61,7 +90,7 @@ module Rigor
|
|
|
61
90
|
@first_refused_path = nil
|
|
62
91
|
end
|
|
63
92
|
|
|
64
|
-
# Issue #959 — `TrustPolicy#allow_read?` refuses a path purely by `File.
|
|
93
|
+
# Issue #959 — `TrustPolicy#allow_read?` refuses a path purely by `File.absolute_path` comparison
|
|
65
94
|
# (ADR-2's documented bound; no `File.realpath`), so a project rooted under a symlink (macOS'
|
|
66
95
|
# `/tmp` → `/private/tmp` alias is the common case) can have every plugin read refused with no
|
|
67
96
|
# visible cause: the boundary answered "not readable" and the plugin swallowed or surfaced only its
|
|
@@ -96,7 +125,7 @@ module Rigor
|
|
|
96
125
|
# outcome is recorded: a path that exists but cannot be read (`EISDIR`, `EACCES`) is a failure the
|
|
97
126
|
# plugin reports, not a probe of absence, and records nothing — as before.
|
|
98
127
|
def read_file(path)
|
|
99
|
-
absolute = File.
|
|
128
|
+
absolute = File.absolute_path(path.to_s)
|
|
100
129
|
unless @policy.allow_read?(absolute)
|
|
101
130
|
record_refusal(absolute)
|
|
102
131
|
raise AccessDeniedError.new(
|
|
@@ -159,7 +188,7 @@ module Rigor
|
|
|
159
188
|
# @param path — project directory; relative paths expand against the working directory
|
|
160
189
|
# @return the absolute paths directly under `path` in `Dir.glob` order; empty when not a directory
|
|
161
190
|
def list_directory(path)
|
|
162
|
-
absolute = File.
|
|
191
|
+
absolute = File.absolute_path(path.to_s)
|
|
163
192
|
entries = Dir.glob(File.join(absolute, "*"))
|
|
164
193
|
unless @policy.allow_read?(absolute)
|
|
165
194
|
record_refusal(absolute)
|
|
@@ -202,8 +231,53 @@ module Rigor
|
|
|
202
231
|
Cache::Descriptor.new(files: files, configs: configs, globs: globs)
|
|
203
232
|
end
|
|
204
233
|
|
|
234
|
+
# #1558 — records every row of `descriptor` as though this boundary had observed it. The caller is
|
|
235
|
+
# {Plugin::Base#cache_for}: a producer served from its own cache ran no block, so without the replay
|
|
236
|
+
# the value's inputs reached neither the ADR-45 run-result descriptor nor the dependency descriptor of
|
|
237
|
+
# a producer computing around it, and both went on validating fresh after those inputs changed.
|
|
238
|
+
#
|
|
239
|
+
# The rows are the ones a fresh hit just validated, so they describe the current filesystem, and they
|
|
240
|
+
# are merged by the precedence the live recorders use. A content row (`:stat` / `:digest` / `:mtime`)
|
|
241
|
+
# replaces an existence row for its path, as {#read_file} does after a probe. Any other collision
|
|
242
|
+
# keeps the row already here: an existence row never displaces one, as in {#probe}, and between two
|
|
243
|
+
# content rows or two glob rows for one slot the live row carries the newer stat tuple, which keeps
|
|
244
|
+
# the next warm run on its stat fast path. The tables are keyed by path, glob slot and URL key, so
|
|
245
|
+
# replaying the same descriptor twice adds nothing. Row order reaches no cache key, because
|
|
246
|
+
# {Cache::Descriptor#to_canonical_hash} sorts every slot.
|
|
247
|
+
#
|
|
248
|
+
# The policy is not consulted: the rows were recorded under it when the entry was written, and replay
|
|
249
|
+
# reads no file. A {Cache::Descriptor::GlobEntry} keeps its `mode`, which is part of its slot key.
|
|
250
|
+
#
|
|
251
|
+
# ADR-45 WD2 (#1507) — every replayed row is also handed to the {.attributing} sink, the ones a held row
|
|
252
|
+
# wins over included, exactly as the live recorders hand over what each read observed: a producer first
|
|
253
|
+
# asked while a file is analysed (rigor-actionpack's `:controller_index`, rigor-rails-i18n's
|
|
254
|
+
# `:locale_index`) serves that file its inputs, and the `--incremental` slot must credit them to it. On a
|
|
255
|
+
# miss the replayed descriptor is the whole boundary's ({Plugin::Base#producer_dependency_descriptor}), so
|
|
256
|
+
# the file is also credited with rows other reads recorded; that over-credits, which only ever makes the
|
|
257
|
+
# slot decline sooner.
|
|
258
|
+
#
|
|
259
|
+
# @param descriptor — a dependency descriptor; only its `files`, `configs` and `globs` slots are read,
|
|
260
|
+
# the only slots a boundary records
|
|
261
|
+
def replay(descriptor)
|
|
262
|
+
@mutex.synchronize do
|
|
263
|
+
descriptor.files.each { |entry| replay_file_entry(entry) }
|
|
264
|
+
descriptor.configs.each { |entry| @config_entries[entry.key] ||= entry }
|
|
265
|
+
descriptor.globs.each { |entry| @glob_entries[entry.slot_key] ||= entry }
|
|
266
|
+
end
|
|
267
|
+
(descriptor.files + descriptor.globs).each { |entry| self.class.attribute(entry) }
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
205
271
|
private
|
|
206
272
|
|
|
273
|
+
# The {#replay} precedence for one file row; the caller holds `@mutex`.
|
|
274
|
+
def replay_file_entry(entry)
|
|
275
|
+
current = @file_entries[entry.path]
|
|
276
|
+
return if current && !(current.comparator == :exists && entry.comparator != :exists)
|
|
277
|
+
|
|
278
|
+
@file_entries[entry.path] = entry
|
|
279
|
+
end
|
|
280
|
+
|
|
207
281
|
# ADR-45 WD1b (#613) — the shared body of {#file?} / {#directory?}.
|
|
208
282
|
#
|
|
209
283
|
# The probe answers TRUTHFULLY for every path, in or out of the trusted-read scope, and never raises:
|
|
@@ -222,7 +296,7 @@ module Rigor
|
|
|
222
296
|
# reverse) → NOTHING, the same bound `#read_file` pins for `EISDIR`: the probe observed neither a
|
|
223
297
|
# usable file nor an absence, and either row would misdescribe what the plugin saw.
|
|
224
298
|
def probe(path)
|
|
225
|
-
absolute = File.
|
|
299
|
+
absolute = File.absolute_path(path.to_s)
|
|
226
300
|
answer = yield(absolute)
|
|
227
301
|
unless @policy.allow_read?(absolute)
|
|
228
302
|
record_refusal(absolute)
|
|
@@ -247,6 +321,7 @@ module Rigor
|
|
|
247
321
|
# A content row replaces an earlier existence row for the same path outright: the file appeared and
|
|
248
322
|
# its bytes were consumed, and a content row validates existence as well as content.
|
|
249
323
|
@mutex.synchronize { @file_entries[path] = entry }
|
|
324
|
+
self.class.attribute(entry)
|
|
250
325
|
end
|
|
251
326
|
|
|
252
327
|
# ADR-45 WD1 (#577) — the absence row for a probed-but-missing path. A content row already recorded
|
|
@@ -263,6 +338,7 @@ module Rigor
|
|
|
263
338
|
def record_absence_entry(path)
|
|
264
339
|
entry = Cache::Descriptor::FileEntry.absent(path: path)
|
|
265
340
|
@mutex.synchronize { @file_entries[path] ||= entry }
|
|
341
|
+
self.class.attribute(entry)
|
|
266
342
|
end
|
|
267
343
|
|
|
268
344
|
# ADR-45 WD1b (#613) — the presence row: fresh while the probed path exists, stale once it is gone.
|
|
@@ -273,6 +349,7 @@ module Rigor
|
|
|
273
349
|
def record_presence_entry(path)
|
|
274
350
|
entry = Cache::Descriptor::FileEntry.present(path: path)
|
|
275
351
|
@mutex.synchronize { @file_entries[path] ||= entry }
|
|
352
|
+
self.class.attribute(entry)
|
|
276
353
|
end
|
|
277
354
|
|
|
278
355
|
# ADR-45 WD1c (#629) — the listing row for {#list_directory}, deduplicated per (root, pattern) slot.
|
|
@@ -281,6 +358,7 @@ module Rigor
|
|
|
281
358
|
def record_glob_entry(root, pattern)
|
|
282
359
|
entry = Cache::Descriptor::GlobEntry.compute(root: root, pattern: pattern)
|
|
283
360
|
@mutex.synchronize { @glob_entries[entry.slot_key] = entry }
|
|
361
|
+
self.class.attribute(entry)
|
|
284
362
|
end
|
|
285
363
|
|
|
286
364
|
# Issue #959 — records that `path` fell outside every {TrustPolicy} read root, for {#refusal_summary}.
|
|
@@ -47,7 +47,7 @@ module Rigor
|
|
|
47
47
|
# build resolves the same root and skips the write — #414), and reading an unshareable value out of
|
|
48
48
|
# a module ivar from a non-main Ractor is the same `Ractor::IsolationError` as writing one.
|
|
49
49
|
def target_bundle_root=(root)
|
|
50
|
-
@target_bundle_root = root.nil? ? nil : File.
|
|
50
|
+
@target_bundle_root = root.nil? ? nil : File.absolute_path(root.to_s).freeze
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Requires `feature`, falling back to the analyzed project's bundler install tree. The fallback
|
|
@@ -36,7 +36,7 @@ module Rigor
|
|
|
36
36
|
|
|
37
37
|
@trusted_gems = trusted_gems.map { |g| g.to_s.dup.freeze }.uniq.sort.freeze
|
|
38
38
|
@allowed_read_roots = allowed_read_roots
|
|
39
|
-
.map { |path| File.
|
|
39
|
+
.map { |path| File.absolute_path(path).freeze }
|
|
40
40
|
.uniq
|
|
41
41
|
.sort
|
|
42
42
|
.freeze
|
|
@@ -46,10 +46,10 @@ module Rigor
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# @return true when the absolute path falls inside any allowed read root. Symlinks are
|
|
49
|
-
# resolved through `File.
|
|
49
|
+
# resolved through `File.absolute_path` only (no `realpath`); plugins with adversarial intent are out of
|
|
50
50
|
# scope per ADR-2.
|
|
51
51
|
def allow_read?(path)
|
|
52
|
-
absolute = File.
|
|
52
|
+
absolute = File.absolute_path(path.to_s)
|
|
53
53
|
@allowed_read_roots.any? { |root| inside?(absolute, root) }
|
|
54
54
|
end
|
|
55
55
|
|
|
@@ -17,8 +17,8 @@ module Rigor
|
|
|
17
17
|
#
|
|
18
18
|
# The answer (issue #260's recorded decision) is one table set, built once, threaded to both sides: the
|
|
19
19
|
# {Mutator} base scope and {DiagnosticOracle}'s runner seam. This module builds that table set — the FULL
|
|
20
|
-
# discovery bundle (`discovered_def_nodes`, `discovered_methods`, superclasses, includes, …), not just
|
|
21
|
-
# class-identity slice site selection needed, because resolving `find` on `singleton(Account)` is what makes
|
|
20
|
+
# discovery bundle (`discovered_def_nodes`, `discovered_methods`, superclasses, includes, extends, …), not just
|
|
21
|
+
# the class-identity slice site selection needed, because resolving `find` on `singleton(Account)` is what makes
|
|
22
22
|
# the site killable.
|
|
23
23
|
#
|
|
24
24
|
# The shape is a plain Hash of {Scope::DiscoveryIndex} slot names, which is exactly what both consumers take:
|
|
@@ -84,9 +84,10 @@ module Rigor
|
|
|
84
84
|
# collectors driven over the same tree) that {Analysis::Runner::ProjectPrePasses#discover} uses. Empty
|
|
85
85
|
# tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing" branch keeps working.
|
|
86
86
|
#
|
|
87
|
-
# `discovered_class_sources` is
|
|
88
|
-
#
|
|
89
|
-
#
|
|
87
|
+
# `discovered_class_sources` is carried as the runner carries it on every run: Tier 2 never records edges,
|
|
88
|
+
# but an ancestor-order reader asks whether a class is declared in more than one file. The mixin tables
|
|
89
|
+
# (`discovered_includes`, `discovered_prepends`, `discovered_extends`) ride the same fold, so a singleton
|
|
90
|
+
# `extend` reads the same from a per-file scope under coverage and protection as it does under `check`.
|
|
90
91
|
def discovery_tables(paths)
|
|
91
92
|
seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
|
|
92
93
|
end
|
|
@@ -99,19 +100,27 @@ module Rigor
|
|
|
99
100
|
tables = { discovered_classes: index.fetch(:classes) }
|
|
100
101
|
%i[
|
|
101
102
|
def_nodes def_nestings singleton_def_nodes def_sources singleton_def_sources superclasses includes
|
|
102
|
-
prepends
|
|
103
|
+
extends prepends unpositioned_mixins class_sources
|
|
103
104
|
method_visibilities methods parameter_envelopes data_member_layouts struct_member_layouts
|
|
104
105
|
deferred_ranges refinements global_write_census
|
|
105
106
|
].each do |slot|
|
|
106
107
|
tables[seed_key(slot)] = def_index.fetch(slot)
|
|
107
108
|
end
|
|
108
|
-
|
|
109
|
+
# ADR-119 WD1 — the pairs travel as one: an unpaired empty table is dropped here, a pair only when both
|
|
110
|
+
# halves are empty (`compact_pairs`), so a non-empty sibling is never stranded by an empty member.
|
|
111
|
+
tables.merge!(def_index.fetch(:siblings))
|
|
112
|
+
Scope::DiscoveryIndex.compact_pairs(
|
|
113
|
+
tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
|
|
114
|
+
)
|
|
109
115
|
end
|
|
110
116
|
|
|
117
|
+
UNPREFIXED_SLOTS = %i[data_member_layouts struct_member_layouts unpositioned_mixins].freeze
|
|
118
|
+
private_constant :UNPREFIXED_SLOTS
|
|
119
|
+
|
|
111
120
|
# The def-index slot names match the {Scope::DiscoveryIndex} ones under a `discovered_` prefix, except the
|
|
112
|
-
# two member-layout tables, which carry the same name on both sides.
|
|
121
|
+
# two member-layout tables and the unpositioned-mixin table, which carry the same name on both sides.
|
|
113
122
|
def seed_key(slot)
|
|
114
|
-
|
|
123
|
+
UNPREFIXED_SLOTS.include?(slot) ? slot : :"discovered_#{slot}"
|
|
115
124
|
end
|
|
116
125
|
end
|
|
117
126
|
end
|
|
@@ -140,7 +140,7 @@ module Rigor
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def snapshot_root_candidates(roots, configured)
|
|
143
|
-
[roots, Array(roots).map { |path| File.
|
|
143
|
+
[roots, Array(roots).map { |path| File.absolute_path(path) },
|
|
144
144
|
configured, Array(configured).map { |path| relative_to_pwd(path) }]
|
|
145
145
|
.compact.reject(&:empty?).uniq
|
|
146
146
|
end
|
|
@@ -148,8 +148,8 @@ module Rigor
|
|
|
148
148
|
# `path` rendered relative to the working directory when it lies underneath it; unchanged otherwise.
|
|
149
149
|
# Shared by the root-candidate list and the per-file snapshot lookup, which face the same skew.
|
|
150
150
|
def relative_to_pwd(path)
|
|
151
|
-
absolute = File.
|
|
152
|
-
prefix = "#{File.
|
|
151
|
+
absolute = File.absolute_path(path)
|
|
152
|
+
prefix = "#{File.absolute_path(Dir.pwd)}#{File::SEPARATOR}"
|
|
153
153
|
absolute.start_with?(prefix) ? absolute.delete_prefix(prefix) : path.to_s
|
|
154
154
|
end
|
|
155
155
|
|
|
@@ -11,18 +11,27 @@ module Rigor
|
|
|
11
11
|
ANCESTOR_SCOPES_KEY = :__rigor_ancestor_constant_scopes__
|
|
12
12
|
private_constant :ANCESTOR_SCOPES_KEY
|
|
13
13
|
|
|
14
|
+
# The recording run's slot. See {.recorded_ancestor_constant_scopes}.
|
|
15
|
+
RECORDED_ANCESTOR_SCOPES_KEY = :__rigor_recorded_ancestor_constant_scopes__
|
|
16
|
+
private_constant :RECORDED_ANCESTOR_SCOPES_KEY
|
|
17
|
+
|
|
14
18
|
module_function
|
|
15
19
|
|
|
16
20
|
# #354 — the project classes and modules whose own constants `class_name` inherits, in Ruby's
|
|
17
|
-
# ancestor order:
|
|
18
|
-
#
|
|
21
|
+
# ancestor order: the entries of `class_name`'s `Scope::ResolutionChain` — prepended modules, then
|
|
22
|
+
# included ones, then the superclass's chain likewise — each once. `class_name` itself is excluded:
|
|
23
|
+
# step 1 already covered it.
|
|
24
|
+
#
|
|
25
|
+
# Issue #1571 — this was a breadth-first walk of its own, which is not Ruby's order: `class C < Base;
|
|
26
|
+
# include A` with `A` including `M` reached `Base::X` (depth 1) before `M::X` (depth 2), while Ruby's
|
|
27
|
+
# ancestors are `[C, A, M, Base]`. It reads the chain's `:constants` flavor, whose predicate for a
|
|
28
|
+
# project class also admits a module that holds only constants.
|
|
19
29
|
#
|
|
20
|
-
# Only PROJECT ancestors appear
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# with its own FP surface.
|
|
30
|
+
# Only PROJECT ancestors appear: an as-written name that resolves to no discovered class or module
|
|
31
|
+
# is an external entry on the chain and contributes nothing here, so a `class Foo <
|
|
32
|
+
# ActiveRecord::Base` adds no candidate and a constant owned by an RBS-known ancestor still resolves
|
|
33
|
+
# only if the bare name reaches it at step 3. That gap is deliberate for this slice: widening to the
|
|
34
|
+
# RBS ancestor graph is a separate question with its own FP surface.
|
|
26
35
|
#
|
|
27
36
|
# Memoised per run because step 2 runs on every constant reference whose lexical candidates all
|
|
28
37
|
# miss — which is the common case for a core-class reference (`String` inside `class Foo`). The
|
|
@@ -30,11 +39,49 @@ module Rigor
|
|
|
30
39
|
# back to the per-file discovery table for runner-less scopes, so a re-run in one process (LSP,
|
|
31
40
|
# ADR-62 warm loop) cannot hit stale entries.
|
|
32
41
|
def ancestor_constant_scopes(class_name, scope)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
ancestor_constant_worlds(class_name, scope).first
|
|
43
|
+
end
|
|
44
|
+
private_class_method :ancestor_constant_scopes
|
|
45
|
+
|
|
46
|
+
# ADR-24 / #1570 — the first ancestor that owns a constant, asked as {.ancestor_constant_scopes} orders
|
|
47
|
+
# them and settled by `Scope::ResolutionChain#settle`: the block's first truthy answer where the chain
|
|
48
|
+
# stands, and otherwise the answer of the breadth-first order this rung used before the chain
|
|
49
|
+
# (`Scope::ResolutionChain::MasterOrder`) — where a skipped `include` would put a different owner first,
|
|
50
|
+
# which constant Ruby reads depends on the order the bodies ran.
|
|
51
|
+
def agreed_ancestor_hit(class_name, scope, &)
|
|
52
|
+
scopes, chain = ancestor_constant_worlds(class_name, scope)
|
|
53
|
+
owner, hit = first_ancestor_hit(scopes, &)
|
|
54
|
+
verdict = chain.settle(scope, owner, owner: owner) do |retro|
|
|
55
|
+
first_ancestor_hit(constant_scopes_of(retro, class_name), &)&.first
|
|
56
|
+
end
|
|
57
|
+
return hit if verdict == :chain
|
|
58
|
+
|
|
59
|
+
first_ancestor_hit(master_constant_scopes(class_name, scope), &)&.last
|
|
60
|
+
end
|
|
61
|
+
private_class_method :agreed_ancestor_hit
|
|
62
|
+
|
|
63
|
+
def master_constant_scopes(class_name, scope)
|
|
64
|
+
Scope::ResolutionChain::MasterOrder.breadth_first(scope, class_name.to_s, :constants)
|
|
65
|
+
end
|
|
66
|
+
private_class_method :master_constant_scopes
|
|
67
|
+
|
|
68
|
+
def first_ancestor_hit(entries)
|
|
69
|
+
entries.each do |entry|
|
|
70
|
+
hit = yield entry
|
|
71
|
+
return [entry, hit] if hit
|
|
72
|
+
end
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
private_class_method :first_ancestor_hit
|
|
76
|
+
|
|
77
|
+
# `[scopes, chain]` — {.ancestor_constant_scopes} and the chain they were read from, which settles the
|
|
78
|
+
# answer.
|
|
79
|
+
def ancestor_constant_worlds(class_name, scope)
|
|
80
|
+
# ADR-46: the answer depends on every class on the chain, and the memo is run-scoped rather than
|
|
81
|
+
# file-scoped — a hit would skip the recording and under-record the edge for every later file. A
|
|
82
|
+
# recording run reads the chain, which is memoised per discovery index, and files its edges on
|
|
83
|
+
# every call.
|
|
84
|
+
return recorded_ancestor_constant_scopes(class_name, scope) if Analysis::DependencyRecorder.active?
|
|
38
85
|
|
|
39
86
|
generation = scope.run_generation || scope.discovered_superclasses
|
|
40
87
|
slot = Thread.current[ANCESTOR_SCOPES_KEY]
|
|
@@ -45,48 +92,50 @@ module Rigor
|
|
|
45
92
|
bucket = slot[1]
|
|
46
93
|
bucket.fetch(class_name) { bucket[class_name] = compute_ancestor_constant_scopes(class_name, scope) }
|
|
47
94
|
end
|
|
48
|
-
private_class_method :
|
|
49
|
-
|
|
50
|
-
def compute_ancestor_constant_scopes(class_name, scope)
|
|
51
|
-
queue = [class_name]
|
|
52
|
-
seen = { class_name => true }
|
|
53
|
-
out = []
|
|
54
|
-
until queue.empty?
|
|
55
|
-
current = queue.shift
|
|
56
|
-
# Mixins first, then the superclass — Ruby's ancestor order.
|
|
57
|
-
scope.includes_of(current).each do |raw|
|
|
58
|
-
resolved = resolve_ancestor_name(current, raw, scope)
|
|
59
|
-
next if resolved.nil? || seen[resolved]
|
|
95
|
+
private_class_method :ancestor_constant_worlds
|
|
60
96
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
97
|
+
# The recording run's memo. The answer is every project entry of the chain, so its edges are the
|
|
98
|
+
# root's and every entry's — what `Scope::ResolutionChain#record` files, which a computation does and a
|
|
99
|
+
# hit replays, in the same order. One slot keyed on the discovery index: the answer and the edges are
|
|
100
|
+
# both read from it, so the key is exact, and the slot never pins more than the index it serves.
|
|
101
|
+
def recorded_ancestor_constant_scopes(class_name, scope)
|
|
102
|
+
discovery = scope.discovery
|
|
103
|
+
slot = Thread.current[RECORDED_ANCESTOR_SCOPES_KEY]
|
|
104
|
+
unless slot && slot[0].equal?(discovery)
|
|
105
|
+
slot = [discovery, {}]
|
|
106
|
+
Thread.current[RECORDED_ANCESTOR_SCOPES_KEY] = slot
|
|
107
|
+
end
|
|
108
|
+
bucket = slot[1]
|
|
109
|
+
worlds = bucket[class_name]
|
|
110
|
+
return bucket[class_name] = compute_ancestor_constant_scopes(class_name, scope) if worlds.nil?
|
|
67
111
|
|
|
68
|
-
|
|
69
|
-
|
|
112
|
+
Scope::ResolutionChain.for(scope, class_name.to_s, :instance, :constants).record(scope)
|
|
113
|
+
worlds
|
|
114
|
+
end
|
|
115
|
+
private_class_method :recorded_ancestor_constant_scopes
|
|
70
116
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
117
|
+
# Reads the chain whole, so a recording run files every class on it (a no-op otherwise).
|
|
118
|
+
def compute_ancestor_constant_scopes(class_name, scope)
|
|
119
|
+
chain = Scope::ResolutionChain.for(scope, class_name.to_s, :instance, :constants)
|
|
120
|
+
chain.record(scope)
|
|
121
|
+
scopes = constant_scopes_of(chain, class_name)
|
|
122
|
+
[scopes, chain].freeze
|
|
76
123
|
end
|
|
77
124
|
private_class_method :compute_ancestor_constant_scopes
|
|
78
125
|
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
126
|
+
# The chain's project entries in order, each once, without `class_name` itself.
|
|
127
|
+
def constant_scopes_of(chain, class_name)
|
|
128
|
+
out = []
|
|
129
|
+
chain.entries.each do |entry|
|
|
130
|
+
name = entry.name
|
|
131
|
+
out << name unless name.nil? || name == class_name || out.include?(name)
|
|
132
|
+
end
|
|
133
|
+
out.freeze
|
|
87
134
|
end
|
|
88
|
-
private_class_method :
|
|
135
|
+
private_class_method :constant_scopes_of
|
|
89
136
|
|
|
137
|
+
# Whether `name` is a namespace the project declares — the predicate the chain's `:constants` flavor
|
|
138
|
+
# resolves ancestor names with, and the path walk's (`reflection/constant_path.rb`) test for a head.
|
|
90
139
|
def known_project_namespace?(name, scope)
|
|
91
140
|
scope.discovered_superclasses.key?(name) ||
|
|
92
141
|
scope.discovered_includes.key?(name) ||
|
|
@@ -81,7 +81,10 @@ module Rigor
|
|
|
81
81
|
|
|
82
82
|
prefix = caller_derived ? enclosing_class_path(scope) : nil
|
|
83
83
|
unless prefix.nil? || prefix.empty?
|
|
84
|
-
hit =
|
|
84
|
+
hit = bounded_agreed_hit(prefix, scope) do |entry|
|
|
85
|
+
candidate = "#{entry}::#{head}"
|
|
86
|
+
candidate if namespace_known?(candidate, scope)
|
|
87
|
+
end
|
|
85
88
|
return hit if hit
|
|
86
89
|
end
|
|
87
90
|
|
|
@@ -106,28 +109,40 @@ module Rigor
|
|
|
106
109
|
candidate = "#{owner}::#{segment}"
|
|
107
110
|
return candidate if yield(candidate)
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
bounded_agreed_hit(owner, scope) do |ancestor|
|
|
110
113
|
inherited = "#{ancestor}::#{segment}"
|
|
111
|
-
|
|
114
|
+
inherited if yield(inherited)
|
|
112
115
|
end
|
|
113
|
-
nil
|
|
114
116
|
end
|
|
115
117
|
private_class_method :constant_in_namespace
|
|
116
118
|
|
|
119
|
+
# {.agreed_ancestor_hit} over the budget-bounded scope lists: the first answer where
|
|
120
|
+
# `Scope::ResolutionChain#settle` lets the chain stand, and otherwise the breadth-first order's.
|
|
121
|
+
def bounded_agreed_hit(class_name, scope, &)
|
|
122
|
+
scopes, chain = ancestor_constant_worlds(class_name, scope)
|
|
123
|
+
owner, hit = first_ancestor_hit(bounded(scopes), &)
|
|
124
|
+
verdict = chain.settle(scope, owner, owner: owner) do |retro|
|
|
125
|
+
first_ancestor_hit(bounded(constant_scopes_of(retro, class_name)), &)&.first
|
|
126
|
+
end
|
|
127
|
+
return hit if verdict == :chain
|
|
128
|
+
|
|
129
|
+
first_ancestor_hit(bounded(master_constant_scopes(class_name, scope)), &)&.last
|
|
130
|
+
end
|
|
131
|
+
private_class_method :bounded_agreed_hit
|
|
132
|
+
|
|
117
133
|
# How many of {.ancestor_constant_scopes}' entries one segment may consult, under
|
|
118
134
|
# `Scope::ANCESTOR_WALK_LIMIT` — the budget `Scope`'s method lookup already spends on this same
|
|
119
135
|
# graph, reused rather than re-chosen so one pathological ancestry costs one bound. The list
|
|
120
136
|
# itself is the memoised one step 2 builds, so this bounds the per-segment consultation the walk
|
|
121
137
|
# adds and not the graph. Reported through the shared counter, so a truncated walk is visible in a
|
|
122
138
|
# budget trace instead of looking like a plain miss.
|
|
123
|
-
def
|
|
124
|
-
scopes = ancestor_constant_scopes(class_name, scope)
|
|
139
|
+
def bounded(scopes)
|
|
125
140
|
return scopes if scopes.size <= Scope::ANCESTOR_WALK_LIMIT
|
|
126
141
|
|
|
127
142
|
Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
|
|
128
143
|
scopes.first(Scope::ANCESTOR_WALK_LIMIT)
|
|
129
144
|
end
|
|
130
|
-
private_class_method :
|
|
145
|
+
private_class_method :bounded
|
|
131
146
|
|
|
132
147
|
# Whether `name` can OWN a constant. `class_known?` covers the RBS and registry classes and modules;
|
|
133
148
|
# the project tables cover a namespace the project declares with no RBS for it.
|
data/lib/rigor/reflection.rb
CHANGED
|
@@ -220,7 +220,7 @@ module Rigor
|
|
|
220
220
|
def ancestor_constant_type(name, scope, prefix, shadows)
|
|
221
221
|
return nil if prefix.nil? || prefix.empty?
|
|
222
222
|
|
|
223
|
-
|
|
223
|
+
agreed_ancestor_hit(prefix, scope) { |entry| constant_type_at("#{entry}::#{name}", scope, shadows) }
|
|
224
224
|
end
|
|
225
225
|
private_class_method :ancestor_constant_type
|
|
226
226
|
|
|
@@ -322,8 +322,7 @@ module Rigor
|
|
|
322
322
|
# ({Analysis::Incremental.changed_constant_publications}). Gated by the caller on the recorder being
|
|
323
323
|
# active, so an ordinary run pays one integer read.
|
|
324
324
|
def record_constant_reference(name)
|
|
325
|
-
|
|
326
|
-
Analysis::DependencyRecorder.read_name(:constant, segment) if segment
|
|
325
|
+
Analysis::DependencyRecorder.read_last_segment(:constant, name)
|
|
327
326
|
end
|
|
328
327
|
private_class_method :record_constant_reference
|
|
329
328
|
|