rigortype 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../scope"
6
+ require_relative "../type"
7
+ require_relative "scope_indexer"
8
+
9
+ module Rigor
10
+ module Inference
11
+ # Issue #352 / [ADR-17](../../../docs/adr/17-monkey-patch-pre-evaluation.md) — the constant half of the
12
+ # `pre_eval:` publication surface.
13
+ #
14
+ # ADR-17 slice 2 gave `pre_eval:` a project-wide **method** registry ({ProjectPatchedMethods}). A
15
+ # constant declared in the same file stayed invisible across the file boundary: `Scope#in_source_constants`
16
+ # is per-file and is deliberately not part of `Runner#project_scope_seed_tables`, so only class-shaped
17
+ # constants (a `class` / `module` declaration, or one of the four meta-new forms `ScopeIndexer`'s
18
+ # `record_class_new_constant_decl` promotes) ever crossed. `TIMEOUT = 30` in a listed file read as
19
+ # `Dynamic[top]` everywhere else.
20
+ #
21
+ # This collector closes that half. It walks the `pre_eval:` files with the SAME constant pre-pass the
22
+ # per-file path uses ({ScopeIndexer.build_in_source_constants}) and publishes the result — widened — into
23
+ # the project seed. Files not listed under `pre_eval:` are untouched, so the feature is opt-in by
24
+ # construction and its cost stays proportional to the listed file count (ADR-17 WD1's cost-bounded
25
+ # argument, which is also why slice 5's full-project two-pass stayed rejectable).
26
+ #
27
+ # ## The widening rule — why the published type is not the same-file type
28
+ #
29
+ # Same-file constant propagation is **value-pinned**: `42`, `"hello"`, a `Tuple`, a `HashShape`. Carrying
30
+ # that verbatim across a file boundary would land diagnostics in files whose author never opened the
31
+ # constant's definition — `CONFIG = { a: 1 }` arriving as a closed `HashShape` makes `CONFIG.fetch(:b)`
32
+ # fire, `ARR = [1, 2]` arriving as a `Tuple` routes through `ShapeDispatch` instead of the RBS overload a
33
+ # call site relied on. Today's cross-file `Dynamic[top]` is false-positive-free *by construction*, so
34
+ # precision here can only spend that budget; AGENTS.md § "Implementation Guidelines" ranks false positives
35
+ # above worst-case static reading, so this slice spends as little of it as possible.
36
+ #
37
+ # {.widen} therefore publishes the **erased class**, never the value:
38
+ #
39
+ # | Declared | Same file | Published cross-file |
40
+ # | --- | --- | --- |
41
+ # | `INT_LIT = 42` | `42` | `Integer` |
42
+ # | `STR_LIT = "hello"` | `"hello"` | `String` |
43
+ # | `ARR_LIT = [1, 2]` | `[1, 2]` (Tuple) | `Array` (raw, elements dropped) |
44
+ # | `HSH_LIT = { a: 1 }` | `{ a: 1 }` (HashShape) | `Hash` (raw, keys dropped) |
45
+ # | `ALIAS_CLS = String` | `singleton(String)` | `singleton(String)` |
46
+ # | `Nest::NESTED_INT = 7` | `7` | `Integer` |
47
+ #
48
+ # Anything the rule does not recognise **declines** — the name is simply not published and reads exactly
49
+ # as it does today. Declining is always the safe answer here, which is why the `else` arm is `nil` rather
50
+ # than a fallback. `Constant[nil]` declines on purpose: a `X = nil` at declaration position is a
51
+ # placeholder for a value assigned at runtime far more often than it is a genuine `NilClass`, and
52
+ # publishing `NilClass` project-wide would make every use of it a diagnostic.
53
+ #
54
+ # Value-pinning a *provably* frozen single-write literal cross-file is a strictly later question; it needs
55
+ # its own FP measurement and is deliberately not attempted here.
56
+ #
57
+ # ## The multi-file write rule — widen on conflict, all the way to `Dynamic[top]`
58
+ #
59
+ # Within one file, `ScopeIndexer#record_constant_write` unions repeated writes; that union is widened as a
60
+ # whole (`X = 1; X = "a"` in one file publishes `Integer | String`). ACROSS files the same union would be
61
+ # a type neither author can see, so the rule is **widen on conflict**: when two listed files publish the
62
+ # same qualified name with different widened types, the name is dropped from the table entirely and reads
63
+ # as `Dynamic[top]` — the widest type there is, and the one the name already had. Agreeing writes (`X = 1`
64
+ # here, `X = 2` there — both `Integer`) are not a conflict at all, which is the point of widening first:
65
+ # `1 | 2` is never produced.
66
+ #
67
+ # ## Ordering
68
+ #
69
+ # The published table seeds `Scope#in_source_constants`, which `Reflection.constant_type_at` consults
70
+ # AFTER the class registry and `discovered_classes`. A published entry can therefore never mask a
71
+ # class-shaped constant that already crossed, and the per-file table always wins over the seed (see
72
+ # {ScopeIndexer.index}'s merge) — same-file remains the most specific authority.
73
+ module PreEvalConstants
74
+ EMPTY = {}.freeze
75
+
76
+ module_function
77
+
78
+ # Collects and widens the constants every `pre_eval:` file declares.
79
+ #
80
+ # @param paths [Array<String>] absolute paths to the `pre_eval:` files that exist on disk.
81
+ # @param scope_builder [#call] `path -> Rigor::Scope`; the caller supplies a project-seeded, environment-
82
+ # bound scope so the rvalue typer resolves cross-file classes exactly as per-file analysis would.
83
+ # @param target_ruby [String, nil] the Prism parse version (`Configuration#target_ruby`).
84
+ # @param buffer [Rigor::Analysis::BufferBinding, nil] editor-mode binding; when set, a listed file that
85
+ # matches the in-flight buffer is read from its physical bytes.
86
+ # @return [Hash{String => Rigor::Type}] frozen qualified-name -> published type table.
87
+ def collect(paths:, scope_builder:, target_ruby: nil, buffer: nil)
88
+ published = {}
89
+ conflicted = {}
90
+ paths.each do |path|
91
+ file_constants(path, scope_builder: scope_builder, target_ruby: target_ruby, buffer: buffer)
92
+ .each { |name, type| merge_publication(published, conflicted, name, type) }
93
+ end
94
+ published.freeze
95
+ end
96
+
97
+ # Folds one declaration into the accumulator under the widen-on-conflict rule. A name that has already
98
+ # conflicted stays out for the rest of the collection — a later agreeing write must not resurrect it.
99
+ def merge_publication(published, conflicted, name, type)
100
+ return if conflicted.key?(name)
101
+
102
+ widened = widen(type)
103
+ return if widened.nil?
104
+
105
+ existing = published[name]
106
+ return published[name] = widened if existing.nil?
107
+ return if existing == widened
108
+
109
+ published.delete(name)
110
+ conflicted[name] = true
111
+ end
112
+ private_class_method :merge_publication
113
+
114
+ # The per-file constant table, typed through the same pre-pass the per-file path runs. Deliberately NOT
115
+ # a full `ScopeIndexer.index`: ADR-17 WD4 keeps the pre-eval pass a discovery walk, and the constant
116
+ # pre-pass (plus the declaration artifacts it needs to resolve in-file class references) is exactly the
117
+ # discovery facet this feature consumes. Fails soft to the empty table — a pre-eval file that cannot be
118
+ # read or parsed already surfaces a `pre-eval.parse-error` warning from {ProjectPatchedScanner}, and it
119
+ # must never break the run.
120
+ def file_constants(path, scope_builder:, target_ruby: nil, buffer: nil)
121
+ physical = buffer ? buffer.resolve(path) : path
122
+ parse_result = Prism.parse(File.read(physical), filepath: path, version: target_ruby)
123
+ return EMPTY unless parse_result.errors.empty?
124
+
125
+ root = parse_result.value
126
+ ScopeIndexer.build_in_source_constants(root, declaration_seeded_scope(root, scope_builder.call(path)))
127
+ rescue StandardError
128
+ EMPTY
129
+ end
130
+ private_class_method :file_constants
131
+
132
+ # Mirrors the head of {ScopeIndexer.index}: seed the file's own declaration overrides and discovered
133
+ # classes onto the project-seeded scope so a `CONST = SomeClassDefinedRightHere.new` rvalue types the
134
+ # same way it does during real analysis.
135
+ def declaration_seeded_scope(root, scope)
136
+ declared_types, discovered_classes = ScopeIndexer.build_declaration_artifacts(root)
137
+ scope.with_discovery(
138
+ scope.discovery.with(
139
+ declared_types: declared_types,
140
+ discovered_classes: scope.discovered_classes.merge(discovered_classes)
141
+ )
142
+ )
143
+ end
144
+ private_class_method :declaration_seeded_scope
145
+
146
+ # The publication widening. Returns the type to publish, or `nil` to decline (the name keeps today's
147
+ # `Dynamic[top]` cross-file reading). See the module doc for why declining is the safe default.
148
+ def widen(type)
149
+ case type
150
+ when Type::Constant then widen_constant(type)
151
+ when Type::Refined then widen(type.base)
152
+ when Type::IntegerRange then Type::Combinator.nominal_of("Integer")
153
+ when Type::Tuple then Type::Combinator.nominal_of("Array")
154
+ when Type::HashShape then Type::Combinator.nominal_of("Hash")
155
+ when Type::Nominal then type.type_args.empty? ? type : Type::Combinator.nominal_of(type.class_name)
156
+ when Type::Singleton then type
157
+ when Type::DataInstance, Type::StructInstance then nominal_for_class_name(type.class_name)
158
+ when Type::Union then widen_union(type)
159
+ end
160
+ end
161
+
162
+ # `Constant[v]` publishes `v`'s class. `nil` declines (see the module doc); so does a value whose class
163
+ # is anonymous, which has no name to publish under.
164
+ def widen_constant(type)
165
+ return nil if type.value.nil?
166
+
167
+ nominal_for_class_name(type.value.class.name)
168
+ end
169
+ private_class_method :widen_constant
170
+
171
+ def nominal_for_class_name(class_name)
172
+ return nil unless class_name.is_a?(String) && !class_name.empty?
173
+
174
+ Type::Combinator.nominal_of(class_name)
175
+ end
176
+ private_class_method :nominal_for_class_name
177
+
178
+ # A union publishes only when EVERY member widens: one unrecognised arm means the union's real extent is
179
+ # unknown, and a partial union would be narrower than the truth — the false-positive direction.
180
+ def widen_union(type)
181
+ widened = type.members.map { |member| widen(member) }
182
+ return nil if widened.any?(&:nil?)
183
+
184
+ Type::Combinator.union(*widened)
185
+ end
186
+ private_class_method :widen_union
187
+ end
188
+ end
189
+ end
@@ -7,6 +7,7 @@ require_relative "../type"
7
7
  require_relative "../source/constant_path"
