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
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-119 WD3 (errata 2026-10-08, PR C2-a) — the singleton side's positional hook decline. A hook's edge on
6
+ # the singleton side is recorded on no includer, so no chain state carries it (WD3); what the read CAN say
7
+ # is where such an edge could land. A hook touches the singleton chain of a class `K` in two ways only:
8
+ #
9
+ # - (U1) `K.extend(X)`, `class_methods do`, a concern's `ClassMethods`: Ruby inserts `X` after `#<Class:K>`
10
+ # and before `#<Class:K.superclass>`, inside `K`'s own level;
11
+ # - (U2) `included do def self.x end`, `define_singleton_method`, `singleton_class.class_eval`: a definition ON
12
+ # `#<Class:K>`, at the level's head.
13
+ #
14
+ # So a hook at level `i` cannot move a definer at an index at or before the level's head, and a definer found
15
+ # at `#<Class:K_j>` itself, written in `K_j`'s own body, is immune to every hook of levels `j` and deeper. The
16
+ # read's last candidate gives a BOUND: its index when it is that class entry and its `def` was written in
17
+ # `K_j`'s body; one past it for an extended module's entry, or for a copy the extends fold put on `K_j`
18
+ # (the `def`'s recorded nesting does not start with `K_j`), and every entry when nothing answers. The levels
19
+ # tested are those that start before the bound and do not end before `from`. The read declines when a tested
20
+ # level's own instance level (the class, its prepends and includes with their closures) or its singleton
21
+ # segment (the class's singleton and its extends' closures) holds a HOOK-CAPABLE entry, or when a level
22
+ # deeper than the shallowest tested level is hook-capable by the same test: its `inherited` defines on
23
+ # every subclass's singleton, and it need not be the class's own `def`. A deeper external superclass RBS
24
+ # does not know counts, its `inherited` being unknown by construction.
25
+ #
26
+ # Hook-capable, for a project entry or a declared module the chain holds as external (a candidate of its
27
+ # spelling is a `discovered_class_sources`, `discovered_classes`, `discovered_includes` or
28
+ # `discovered_extends` key; an ambiguous spelling tests every declared candidate and any capable one
29
+ # declines): lists `"*"` on either side, records a {Scope::ResolutionChain::Relevance::HOOKS} name on
30
+ # either side or as an envelope key, or extends `ActiveSupport::Concern`. An undeclared external is capable
31
+ # unless RBS knows it (WD2(i)'s limit: an RBS-known external is clean). The dynamic mark is not a hook signal.
32
+ # The verdict does not depend on the name asked, so it is memoised per entry in the chain's flavor bucket
33
+ # with the ADR-46 edges it read (a class edge and the negative class edge on the unqualified name per tested
34
+ # project entry, the negative class edge on the spelling's last segment and the candidates' class edges per
35
+ # tested external), replayed on every call while a recording is active.
36
+ #
37
+ # Known remainders, pinned in `spec/integration/definer_resolution_witness_spec.rb`: `class << self; prepend
38
+ # P` is recorded as an `extend` (the chain places `P` after the singleton, so an own `def self.x` is trusted
39
+ # against it); a concern is capable for every name; an own `def self.x` is trusted against its own level's
40
+ # U2 hooks and same-class redefinitions; a `class Class; def inherited` monkeypatch is unseen; and a
41
+ # superclass that only `extend`s adds two forks, so every read on its subclasses declines.
42
+ module SingletonHookDecline
43
+ WILDCARD = "*"
44
+ CONCERN = "ActiveSupport::Concern"
45
+ SIDES = %i[include extend].freeze
46
+
47
+ module_function
48
+
49
+ # True when a hook may have placed a definer of `method_name` ahead of the read's last candidate on the
50
+ # singleton `chain` (the candidate set `hits`, read from `from`).
51
+ def decline?(scope, chain, method_name, from, hits)
52
+ bound = bound_of(scope, chain, method_name, hits.last)
53
+ tested = tested_levels(chain, from, bound)
54
+ return false if tested.empty?
55
+ return true if chain.truncated?
56
+
57
+ context = Context.new(scope, chain.flavor)
58
+ tested.any? { |level| context.level_capable?(chain, level) } || context.deeper_hook?(chain, tested.first)
59
+ end
60
+
61
+ def bound_of(scope, chain, method_name, hit)
62
+ return chain.entries.size if hit.nil?
63
+
64
+ index = hit.index
65
+ entry = chain.entries[index]
66
+ # A singleton project entry on a singleton chain is always a level's class entry.
67
+ own = !entry.external? && entry.side == :singleton && def_head(scope, entry.name, method_name) == entry.name
68
+ own ? index : index + 1
69
+ end
70
+
71
+ # What a class entry's answer is when its `def` was written in another module's body: `:copy` when that
72
+ # module is on the same level's singleton segment (the extends fold copied it there; the copy is not where
73
+ # Ruby finds the method — the module's own entry is, and an entry between them may answer first — so the
74
+ # read asks again past it), `:stray` when it is not (the fold and the chain resolved the `extend`'s name to
75
+ # different modules, `extend X` inside `module A` with both `X` and `A::X` declared; or the `def` is not the
76
+ # node that module's instance table holds, a `def C.x` written in its body: the read declines), and
77
+ # nil otherwise (the class's own `def`, no `def`, or one with no recorded nesting).
78
+ def copy_kind(scope, chain, method_name, hit)
79
+ entry = chain.entries[hit.index]
80
+ return nil if entry.external? || entry.side != :singleton
81
+
82
+ head = def_head(scope, entry.name, method_name)
83
+ return nil if head.nil? || head == entry.name
84
+
85
+ level = chain.level_starts.rindex { |start| start <= hit.index }
86
+ return :stray if level.nil? || level >= chain.level_count
87
+
88
+ found = chain.level_entries(level).any? { |candidate| candidate.side == :instance && candidate.name == head }
89
+ found && copied_from?(scope, head, method_name, entry.name) ? :copy : :stray
90
+ end
91
+
92
+ # Whether the class's singleton `def` is the very node `head`'s instance table holds, the one the fold
93
+ # copied: a receiverless `def`. A `def C.x` or `class << C; def x; end` written inside `head`'s body has the
94
+ # same nesting head and is the class's own, not a copy. (The walk records a `def C.x` written in `module X`
95
+ # as `X#x` too, so identity alone does not tell it from a copy; its receiver does.)
96
+ def copied_from?(scope, head, method_name, owner)
97
+ node = scope.singleton_def_for(owner, method_name)
98
+ return false if node.nil? || !node.receiver.nil?
99
+
100
+ scope.user_def_for(head, method_name).equal?(node)
101
+ end
102
+
103
+ # The innermost `Module.nesting` entry `owner`'s singleton `def` of the name was written in, or nil.
104
+ def def_head(scope, owner, method_name)
105
+ node = scope.singleton_def_for(owner, method_name)
106
+ return nil if node.nil?
107
+
108
+ nesting = scope.discovery.discovered_def_nestings[node] || DefNodeResolver.rehydrated_nesting(node)
109
+ nesting&.first
110
+ end
111
+
112
+ def tested_levels(chain, from, bound)
113
+ starts = chain.level_starts
114
+ size = chain.entries.size
115
+ (0...chain.level_count).select { |level| starts[level] < bound && (starts[level + 1] || size) >= from }
116
+ end
117
+
118
+ # The verdicts' working state: the scope, the flavor and the memo the verdicts share.
119
+ class Context
120
+ def initialize(scope, flavor)
121
+ @scope = scope
122
+ @flavor = flavor
123
+ @memo = Scope::ResolutionChain.hook_memo(scope, flavor)
124
+ @recording = Analysis::DependencyRecorder.active?
125
+ end
126
+
127
+ # A tested level: its singleton segment, and its class's own instance level.
128
+ def level_capable?(chain, level)
129
+ return true if chain.level_entries(level).any? { |entry| capable?(entry) }
130
+
131
+ class_name = chain.level_classes[level]
132
+ !class_name.nil? && instance_level_capable?(class_name)
133
+ end
134
+
135
+ # A level deeper than `level` whose `inherited` can define on every subclass's singleton: one that is
136
+ # hook-capable as a tested level is. Its class records `inherited` (own, folded, or a literal
137
+ # `define_method`), or an entry of its own instance level or singleton segment can define it unseen (a
138
+ # concern's `class_methods do def inherited`, a hook that extends the class with a module defining it),
139
+ # or it is an external superclass that is hook-capable.
140
+ def deeper_hook?(chain, level)
141
+ ((level + 1)...chain.level_count).any? { |deeper| level_capable?(chain, deeper) }
142
+ end
143
+
144
+ def capable?(entry)
145
+ return verdict(:project, entry.name) { project_capable?(entry.name) } unless entry.external?
146
+
147
+ verdict(:external, entry) { external_capable?(entry) }
148
+ end
149
+
150
+ private
151
+
152
+ def instance_level_capable?(class_name)
153
+ verdict(:instance_level, class_name) do |edges|
154
+ chain = Scope::ResolutionChain.for(@scope, class_name, :instance, @flavor)
155
+ next true if chain.truncated?
156
+
157
+ entries = chain.level_count.zero? ? chain.entries : chain.level_entries(0)
158
+ entries.any? do |entry|
159
+ edges.concat(entry_edges(entry))
160
+ capable?(entry)
161
+ end
162
+ end
163
+ end
164
+
165
+ # The memoised `[verdict, edges]` of one test, its edges replayed while a recording is active.
166
+ def verdict(kind, key)
167
+ verdict, edges = @memo[[kind, key]] ||= begin
168
+ edges = []
169
+ value = yield(edges)
170
+ [value ? true : false, edges.concat(own_edges(kind, key)).uniq.freeze].freeze
171
+ end
172
+ replay(edges) if @recording
173
+ verdict
174
+ end
175
+
176
+ def own_edges(kind, key)
177
+ return entry_edges(key) if kind == :external
178
+ return EMPTY_EDGES if kind == :instance_level
179
+
180
+ [[:class, key], [:missing, key.to_s.split("::").last]]
181
+ end
182
+
183
+ def entry_edges(entry)
184
+ return [[:class, entry.name], [:missing, entry.last_segment]] unless entry.external?
185
+
186
+ entry.candidates.map { |candidate| [:class, candidate] } << [:missing, entry.raw.to_s.split("::").last]
187
+ end
188
+
189
+ def replay(edges)
190
+ edges.each do |kind, value|
191
+ case kind
192
+ when :class then Scope::ResolutionChain.record_class(@scope, value)
193
+ when :missing then Analysis::DependencyRecorder.read_missing(:class, value)
194
+ end
195
+ end
196
+ end
197
+
198
+ def project_capable?(name)
199
+ sides = @scope.discovery.unpositioned_mixins[name]
200
+ return true if sides && SIDES.any? { |side| sides[side]&.include?(WILDCARD) }
201
+
202
+ return true if records_hook?(name)
203
+
204
+ extends_concern?(name)
205
+ end
206
+
207
+ # Whether `name` records a hook name on either side, or in its envelope table: a `def self.inherited`, a
208
+ # module's instance `def inherited` (a hook once extended), `singleton_class.define_method(:inherited)`
209
+ # (recorded as an instance method) and `define_singleton_method(:inherited)` (recorded only as an opaque
210
+ # envelope, the name-literal evidence `ScopeIndexer#record_surface_evidence` files).
211
+ def records_hook?(name)
212
+ envelopes = @scope.parameter_envelopes_of(name)
213
+ Scope::ResolutionChain::Relevance::HOOKS.any? do |hook|
214
+ @scope.discovered_method?(name, hook, :singleton) || @scope.discovered_method?(name, hook, :instance) ||
215
+ envelopes.key?([:singleton, hook]) || envelopes.key?([:instance, hook])
216
+ end
217
+ end
218
+
219
+ # Whether `name`'s own singleton segment (its `extend`s, as the chain places them) holds
220
+ # `ActiveSupport::Concern` — the concern shape's only signal, a framework name the follow-up ADR moves
221
+ # behind the plugin API (WD3, Q11).
222
+ def extends_concern?(name)
223
+ chain = Scope::ResolutionChain.for(@scope, name, :singleton, @flavor)
224
+ entries = chain.level_count.zero? ? chain.entries : chain.level_entries(0)
225
+ entries.any? do |entry|
226
+ entry.external? ? entry.candidates.include?(CONCERN) : entry.name == CONCERN
227
+ end
228
+ end
229
+
230
+ # A declared module the chain holds as external is tested as that module; an undeclared one is capable
231
+ # unless RBS knows it.
232
+ def external_capable?(entry)
233
+ declared = entry.candidates.select { |candidate| declared?(candidate) }
234
+ return declared.any? { |name| verdict(:project, name) { project_capable?(name) } } unless declared.empty?
235
+
236
+ entry.candidates.none? { |candidate| Rigor::Reflection.rbs_class_known?(candidate, scope: @scope) }
237
+ rescue StandardError
238
+ true
239
+ end
240
+
241
+ def declared?(name)
242
+ discovery = @scope.discovery
243
+ discovery.discovered_class_sources.key?(name) || discovery.discovered_classes.key?(name) ||
244
+ discovery.discovered_includes.key?(name) || discovery.discovered_extends.key?(name)
245
+ end
246
+ end
247
+ private_constant :Context
248
+
249
+ EMPTY_EDGES = [].freeze
250
+ private_constant :EMPTY_EDGES
251
+ end
252
+ end
253
+ end
@@ -9,6 +9,7 @@ require_relative "../source/node_walker"
9
9
  require_relative "../source/node_children"
