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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. 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.expand_path` comparison
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.expand_path(path.to_s)
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.expand_path(path.to_s)
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.expand_path(path.to_s)
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.expand_path(root.to_s).freeze
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.expand_path(path).freeze }
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.expand_path` only (no `realpath`); plugins with adversarial intent are out of
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.expand_path(path.to_s)
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 the
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 deliberately NOT carried: the runner itself seeds it only under dependency
88
- # recording (it is read by the ancestry accessors solely to record cross-file edges), and Tier 2 never
89
- # records.
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
- tables.reject { |_, table| table.nil? || table.empty? }
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
- slot.to_s.end_with?("member_layouts") ? slot : :"discovered_#{slot}"
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.expand_path(path) },
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.expand_path(path)
152
- prefix = "#{File.expand_path(Dir.pwd)}#{File::SEPARATOR}"
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: included / prepended modules before the superclass (Ruby places mixins nearer),
18
- # transitively, breadth-first. `class_name` itself is excluded — step 1 already covered it.
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. `Scope#superclass_of` / `#includes_of` carry as-written names
21
- # from the discovery pre-pass, and an as-written name that resolves to no discovered class or
22
- # module is dropped — so a `class Foo < ActiveRecord::Base` contributes nothing and a constant
23
- # owned by an RBS-known ancestor still resolves only if the bare name reaches it at step 3. That
24
- # gap is deliberate for this slice: widening to the RBS ancestor graph is a separate question
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
- # ADR-46: `superclass_of` / `includes_of` record a cross-file class dependency per consumer
34
- # file, and the memo is run-scoped rather than file-scoped — a hit would skip the recording and
35
- # under-record the edge for every later file. Recording runs are rare (incremental only), so
36
- # they simply bypass the memo rather than complicate its key.
37
- return compute_ancestor_constant_scopes(class_name, scope) if Analysis::DependencyRecorder.active?
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 :ancestor_constant_scopes
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
- seen[resolved] = true
62
- out << resolved
63
- queue << resolved
64
- end
65
- raw_super = scope.superclass_of(current)
66
- next if raw_super.nil?
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
- resolved_super = resolve_ancestor_name(current, raw_super, scope)
69
- next if resolved_super.nil? || seen[resolved_super]
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
- seen[resolved_super] = true
72
- out << resolved_super
73
- queue << resolved_super
74
- end
75
- out.freeze
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
- # Resolves an ancestor name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) against the nesting in
80
- # force where the subclass's header is written — `Scope#ancestor_name_candidates`, the single
81
- # owner of that order, which `Scope#enqueue_ancestors` reads for method lookup and the
82
- # override-visibility rule reads for its own walk. Returns nil when no candidate names a
83
- # discovered project class or module.
84
- def resolve_ancestor_name(subclass_qualified, raw, scope)
85
- scope.ancestor_name_candidates(subclass_qualified, raw)
86
- .find { |candidate| known_project_namespace?(candidate, scope) }
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 :resolve_ancestor_name
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 = first_namespace_hit(bounded_ancestor_scopes(prefix, scope), head, scope)
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
- bounded_ancestor_scopes(owner, scope).each do |ancestor|
112
+ bounded_agreed_hit(owner, scope) do |ancestor|
110
113
  inherited = "#{ancestor}::#{segment}"
111
- return inherited if yield(inherited)
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 bounded_ancestor_scopes(class_name, scope)
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 :bounded_ancestor_scopes
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.
@@ -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
- first_constant_hit(ancestor_constant_scopes(prefix, scope), name, scope, shadows)
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
- segment = name.delete_prefix("::").split("::").last
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