8
8
  require_relative "../source/node_children"
9
9
  require_relative "../cache/file_digest"
10
+ require_relative "anonymous_meta_class"
10
11
  require_relative "def_handle"
11
12
  require_relative "mutation_widening"
12
13
  require_relative "narrowing"
@@ -87,10 +88,13 @@ module Rigor
87
88
  # program and types its rvalue under a scope that carries the surrounding qualified prefix as `self_type`, so
88
89
  # the rvalue typer sees in-class references resolve correctly. Multiple writes to the same qualified name union
89
90
  # via `Type::Combinator.union`.
91
+ # Issue #352 — the per-file table merges OVER whatever the base scope already published (the
92
+ # `pre_eval:` constant seed `Runner#project_scope_seed_tables` applies). Same-file declarations are the
93
+ # most specific authority, exactly as `merged_classes` above resolves the same collision. Without the
94
+ # merge, the assignment below would silently drop the project seed on every file.
90
95
  in_source_constants = build_in_source_constants(root, seeded_scope)
91
- seeded_scope = seeded_scope.with_discovery(
92
- seeded_scope.discovery.with(in_source_constants: in_source_constants)
93
- )
96
+ merged_constants = merge_seeded_constants(default_scope.in_source_constants, in_source_constants)
97
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(in_source_constants: merged_constants))
94
98
 