10
10
  require_relative "../source/constant_path"
11
11
  require_relative "anonymous_meta_class"
12
+ require_relative "definer_resolution"
12
13
  require_relative "block_repetition"
13
14
  require_relative "block_parameter_binder"
14
15
  require_relative "body_fixpoint"
@@ -272,10 +273,16 @@ module Rigor
272
273
  # {OperandWalk} rooted inside an operand still records its later operands ({#walk_recorder}).
273
274
  # @param operand_types — the later operands' own values ({OperandWalk#types}) for the evaluator that runs a
274
275
  # call from the scope its operands left, read by {#type_operand}.
276
+ # @param opaque_class — true while the walk is inside the body of a class nothing names (issue #1518:
277
+ # a declined `class self::X`, a parse-error header), where `class_context` is empty without the code
278
+ # being at the top level. A `def` (or `class << self`) there runs under an untyped `self` instead of
279
+ # the top level's nil one, so its implicit-self calls answer `Dynamic` and no top-level rule fires.
275
280
  def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze, # rubocop:disable Metrics/ParameterLists
276
281
  lexical_nesting: EMPTY_NESTING, converged_loop_recording: false, next_scope_sink: nil,
277
- operand_scope: nil, in_operand: false, operand_recorder: nil, operand_types: nil)
282
+ operand_scope: nil, in_operand: false, operand_recorder: nil, operand_types: nil,
283
+ opaque_class: false)
278
284
  @scope = scope