95
99
  # Slice 7 phase 12. In-source method discovery. Walks every class/module body for `Prism::DefNode` and
96
100
  # recognised `define_method` calls and records the introduced method names. `rigor check` consults the table to
@@ -133,7 +137,7 @@ module Rigor
133
137
  # file_def_nodes]` so the caller can thread the def-node table into {#merge_project_method_indexes} without
134
138
  # walking the file a second time.
135
139
  def seed_discovered_methods(seeded_scope, default_scope, root)
136
- file_methods, file_def_nodes = build_methods_and_def_nodes(root)
140
+ file_methods, file_def_nodes = build_methods_and_def_nodes(root, default_scope.source_path)
137
141
  discovered_methods = deep_merge_class_methods(default_scope.discovered_methods, file_methods)
138
142
  scope = seeded_scope.with_discovery(seeded_scope.discovery.with(discovered_methods: discovered_methods))
139
143
  [scope, file_def_nodes]
@@ -161,7 +165,7 @@ module Rigor
161
165
  build_discovered_singleton_def_nodes(root)
162
166
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
163
167
  superclasses = default_scope.discovered_superclasses.merge(
164
- build_discovered_superclasses(root)
168
+ build_discovered_superclasses(root, default_scope.source_path)
165
169
  )
166
170
  includes = default_scope.discovered_includes.merge(
167
171
  build_discovered_includes(root)
@@ -1205,6 +1209,15 @@ module Rigor
1205
1209
  accumulator.freeze
1206
1210
  end
1207
1211
 
1212
+ # Issue #352 — folds the project-wide `pre_eval:` constant seed under this file's own table. Returns the
1213
+ # per-file table unchanged (same frozen object) when nothing was seeded, so a run without `pre_eval:`
1214
+ # constants allocates and compares exactly what it did before.
1215
+ def merge_seeded_constants(seeded, per_file)
1216
+ return per_file if seeded.nil? || seeded.empty?
1217
+
1218
+ seeded.merge(per_file).freeze
1219
+ end
1220
+
1208
1221
  def walk_constant_writes(node, qualified_prefix, default_scope, accumulator)
1209
1222
  return unless node.is_a?(Prism::Node)
1210
1223
 
@@ -1266,10 +1279,10 @@ module Rigor
1266
1279
  # `walk_methods` and `walk_def_nodes` had byte-identical class / module / singleton / meta-block descents (both
1267
1280
  # stop at `DefNode`), so a single combined walk records both accumulators at once instead of traversing every file
1268
1281
  # twice.
1269
- def build_methods_and_def_nodes(root)
1282
+ def build_methods_and_def_nodes(root, source_path = nil)
1270
1283
  methods = {}
1271
1284
  def_nodes = {}
1272
- walk_methods_and_def_nodes(root, [], false, methods, def_nodes)
1285
+ walk_methods_and_def_nodes(root, [], false, methods, def_nodes, source_path)
1273
1286
  apply_alias_def_nodes(root, def_nodes)
1274
1287
  [methods.transform_values(&:freeze).freeze, def_nodes.transform_values(&:freeze).freeze]
1275
1288
  end
@@ -1315,7 +1328,8 @@ module Rigor
1315
1328
  # right accumulator) and the original `walk_methods` returning at `AliasMethodNode` (its symbol-only children
1316
1329
  # carry no def / class node, so not descending them is byte-identical for `def_nodes` too). See
1317
1330
  # {#build_methods_and_def_nodes}.
1318
- def walk_methods_and_def_nodes(node, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc)
1331
+ def walk_methods_and_def_nodes(node, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc,
1332
+ source_path = nil)
1319
1333
  return unless node.is_a?(Prism::Node)
1320
1334
 
1321
1335
  case node
@@ -1324,21 +1338,27 @@ module Rigor
1324
1338
  if name
1325
1339
  child_prefix = qualified_prefix + [name]
1326
1340
  record_meta_superclass_members(node, child_prefix, methods_acc) if node.is_a?(Prism::ClassNode)
1327
- walk_methods_and_def_nodes(node.body, child_prefix, false, methods_acc, def_nodes_acc) if node.body
1341
+ if node.body
1342
+ walk_methods_and_def_nodes(node.body, child_prefix, false, methods_acc, def_nodes_acc, source_path)
1343
+ end
1328
1344
  return
1329
1345
  end
1330
1346
  when Prism::SingletonClassNode
1331
1347
  if node.body
1332
1348
  singleton_prefix = singleton_class_prefix(node, qualified_prefix)
1333
1349
  if singleton_prefix
1334
- walk_methods_and_def_nodes(node.body, singleton_prefix, true, methods_acc, def_nodes_acc)
1350
+ walk_methods_and_def_nodes(node.body, singleton_prefix, true, methods_acc, def_nodes_acc, source_path)
1335
1351
  return
1336
1352
  end
1337
1353
  end
1338
1354
  when Prism::ConstantWriteNode
1339
1355
  if meta_new_block_body(node)
1340
1356
  child_prefix = qualified_prefix + [node.name.to_s]
1341
- walk_methods_and_def_nodes(meta_new_block_body(node), child_prefix, false, methods_acc, def_nodes_acc)
1357
+ walk_methods_and_def_nodes(meta_new_block_body(node), child_prefix, false, methods_acc, def_nodes_acc,
1358
+ source_path)
1359
+ # No anonymous registration here: the constant IS the name, and `StatementEvaluator` never routes a
1360
+ # constant-write rvalue through the block-body narrowing (its scope index still shows `self` as
1361
+ # `Dynamic[top]` inside such a body), so the two passes agree on the constant name alone.
1342
1362
  return
1343
1363
  end
1344
1364
  when Prism::DefNode
@@ -1349,14 +1369,43 @@ module Rigor
1349
1369
  record_alias_method(node, qualified_prefix, in_singleton_class, methods_acc)
1350
1370
  return
1351
1371
  when Prism::CallNode
1352
- record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
1353
- if ATTR_MACROS.include?(node.name)
1354
- record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc)
1372
+ anonymous = record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
1373
+ if anonymous
1374
+ walk_anonymous_meta_block(node, anonymous, qualified_prefix, in_singleton_class, methods_acc,
1375
+ def_nodes_acc, source_path)
1376
+ return
1355
1377
  end
1356
1378
  end
1357
1379
 
1358
1380
  node.rigor_each_child do |child|
1359
- walk_methods_and_def_nodes(child, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc)
1381
+ walk_methods_and_def_nodes(child, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc,
1382
+ source_path)
1383
+ end
1384
+ end
1385
+
1386
+ # The `Prism::CallNode` leaf actions of {#walk_methods_and_def_nodes}: the `define_method` / `attr_*` macro
1387
+ # recorders, plus the {AnonymousMetaClass} name of a class-creating meta call carrying a block (nil for
1388
+ # every other call), which the caller uses to decide whether the block body needs the anonymous-class-body
1389
+ # descent.
1390
+ def record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
1391
+ record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
1392
+ record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc) if ATTR_MACROS.include?(node.name)
1393
+ AnonymousMetaClass.name_for(node, source_path)
1394
+ end
1395
+
1396
+ # #319 — walks a `Class.new do ... end` / `Module.new do ... end` / `Struct.new(*sym) do ... end` /
1397
+ # `Data.define(*sym) do ... end` block body as the class body it is at runtime, keyed by the call site's
1398
+ # synthetic anonymous `name`; the call's other children (receiver, arguments) keep the enclosing prefix.
1399
+ def walk_anonymous_meta_block(call_node, name, qualified_prefix, in_singleton_class, methods_acc,
1400
+ def_nodes_acc, source_path)
1401
+ call_node.rigor_each_child do |child|
1402
+ if child.equal?(call_node.block)
1403
+ body = call_node.block.body
1404
+ walk_methods_and_def_nodes(body, [name], false, methods_acc, def_nodes_acc, source_path) if body
1405
+ else
1406
+ walk_methods_and_def_nodes(child, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc,
1407
+ source_path)
1408
+ end
1360
1409
  end