285
+ @opaque_class = opaque_class
279
286
  @tracer = tracer
280
287
  @on_enter = on_enter
281
288
  @class_context = class_context.freeze
@@ -625,7 +632,8 @@ module Rigor
625
632
  # lands.
626
633
  def eval_ivar_write(node)
627
634
  rhs_type, post_rhs = sub_eval(node.value, scope)
628
- bound = post_rhs.with_ivar(node.name, rhs_type)
635
+ # Issue #1446 — a write ends a class guard's narrowing of the variable, as it does a global's.
636
+ bound = post_rhs.with_ivar(node.name, rhs_type).without_ivar_guard(node.name)
629
637
  bound = bound.with_ivar_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
630
638
  cause = optimistic_rhs_origin(node.value, post_rhs)
631
639
  bound = bound.with_optimistic_ivar(node.name, cause, miss: optimistic_rhs_miss(node.value, post_rhs)) if cause
@@ -806,7 +814,8 @@ module Rigor
806
814
  end
807
815
 
808
816
  def rebind_variable(target_scope, kind, name, type)
809
- target_scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, type)
817
+ bound = target_scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, type)
818
+ kind == :ivar ? bound.without_ivar_guard(name) : bound
810
819
  end
811
820
 
812
821
  def compound_result_type(current, rhs, operator)