1361
1410
  end
1362
1411
 
@@ -1366,21 +1415,38 @@ module Rigor
1366
1415
  # (semantically `class << self`).
1367
1416
  # - `class << Foo` not nested in `class Foo` returns `[Foo]`
1368
1417
  # so methods defined inside register on Foo's singleton.
1418
+ # - `class << Foo = <expr>` (#320, the private-singleton-object
1419
+ # idiom) is the same case: Ruby evaluates the assignment, then
1420
+ # opens the singleton of the resulting object — which is the
1421
+ # object `Foo` now holds — so the body's methods are reachable
1422
+ # as `Foo.<name>` exactly as for a plain constant read.
1369
1423
  # - Any other expression (variable, method call) returns nil
1370
1424
  # so the walker falls through and skips the body.
1371
1425
  def singleton_class_prefix(node, qualified_prefix)
1372
- case node.expression
1373
- when Prism::SelfNode
1426
+ return qualified_prefix if node.expression.is_a?(Prism::SelfNode)
1427
+
1428
+ rendered = singleton_receiver_constant_name(node.expression)
1429
+ return nil unless rendered
1430
+
1431
+ if !qualified_prefix.empty? && qualified_prefix.last == rendered
1374
1432
  qualified_prefix
1375
- when Prism::ConstantReadNode, Prism::ConstantPathNode
1376
- rendered = Source::ConstantPath.qualified_name(node.expression)
1377
- return nil unless rendered
1433
+ else
1434
+ rendered.split("::")
1435
+ end
1436
+ end
1378
1437
 
1379
- if !qualified_prefix.empty? && qualified_prefix.last == rendered
1380
- qualified_prefix
1381
- else
1382
- rendered.split("::")
1383
- end
1438
+ # The constant a `class << X` operand names, or nil when the operand is not constant-shaped. Both the
1439
+ # read spellings (`Foo`, `A::Foo`, `::Foo`) and the two constant-*write* spellings (`Foo = expr`,
1440
+ # `A::Foo = expr`) resolve to the same unqualified rendering the read branch uses, so a body opened on
1441
+ # the assignment and one opened on a later plain read land on the same table key.
1442
+ def singleton_receiver_constant_name(expression)
1443
+ case expression
1444
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1445
+ Source::ConstantPath.qualified_name(expression)
1446
+ when Prism::ConstantWriteNode
1447
+ expression.name.to_s
1448
+ when Prism::ConstantPathWriteNode
1449
+ Source::ConstantPath.qualified_name(expression.target)
1384
1450
  end
1385
1451
  end
1386
1452
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
@@ -1483,6 +1549,24 @@ module Rigor
1483
1549
  class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
1484
1550
  accumulator[class_name] ||= {}