@@ -2431,6 +2440,12 @@ module Rigor
2431
2440
  case index_node
2432
2441
  when Prism::LocalVariableTargetNode
2433
2442
  scope.with_local(index_node.name, element_type)
2443
+ when Prism::InstanceVariableTargetNode
2444
+ # Issue #1446 — `for @io in xs` writes the ivar each iteration, which ends a class guard's narrowing of it,
2445
+ # and `for $out in xs` the global, which ends any guard's narrowing of it.
2446
+ scope.with_ivar(index_node.name, element_type).without_ivar_guard(index_node.name)
2447
+ when Prism::GlobalVariableTargetNode
2448
+ scope.with_global(index_node.name, element_type)
2434
2449
  when Prism::IndexTargetNode
2435
2450
  widen_index_target(index_node, element_type, scope, type_scope: scope)
2436
2451
  when Prism::MultiTargetNode
@@ -2698,13 +2713,31 @@ module Rigor
2698
2713
  # that half rather than this one deriving it from the frames.
2699
2714
  def eval_class_or_module(node)
2700
2715
  path = node.constant_path
2701
- frame = ClassFrame.new(name: Source::ConstantPath.qualified_name(path), singleton: false)
2716
+ name = Source::ConstantPath.qualified_name(path)
2717
+ # A header Prism recovered without a constant (`class foo`, a parse error) names no class: its body
2718
+ # runs under an untyped `self` and no class frame, so nothing is filed under an empty name.
2719
+ return eval_unnameable_class_body(node) if name.nil?
2720
+ # `class self::X` the indexer declined (opaque `self`, e.g. `REGISTRY.first.class_eval`) is not the
2721
+ # lexical `X` that `qualified_name` renders; a nameable `self::` header is in the identity table.
2722
+ return eval_unnameable_class_body(node) if declined_self_header?(path)
2723
+
2724
+ frame = ClassFrame.new(name: name, singleton: false)
2702
2725
  new_context = Source::ConstantPath.rooted?(path) ? [frame] : @class_context + [frame]
2703
2726
  body_type, _body_scope = eval_class_body(node, new_context,
2704
2727
  Source::ConstantPath.pushed_nesting(@lexical_nesting, path))
2705
2728
  [body_type, scope]
2706
2729
  end
2707
2730
 
2731
+ def declined_self_header?(path)
2732
+ path.is_a?(Prism::ConstantPathNode) && path.parent.is_a?(Prism::SelfNode) &&
2733
+ !scope.declared_types.key?(path)
2734
+ end
2735
+
2736
+ def eval_unnameable_class_body(node)
2737
+ body_type, _body_scope = eval_class_body(node, [], @lexical_nesting, opaque_self: true)
2738
+ [body_type, scope]
2739
+ end
2740
+
2708
2741
  # `class << expr; body; end`. When `expr` is `self`, the body defines class methods on the immediate enclosing
2709
2742
  # class — the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
2710
2743
  # rather than `instance_method`. For non-`self` expressions we cannot statically resolve the receiver, so we keep
@@ -5175,7 +5208,7 @@ module Rigor
5175
5208
 
5176
5209
  # ----- def/class helpers -----
5177
5210
 
5178
- def eval_class_body(node, new_context, new_nesting = @lexical_nesting)
5211
+ def eval_class_body(node, new_context, new_nesting = @lexical_nesting, opaque_self: false)
5179
5212
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
5180
5213
 
5181
5214
  # Class/module bodies run in a fresh scope: the outer scope's locals are NOT visible inside `class Foo; ...
@@ -5183,7 +5216,10 @@ module Rigor
5183
5216
  # A-engine: `self` inside a class body is the class object itself, so we set `self_type` to
5184
5217
  # `Singleton[<qualified>]`.
5185
5218
  fresh = build_fresh_body_scope