1485
1551
  accumulator[class_name][def_node.name] = def_node
1552
+ record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator)
1553
+ end
1554
+
1555
+ # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
1556
+ # Before the anonymous class had a name the body was walked with an empty prefix, so every such `def`
1557
+ # landed there; that is the same leniency this key already grants a `def` nested in any other DSL block
1558
+ # (see {TOP_LEVEL_DEF_KEY}), and it is what lets an implicit-self call elsewhere in the file resolve
1559
+ # against a method the anonymous module contributes to some other object's `self` — the
1560
+ # `Module.new { def start; end }` mixed into a spawned actor environment, then called from the sibling
1561
+ # `spawn(...) { start }` block. Giving the body a class of its own must not silently retract it: the call
1562
+ # resolves at runtime, and `call.unresolved-toplevel` firing on it would be a new false positive traded
1563
+ # for the ones this change retires. Never clobbers a real top-level `def` of the same name.
1564
+ def record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator)
1565
+ return unless qualified_prefix.length == 1
1566
+ return unless Type::AnonymousClassName.match?(qualified_prefix.first)
1567
+
1568
+ table = (accumulator[TOP_LEVEL_DEF_KEY] ||= {})
1569
+ table[def_node.name] ||= def_node
1486
1570
  end
1487
1571
 
1488
1572
  # Module-singleton call resolution (ADR-57 follow-up) — the SINGLETON-side mirror of `build_discovered_def_nodes`.
@@ -1617,16 +1701,18 @@ module Rigor
1617
1701
  # `class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(...)` and other
1618
1702
  # non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call
1619
1703
  # site against the subclass's lexical nesting — see `ExpressionTyper#resolve_ancestor_class_name`.
1620
- def build_discovered_superclasses(root)
1704
+ def build_discovered_superclasses(root, source_path = nil)
1621
1705
  accumulator = {}
1622
- walk_class_superclasses(root, [], accumulator)
1706
+ walk_class_superclasses(root, [], accumulator, source_path)
1623
1707
  accumulator.freeze
1624
1708
  end
1625
1709
 
1626
- def walk_class_superclasses(node, qualified_prefix, accumulator)
1710
+ def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil)
1627
1711
  return unless node.is_a?(Prism::Node)
1628
1712
 
1629
1713
  case node
1714
+ when Prism::CallNode
1715
+ record_anonymous_meta_superclass(node, accumulator, source_path)
1630
1716
  when Prism::ClassNode
1631
1717
  name = Source::ConstantPath.qualified_name(node.constant_path)
1632
1718
  if name
@@ -1645,10 +1731,27 @@ module Rigor
1645
1731
  end
1646
1732
 
1647
1733
  node.rigor_each_child do |child|
1648
- walk_class_superclasses(child, qualified_prefix, accumulator)
1734
+ walk_class_superclasses(child, qualified_prefix, accumulator, source_path)
1649
1735
  end
1650
1736
  end
1651
1737
 
1738
+ # #319 — `Class.new(Parent) do ... end` names its superclass in the first positional. Recording it under the
1739
+ # call site's anonymous name keeps `Parent`'s surface reachable from the block body (whose `self_type` is now
1740
+ # `Singleton[<anonymous>]`) and from an instance of the resulting class, so giving the anonymous class an
1741
+ # identity does not cost the inheritance the old `Singleton[Parent]` answer carried for free.
1742
+ def record_anonymous_meta_superclass(call_node, accumulator, source_path)
1743
+ return unless AnonymousMetaClass.block_form_receiver(call_node) == :Class
1744
+
1745
+ arg = call_node.arguments&.arguments&.first
1746
+ return if arg.nil?
1747
+
1748
+ superclass = Source::ConstantPath.qualified_name(arg)
1749
+ return if superclass.nil?
1750
+
1751
+ name = AnonymousMetaClass.name_for(call_node, source_path)
1752
+ accumulator[name] = superclass if name
1753
+ end
1754
+
1652
1755
  # ADR-48 — per qualified class name -> ordered `Data.define` member-name list, for both the named-subclass form
1653
1756
  # (`class Point < Data.define(:x, :y)`) and the constant-assigned form (`Point = Data.define(:x, :y)`). Only
1654
1757
  # `Data.define` is recorded: `Struct.new` instances are mutable, so member-value folding would be unsound (the
@@ -2511,7 +2614,7 @@ module Rigor
2511
2614
  # One combined descent yields both the methods existence table and the def-node table; the latter is also
2512
2615
  # consumed by `record_class_sources`, so a def-dense file is walked once here instead of three times (methods +