5186
- body_self = self_type_for_class_body(new_context)
5219
+ # Issue #1518 — `class << self` inside an unnameable class body keeps that body's opacity: it opens the
5220
+ # singleton of a class nothing names, and its empty frame stack is not the top level.
5221
+ opaque_self ||= node.is_a?(Prism::SingletonClassNode) && @opaque_class && new_context.empty?
5222
+ body_self = opaque_self ? Type::Combinator.untyped : self_type_for_class_body(new_context)
5187
5223
  fresh = fresh.with_self_type(body_self) if body_self
5188
5224
  # Issue #963 — `self` in a `class << ...` body is the SINGLETON class, which shares the `Singleton[X]`
5189
5225
  # carrier a `class X` body gets. The mark is the only thing that tells the two apart downstream, and a
@@ -5191,7 +5227,7 @@ module Rigor
5191
5227
  fresh = fresh.with_singleton_class_body(node.is_a?(Prism::SingletonClassNode))
5192
5228
  fresh = fresh.with_match_frame(node.body)
5193
5229
  fresh = stamp_nesting(fresh, new_nesting)
5194
- sub_eval(node.body, fresh, class_context: new_context, lexical_nesting: new_nesting)
5230
+ sub_eval(node.body, fresh, class_context: new_context, lexical_nesting: new_nesting, opaque_class: opaque_self)
5195
5231
  end
5196
5232
 
5197
5233
  def build_method_entry_scope(def_node) # rubocop:disable Metrics/AbcSize
@@ -5410,7 +5446,9 @@ module Rigor
5410
5446
  # Returns nil for top-level defs that have no enclosing class.
5411
5447
  def self_type_for_method_body(singleton:)
5412
5448
  path = current_class_path
5413
- return nil if path.nil?
5449
+ # Issue #1518 — a `def` in a class nothing names has a receiver, just not one Rigor can type; nil would
5450
+ # read its body as the top level and its implicit-self calls as unresolved top-level calls.
5451
+ return (Type::Combinator.untyped if @opaque_class) if path.nil?
5414
5452
 
5415
5453
  if singleton
5416
5454
  Type::Combinator.singleton_of(path)
@@ -5549,10 +5587,14 @@ module Rigor
5549
5587
  # `on_enter: nil` evaluates without recording into the per-node scope index — for a pass whose scopes are not
5550
5588
  # the ones the index should keep. `next_scope_sink:` is replaced only by {#evaluate_invocation} and
5551
5589
  # {#loop_iteration}.
5552
- def sub_eval(node, with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting,
5553
- on_enter: @on_enter, next_scope_sink: @next_scope_sink, operand_recorder: @operand_recorder)
5590
+ # `opaque_class:` survives only while the frame stack stays empty: a named class opened inside an
5591
+ # unnameable body gets a frame, and its `def`s a nominal `self`, as they did before #1518.
5592
+ def sub_eval(node, with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting, # rubocop:disable Metrics/ParameterLists
5593
+ on_enter: @on_enter, next_scope_sink: @next_scope_sink, operand_recorder: @operand_recorder,
5594
+ opaque_class: @opaque_class && class_context.empty?)
5554
5595
  evaluator_at(with_scope, class_context: class_context, lexical_nesting: lexical_nesting, on_enter: on_enter,
5555
- next_scope_sink: next_scope_sink, operand_recorder: operand_recorder).evaluate(node)
5596
+ next_scope_sink: next_scope_sink, operand_recorder: operand_recorder,
5597
+ opaque_class: opaque_class).evaluate(node)
5556
5598
  end
5557
5599
 
5558
5600
  # An evaluator over `with_scope` that inherits everything else from this one. `operand_scope:` and
@@ -5560,7 +5602,8 @@ module Rigor
5560
5602
  # operands left.
5561
5603
  def evaluator_at(with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting, # rubocop:disable Metrics/ParameterLists
5562
5604
  on_enter: @on_enter, next_scope_sink: @next_scope_sink, operand_scope: nil,
5563
- in_operand: @in_operand, operand_recorder: @operand_recorder, operand_types: nil)
5605
+ in_operand: @in_operand, operand_recorder: @operand_recorder, operand_types: nil,
5606
+ opaque_class: @opaque_class)
5564
5607
  StatementEvaluator.new(
5565
5608
  scope: with_scope,
5566
5609
  tracer: tracer,
@@ -5572,7 +5615,8 @@ module Rigor
5572
5615
  operand_scope: operand_scope,
5573
5616
  in_operand: in_operand,
5574
5617
  operand_recorder: operand_recorder,
5575
- operand_types: operand_types
5618
+ operand_types: operand_types,
5619
+ opaque_class: opaque_class
5576
5620
  )
5577
5621
  end
5578
5622
 
@@ -5675,6 +5719,12 @@ module Rigor
5675
5719
  case ref
5676
5720
  when Prism::LocalVariableTargetNode
5677
5721
  scope.with_local(ref.name, exception_type)
5722
+ when Prism::InstanceVariableTargetNode
5723
+ # Issue #1446 — `rescue => @io` writes the ivar, which ends a class guard's narrowing of it, and `rescue =>
5724
+ # $out` the global, which ends any guard's narrowing of it.
5725
+ scope.with_ivar(ref.name, exception_type).without_ivar_guard(ref.name)
5726
+ when Prism::GlobalVariableTargetNode
5727
+ scope.with_global(ref.name, exception_type)
5678
5728
  when Prism::IndexTargetNode
5679
5729
  widen_index_target(ref, exception_type, scope, type_scope: scope)
5680
5730
  else
@@ -5818,7 +5868,7 @@ module Rigor
5818
5868
  def source_decomposition_projection(subject_type, method_name, arg_types, scope)
5819
5869
  return nil unless subject_type.is_a?(Type::Nominal)
5820
5870
 
5821
- def_node = scope.discovered_def_nodes[subject_type.class_name]&.[](method_name)
5871
+ def_node = own_definer_node(scope, subject_type.class_name, method_name)
5822
5872
  return nil if def_node.nil?
5823
5873
 
5824
5874
  result = scope.user_method_return(def_node, subject_type, arg_types)
@@ -5827,6 +5877,22 @@ module Rigor
5827
5877
  result
5828
5878
  end
5829
5879
 
5880
+ # The `def` node the receiver's OWN class defines `method_name` with, or nil. ADR-119 C2-e (#1615): asked of
5881
+ # `DefinerResolution`, so a slot rebuilt from an ADR-85 seed bundle is resolved to a node and the read files
5882
+ # its ADR-46 edge. UNKNOWN answers nil: an unsettled or forked chain, or a contested slot once ADR-119 C1d
5883
+ # fills the siblings. Conditional or reopened defs are not contested until C1d. A definer further up the
5884
+ # chain (a prepended or included module, a superclass) is not the own class's, so the owner check declines it.
5885
+ def own_definer_node(scope, class_name, method_name)
5886
+ case DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :definer)
5887
+ in DefinerResolution::Known(answer: [node, owner])
5888
+ owner == class_name ? node : nil
5889
+ in DefinerResolution::UNKNOWN # the case/in contract needs one arm per answer
5890
+ nil
5891
+ in DefinerResolution::ABSENT # rubocop:disable Lint/DuplicateBranch -- see above
5892
+ nil
5893
+ end
5894
+ end
5895
+
5830
5896
  # A `StructInstance`'s own projection — `Tuple` of its member values for `deconstruct`, `HashShape` of
5831
5897
  # its members for `deconstruct_keys` — or nil when the subject is another carrier or the projection would
5832
5898
  # be unsound.
@@ -882,7 +882,7 @@ module Rigor
882
882
 
883
883
  before, separator, = file.rpartition("/lib/")
884
884
  root = separator.empty? ? File.dirname(file) : before
885
- relative.map { |rel| File.expand_path(rel, root) }
885
+ relative.map { |rel| File.absolute_path(rel, root) }
886
886
  end
887
887
 
888
888
  # ADR-28 — the path-scoped method-protocol contracts this plugin contributes. Defaults to the
@@ -940,6 +940,17 @@ module Rigor
940
940
  # changed. The producer id is auto-prefixed `plugin.<manifest.id>.` per ADR-7 § "Slice 6-C" so plugin