2513
2616
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
2514
- file_methods, file_def_nodes = build_methods_and_def_nodes(root)
2617
+ file_methods, file_def_nodes = build_methods_and_def_nodes(root, path)
2515
2618
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
2516
2619
  # ADR-46 slice 4 (singleton) — record the singleton-side `"path:line"` sources alongside the nodes,
2517
2620
  # the exact mirror of the instance-side `merge_discovered_defs`, so a class/singleton-method body edit
@@ -2519,7 +2622,7 @@ module Rigor
2519
2622
  # silently degrading to the file's full ancestry closure.
2520
2623
  merge_discovered_defs(acc[:singleton_def_nodes], acc[:singleton_def_sources], path,
2521
2624
  build_discovered_singleton_def_nodes(root))
2522
- superclasses = build_discovered_superclasses(root)
2625
+ superclasses = build_discovered_superclasses(root, path)
2523
2626
  includes = build_discovered_includes(root)
2524
2627
  acc[:superclasses].merge!(superclasses)
2525
2628
  includes.each do |class_name, mods|
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source/constant_path"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Issue #320 — the *private-singleton-object* idiom: a constant holds a plain object whose singleton
8
+ # class carries the methods.
9
+ #
10
+ # class << Merger = Object.new
11
+ # def merge_attributes!(a, b) = a
12
+ # end
13
+ # Merger.merge_attributes!({}, {})
14
+ #
15
+ # {ScopeIndexer} already records such a body under the constant's own name with kind `:singleton` — that
16
+ # keying is correct for both spellings, since `Foo.bar` means "call `bar` on the value of constant `Foo`"
17
+ # whether the value is a class object or an ordinary object. The gap is on the *receiver* side: when the
18
+ # constant names a class or module the read types as `Singleton[Foo]` and dispatch recovers the name from
19
+ # the type, but when it holds an ordinary object the read types as `Nominal[Object]` and the name is
20
+ # gone — so every method in the body is invisible and `call.undefined-method` fires on working code.
21
+ #
22
+ # This module recovers the name from the receiver *syntax* instead, for exactly the receivers whose type
23
+ # has lost it. `Singleton` receivers are excluded so the established class/module path keeps its
24
+ # precedence; a receiver whose constant has no recorded singleton body yields nil, so an undefined method
25
+ # on such a constant (`Merger.nope`) still fires.
26
+ module SingletonObjectConstant
27
+ module_function
28
+
29
+ # The constant name a call's receiver names, when that receiver is a constant reference whose type is
30
+ # not already a `Singleton` carrier. Nil for every other receiver shape.
31
+ def receiver_constant_name(call_node, receiver_type)
32
+ return nil if receiver_type.is_a?(Type::Singleton)
33
+
34
+ receiver = call_node.receiver
35
+ return nil unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
36
+
37
+ Source::ConstantPath.qualified_name(receiver)
38
+ end
39
+
40
+ # True when the project recorded `method_name` on the singleton side of the constant this call's
41
+ # receiver names. The suppression probe for `call.undefined-method`.
42
+ def recorded?(call_node, receiver_type, method_name, scope)
43
+ return false if scope.nil?
44
+
45
+ name = receiver_constant_name(call_node, receiver_type)
46
+ return false if name.nil?
47
+
48
+ scope.discovered_method?(name, method_name, :singleton)
49
+ end
50
+
51
+ # The `Prism::DefNode` for `method_name` in the constant's singleton body, or nil. The inference tier's
52
+ # entry point.
53
+ def def_node_for(call_node, receiver_type, method_name, scope)
54
+ return nil if scope.nil?
55
+
56
+ name = receiver_constant_name(call_node, receiver_type)
57
+ return nil if name.nil?
58
+
59
+ scope.singleton_def_for(name, method_name)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -8,6 +8,7 @@ require_relative "../analysis/fact_store"
8
8
  require_relative "../source/node_walker"
9
9
  require_relative "../source/node_children"
10
10
  require_relative "../source/constant_path"
11
+ require_relative "anonymous_meta_class"
11
12
  require_relative "block_parameter_binder"
12
13
  require_relative "body_fixpoint"
13
14
  require_relative "dynamic_origin"
@@ -20,6 +21,7 @@ require_relative "method_parameter_binder"
20
21
  require_relative "multi_target_binder"
21
22
  require_relative "mutation_widening"
22
23
  require_relative "narrowing"
24
+ require_relative "optimistic_origin"
23
25
 
24
26
  module Rigor
25
27
  module Inference
@@ -259,17 +261,11 @@ module Rigor
259
261
  optimistic_origin_for(value_node, scope_after_rhs)
260
262
  end
261
263
 