941
941
  # caches stay sandboxed from built-in producers.
942
942
  #
943
+ # #1558 — whichever way the value arrives, the boundary ends up holding every row of the producer's
944
+ # dependency descriptor. A hit ran no block and read nothing, so the served entry's stored rows are
945
+ # replayed into it ({IoBoundary#replay}); a miss read its inputs itself, and replaying the descriptor it
946
+ # just recorded adds the evaluated `watch:` rows, the only rows the block did not record. The boundary
947
+ # is per plugin and accumulates for the run, so the rows reach the ADR-45 run-result descriptor
948
+ # (`Runner#build_run_dependency_descriptor`) and the descriptor of every producer of this plugin
949
+ # computed after it, whether its block asked this one or it composes a value this one returned
950
+ # earlier. Without the replay a producer recomputed after a served one stored no row for the served
951
+ # value's inputs, and neither did the run: rigor-activerecord's `:model_index`, recomputed after a
952
+ # model edit while `:schema_table` hit, kept its stale columns after `db/schema.rb` changed.
953
+ #
943
954
  # When `services.cache_store` is `nil` (e.g. CLI `--no-cache`), the callable bypasses the cache and
944
955
  # runs the producer block every time — same semantics as the v0.0.9 cache surface for built-in
945
956
  # producers.
@@ -974,11 +985,9 @@ module Rigor
974
985
  generation_cap: producer[:generation_cap],
975
986
  params: params,
976
987
  serialize: pair_serializer(producer[:serialize]),
977
- deserialize: pair_deserializer(producer[:deserialize])
978
- ) do
979
- value = compute.call
980
- [value, producer_dependency_descriptor(producer)]
981
- end
988
+ deserialize: pair_deserializer(producer[:deserialize]),
989
+ on_hit: ->(dependencies) { io_boundary.replay(dependencies) }
990
+ ) { computed_producer_pair(producer, compute) }
982
991
  end
983
992
  end
984
993
 
@@ -1151,7 +1160,7 @@ module Rigor
1151
1160
 
1152
1161
  def collect_glob_files(roots, patterns)
1153
1162
  matched = roots.flat_map do |root|
1154
- absolute = File.expand_path(root.to_s)
1163
+ absolute = File.absolute_path(root.to_s)
1155
1164
  next [] unless File.directory?(absolute)
1156
1165
 
1157
1166
  patterns.flat_map { |pattern| Dir.glob(File.join(absolute, pattern.to_s)) }
@@ -1196,6 +1205,17 @@ module Rigor
1196
1205
  Cache::Descriptor.compose(auto_built, extra)
1197
1206
  end
1198
1207
 
1208
+ # #1558 — the miss half of {#cache_for}. The block's own reads are in the boundary already, so replaying
1209
+ # the descriptor it just recorded adds the evaluated `watch:` rows, as a hit's replay of the stored
1210
+ # descriptor does; a producer computing around this one then records one row set whichever way this
1211
+ # value arrived.
1212
+ def computed_producer_pair(producer, compute)
1213
+ value = compute.call
1214
+ dependencies = producer_dependency_descriptor(producer)
1215
+ io_boundary.replay(dependencies)
1216
+ [value, dependencies]
1217
+ end
1218
+
1199
1219
  # ADR-60 WD3 — the dependency descriptor stored beside the producer's value, built AFTER the block ran
1200
1220
  # so every in-block `io_boundary` read is captured, plus the evaluated `watch:` glob rows.
1201
1221
  #
@@ -1224,7 +1244,7 @@ module Rigor
1224
1244
  Array(tuples).flat_map do |tuple|
1225
1245
  roots, *patterns = Array(tuple)
1226
1246
  Array(roots).flat_map do |root|
1227
- absolute = File.expand_path(root.to_s)
1247
+ absolute = File.absolute_path(root.to_s)
1228
1248
  patterns.map { |pattern| Cache::Descriptor::GlobEntry.compute(root: absolute, pattern: pattern.to_s) }
1229
1249
  end
1230
1250
  end.uniq