262
- # The effective optimistic-nil-free cause of an expression: the mark on its own node, else — for a bare
263
- # local read or a local write used in value position (`if (x = MAP[k])`, where the write has already
264
- # bound the mark) the one propagated onto the binding.
264
+ # The effective optimistic-nil-free cause of an expression. {Inference::OptimisticOrigin.resolve} owns
265
+ # the judgment the mark on the node itself, the binding a bare local / ivar read resolves through, and
266
+ # the predicate-fold derivation of issue #313.
265
267
  def optimistic_origin_for(node, scope)
266
- recorded = scope.optimistic_origins[node]
267
- return recorded if recorded
268
-
269
- case node
270
- when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
271
- when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
272
- end
268
+ Inference::OptimisticOrigin.resolve(node, scope)
273
269
  end
274
270
 
275
271
  # ADR-82 WD1 — the {Inference::DynamicOrigin} cause to propagate onto a local / ivar being bound to `rhs`.
@@ -1827,7 +1823,20 @@ module Rigor
1827
1823
  return unless block.is_a?(Prism::BlockNode)
1828
1824
 
1829
1825
  block_entry = build_block_entry_scope(node, block)
1830
- sub_eval(block, block_entry)
1826
+ # #319 — `Class.new do ... end` and friends evaluate their block as a CLASS BODY (`class_eval`
1827
+ # semantics): `self` is the freshly created class, so a `def` inside defines an instance method on it
1828
+ # and `attr_reader` runs as a class-level macro. Enter the block under the same `self_type` /
1829
+ # class-context a `class Foo ... end` body gets, keyed by the call site's anonymous name — the name
1830
+ # `ScopeIndexer` registered the body's methods under. Without it the body inherits the enclosing
1831
+ # scope, and at file top level that means `Scope#toplevel?` (a nil `self_type`) reports every macro
1832
+ # call in the body as `call.unresolved-toplevel`.
1833
+ #
1834
+ # Outer locals stay visible: unlike a `class` keyword body, the block is a closure.
1835
+ anonymous = AnonymousMetaClass.name_for(node, scope.source_path)
1836
+ return sub_eval(block, block_entry) if anonymous.nil?
1837
+
1838
+ sub_eval(block, block_entry.with_self_type(Type::Combinator.singleton_of(anonymous)),
1839
+ class_context: [ClassFrame.new(name: anonymous, singleton: false)])
1831
1840
  end
1832
1841
 
1833
1842
  # Slice 6 phase C sub-phase 3b/3c. When the call carries a block whose receiving method is NOT proven
@@ -2504,7 +2513,11 @@ module Rigor
2504
2513
  def build_block_entry_scope(call_node, block_node)
2505
2514
  expected = expected_block_param_types_for(call_node)
2506
2515
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
2507
- scope_with_params = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
2516
+ # Issue #316 every block body enters with `self` unmodelled (`Scope#entering_opaque_block`); the
2517
+ # yielding method, not the lexical context, decides what `self` is, and Rigor does not track it.
2518
+ scope_with_params = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
2519
+ acc.with_local(name, type)
2520
+ end
2508
2521
  block_local_names(block_node).reduce(scope_with_params) do |acc, name|
2509
2522
  acc.with_local(name, Type::Combinator.constant_of(nil))
2510
2523
  end
@@ -710,6 +710,30 @@ module Rigor
710
710
  manifest.protocol_contracts
711
711
  end
712
712
 
713
+ # ADR-103 WD6 / WD10 (#387) — the plugin's effect contributions. Each defaults to the manifest field
714
+ # and each MAY be overridden to fold in per-project facts, exactly as {#protocol_contracts} may.
715
+ #
716
+ # {#effect_attributions} is the one that needs it: a Rails app's ActiveJob transport is whatever
717
+ # `config.active_job.queue_adapter` names, so rigor-activejob answers `io.db.write` for Solid Queue
718
+ # and `io.net` for Sidekiq by reading the project's own configuration. That read is a plugin-side I/O
719
+ # boundary read and must not happen on a run with effects off — {Registry#effect_contributions} is
720
+ # lazy for exactly that reason, and nothing asks unless the project has an `effects:` block.
721
+ def effect_attributions
722
+ manifest.effect_attributions
723
+ end
724
+
725
+ def effect_edges
726
+ manifest.effect_edges
727
+ end
728
+
729
+ def effect_labels
730
+ manifest.effect_labels
731
+ end
732
+
733
+ def effect_entry_points
734
+ manifest.effect_entry_points
735
+ end
736
+
713
737
  # ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}. Memoised so the boundary's accumulated `FileEntry`
714
738
  # rows persist across producer invocations within the same plugin instance and feed cache invalidation
715
739
  # via `cache_for`.