rigortype 0.1.19 → 0.2.0

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+ require_relative "singleton_folding"
5
+ require_relative "member_shape_projection"
6
+
7
+ module Rigor
8
+ module Inference
9
+ module MethodDispatcher
10
+ # ADR-48 Struct follow-up — `Struct.new` value folding, the mutable
11
+ # sibling of {DataFolding}. Same fully-decidable-shape, degrade-on-any-
12
+ # uncertainty discipline, with one extra premise the immutable `Data`
13
+ # path does not need: **mutation soundness.**
14
+ #
15
+ # A `Struct` instance is mutable (`s.x = v`, `s[:x] = v`, escape), so a
16
+ # member map bound to a variable can be invalidated by a later write.
17
+ # This slice (ADR-48 slices 1+2 in the sound *transient* form) folds a
18
+ # member read ONLY off a **fresh** instance — the transient receiver of
19
+ # a `.new(...).x` / `.with(...).x` chain, which provably cannot have
20
+ # been mutated between materialisation and the read. A member read off
21
+ # a *stored* binding degrades to `Dynamic[top]` rather than fold a
22
+ # possibly-stale value. Promoting the fold to mutation-free bound
23
+ # locals is the deferred slice 3 (relax the fresh-receiver gate to a
24
+ # fold-safe-local scan); precise mutated-member re-typing is slice 4.
25
+ #
26
+ # Responsibilities:
27
+ #
28
+ # 1. `Struct.new(:x, :y [, keyword_init: <bool>])` on a
29
+ # `Singleton[Struct]` receiver, literal-Symbol members, NO block ->
30
+ # `StructClass{members:, keyword_init:}`. A block / non-literal
31
+ # members defer.
32
+ # 2. `.new` / `.[]` on a `StructClass` (or a `Singleton[Point]` with a
33
+ # recorded struct layout) -> a `StructInstance`, the member map built
34
+ # from the call's arguments (positional or keyword per the class's
35
+ # `keyword_init`). A form / arity mismatch degrades to `Dynamic[top]`
36
+ # rather than a wrong member map. `.members` on the class folds.
37
+ # 3. member reads + `[]` / `to_h` / `deconstruct` / `deconstruct_keys`
38
+ # / `members` / `with` on a *fresh* `StructInstance` -> the precise
39
+ # projected type; member *setters* (`s.x = v`) return the assigned
40
+ # value type. Unhandled / stored-receiver calls return nil so the
41
+ # pipeline projects the instance to its nominal through RbsDispatch.
42
+ #
43
+ # See docs/adr/48-data-struct-value-folding.md § "Struct follow-up".
44
+ module StructFolding
45
+ module_function
46
+
47
+ # The `[]` / `to_h` / `deconstruct` / `members` / `with` projections
48
+ # and the reader-redefinition guard are shared with {DataFolding}.
49
+ extend MemberShapeProjection
50
+
51
+ # @return [Rigor::Type, nil] the folded result, or nil to defer.
52
+ def try_dispatch(context)
53
+ receiver = context.receiver
54
+
55
+ return fold_struct_new(context) if SingletonFolding.receiver?(receiver, "Struct")
56
+
57
+ case receiver
58
+ when Type::StructClass
59
+ dispatch_struct_class(receiver, context)
60
+ when Type::StructInstance
61
+ fold_instance(receiver, context)
62
+ when Type::Singleton
63
+ fold_named_new(receiver, context)
64
+ end
65
+ end
66
+
67
+ # A `Struct.new`-defined class assigned to a constant (or a
68
+ # `class Point < Struct.new(...)` subclass) is canonicalised by the
69
+ # engine to `Singleton[Point]`, not a `StructClass` — so its member
70
+ # layout is read from the project side-table the scope indexer built
71
+ # (`Scope#struct_member_layout`) rather than from the receiver
72
+ # carrier.
73
+ def fold_named_new(singleton, context)
74
+ scope = context.scope
75
+ return nil if scope.nil?
76
+
77
+ layout = scope.struct_member_layout(singleton.class_name)
78
+ return nil if layout.nil?
79
+
80
+ materialize_instance(layout[:members], layout[:keyword_init], singleton.class_name, context)
81
+ end
82
+
83
+ # --- 1. Struct.new(:x, :y) --------------------------------------
84
+
85
+ def fold_struct_new(context)
86
+ return nil unless context.method_name == :new
87
+ # Block-form (`Struct.new(:x) do ... end`) defers — the named
88
+ # constant/subclass forms still fold via the layout side-table.
89
+ return nil unless context.block_type.nil?
90
+
91
+ parsed = parse_struct_new_args(context.args)
92
+ return nil if parsed.nil?
93
+
94
+ Type::Combinator.struct_class_of(members: parsed[:members], keyword_init: parsed[:keyword_init])
95
+ end
96
+
97
+ # Parses `Struct.new`'s arguments into `{ members:, keyword_init: }`,
98
+ # or nil when any argument is non-conforming (a dynamic member name,
99
+ # an unexpected trailing keyword). Handles the optional leading String
100
+ # class name and the trailing `keyword_init:` option hash.
101
+ def parse_struct_new_args(args)
102
+ rest = args.dup
103
+ keyword_init = false
104
+
105
+ if rest.last.is_a?(Type::HashShape)
106
+ options = rest.pop
107
+ return nil unless struct_options_hash?(options)
108
+
109
+ value = options.pairs[:keyword_init]
110
+ keyword_init = value.is_a?(Type::Constant) && value.value == true
111
+ end
112
+
113
+ # Optional leading String name: `Struct.new("Point", :x, :y)`.
114
+ rest = rest[1..] if rest.first.is_a?(Type::Constant) && rest.first.value.is_a?(String)
115
+
116
+ members = []
117
+ rest.each do |arg|
118
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Symbol)
119
+
120
+ members << arg.value
121
+ end
122
+ return nil if members.empty?
123
+ return nil unless members.uniq.size == members.size
124
+
125
+ { members: members, keyword_init: keyword_init }
126
+ end
127
+
128
+ # A trailing hash is the `Struct.new` options hash only when it is a
129
+ # closed shape whose sole key is `:keyword_init`. Any other key means
130
+ # the call is not a recognisable member-list definition -> defer.
131
+ def struct_options_hash?(shape)
132
+ shape.closed? && shape.optional_keys.empty? &&
133
+ shape.pairs.keys.all? { |key| key == :keyword_init }
134
+ end
135
+
136
+ # --- 2. Point.new(...) / Point[...] / Point.members -------------
137
+
138
+ def dispatch_struct_class(struct_class, context)
139
+ case context.method_name
140
+ when :new, :[]
141
+ materialize_instance(struct_class.members, struct_class.keyword_init,
142
+ struct_class.class_name, context)
143
+ when :members
144
+ Type::Combinator.tuple_of(*struct_class.members.map { |name| Type::Combinator.constant_of(name) })
145
+ end
146
+ end
147
+
148
+ def materialize_instance(members, keyword_init, class_name, context)
149
+ return nil unless %i[new []].include?(context.method_name)
150
+
151
+ map = member_map_for_new(members, keyword_init, context)
152
+ return degraded_instance if map.nil?
153
+
154
+ Type::Combinator.struct_instance_of(members: map, class_name: class_name)
155
+ end
156
+
157
+ # Builds the member -> type map honouring the class's `keyword_init`
158
+ # flag: a `keyword_init: true` struct accepts only the keyword form,
159
+ # a positional struct only the positional form. The mismatched form
160
+ # is a different runtime shape, so it degrades rather than fold.
161
+ def member_map_for_new(members, keyword_init, context)
162
+ if keyword_new?(context)
163
+ keyword_init ? keyword_member_map(members, context.args) : nil
164
+ else
165
+ keyword_init ? nil : positional_member_map(members, context.args)
166
+ end
167
+ end
168
+
169
+ # `Point.new(x: 1)` arrives as a single trailing `HashShape` whose
170
+ # call node is a `KeywordHashNode`; distinguishing it from a
171
+ # positional hash needs the call node (both type to a `HashShape`).
172
+ def keyword_new?(context)
173
+ node = context.call_node
174
+ return false if node.nil?
175
+
176
+ arguments = node.arguments&.arguments
177
+ return false if arguments.nil? || arguments.empty?
178
+
179
+ arguments.last.is_a?(Prism::KeywordHashNode)
180
+ end
181
+
182
+ # `Struct.new(:a, :b).new(v)` is legal — trailing members default to
183
+ # `nil` — so fewer positionals than members pad with `Constant[nil]`;
184
+ # more positionals than members is an ArgumentError -> degrade.
185
+ def positional_member_map(members, args)
186
+ return nil if args.size > members.size
187
+
188
+ members.each_with_index.to_h do |name, index|
189
+ [name, index < args.size ? args[index] : Type::Combinator.constant_of(nil)]
190
+ end
191
+ end
192
+
193
+ # `Struct.new(:a, :b).new(a: 1)` is legal — omitted members default
194
+ # to `nil` — so a keyword subset pads the rest; an unknown key is an
195
+ # ArgumentError -> degrade.
196
+ def keyword_member_map(members, args)
197
+ return nil unless args.size == 1
198
+
199
+ shape = args.first
200
+ return nil unless shape.is_a?(Type::HashShape) && shape.closed?
201
+ return nil unless shape.optional_keys.empty?
202
+ return nil unless shape.pairs.keys.all? { |key| members.include?(key) }
203
+
204
+ members.to_h { |name| [name, shape.pairs[name] || Type::Combinator.constant_of(nil)] }
205
+ end
206
+
207
+ # A `.new` whose arguments cannot soundly populate the member map
208
+ # degrades to `Dynamic[top]` (today's behaviour for `Struct.new(...)`
209
+ # instances), never a wrong map.
210
+ def degraded_instance
211
+ Type::Combinator.untyped
212
+ end
213
+
214
+ # --- 3. inst.x / inst.x = v / inst[...] / inst.to_h / ... -------
215
+
216
+ def fold_instance(instance, context)
217
+ method_name = context.method_name
218
+ args = context.args
219
+ members = instance.members
220
+
221
+ # Member setter `s.x = v`: returns the assigned value type. Sound
222
+ # regardless of mutation state (it models the setter's own return),
223
+ # and avoids a fall-through undefined-method on a writer the
224
+ # existence table does not register.
225
+ setter = member_setter_target(method_name, members)
226
+ return args.first if setter && args.size == 1
227
+
228
+ foldable = foldable_receiver?(context)
229
+
230
+ if members.key?(method_name) && args.empty? && !reader_overridden?(instance, method_name, context.scope)
231
+ # A stored receiver folds only when the bound local is proven
232
+ # fold-safe (ADR-48 slice 3 — never mutated / aliased / escaped);
233
+ # otherwise the member value may be stale, so it degrades to
234
+ # `Dynamic[top]` (not nil -> no undefined-method fall-through).
235
+ return foldable ? members.fetch(method_name) : Type::Combinator.untyped
236
+ end
237
+
238
+ # Projections fold only off a foldable (fresh, or proven fold-safe)
239
+ # instance; off any other stored binding they defer to Struct's RBS
240
+ # (`to_h` / `[]` / `members` / `deconstruct*` all exist on `Struct`),
241
+ # which is sound and non-regressive.
242
+ return nil unless foldable
243
+
244
+ fold_fresh_projection(instance, method_name, args)
245
+ end
246
+
247
+ def fold_fresh_projection(instance, method_name, args)
248
+ case method_name
249
+ when :[] then instance_index(instance, args)
250
+ when :to_h, :to_hash then instance_to_h(instance)
251
+ when :deconstruct then instance_deconstruct(instance)
252
+ when :deconstruct_keys then instance_deconstruct_keys(instance, args)
253
+ when :members then instance_members(instance)
254
+ when :with
255
+ instance_with(instance, args) do |members, class_name|
256
+ Type::Combinator.struct_instance_of(members: members, class_name: class_name)
257
+ end
258
+ end
259
+ end
260
+
261
+ # The member name a `:<member>=` setter targets, or nil. Comparison
262
+ # operators (`==`, `>=`, ...) end with `=` too but never strip to a
263
+ # member symbol, so they fall through to the normal dispatch path.
264
+ def member_setter_target(method_name, members)
265
+ name = method_name.to_s
266
+ return nil unless name.end_with?("=")
267
+
268
+ base = name[0..-2].to_sym
269
+ members.key?(base) ? base : nil
270
+ end
271
+
272
+ # A member read is foldable when the receiver is either FRESH (the
273
+ # transient result of a `.new(...)`/`.with(...)` chain, which cannot
274
+ # have been mutated between materialisation and this read) or a
275
+ # FOLD-SAFE stored local (ADR-48 slice 3 — `StructFoldSafety` proved
276
+ # the binding is never mutated / aliased / escaped in its scope).
277
+ def foldable_receiver?(context)
278
+ fresh_receiver?(context) || fold_safe_local_receiver?(context)
279
+ end
280
+
281
+ # A fresh receiver is the transient result of a chained call
282
+ # (`Point.new(1, 2).x`, `inst.with(x: 9).y`).
283
+ def fresh_receiver?(context)
284
+ node = context.call_node
285
+ return false if node.nil?
286
+
287
+ node.receiver.is_a?(Prism::CallNode)
288
+ end
289
+
290
+ # A fold-safe stored receiver is a local-variable read whose name the
291
+ # body's fold-safe set (on the scope) marks as never mutated.
292
+ def fold_safe_local_receiver?(context)
293
+ node = context.call_node
294
+ receiver = node&.receiver
295
+ scope = context.scope
296
+ return false unless receiver.is_a?(Prism::LocalVariableReadNode) && scope
297
+
298
+ scope.struct_fold_safe?(receiver.name)
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
@@ -12,6 +12,7 @@ require_relative "method_dispatcher/constant_folding"
12
12
  require_relative "method_dispatcher/literal_string_folding"
13
13
  require_relative "method_dispatcher/shape_dispatch"
14
14
  require_relative "method_dispatcher/data_folding"
15
+ require_relative "method_dispatcher/struct_folding"
15
16
  require_relative "method_dispatcher/rbs_dispatch"
16
17
  require_relative "method_dispatcher/iterator_dispatch"
17
18
  require_relative "method_dispatcher/reduce_folding"
@@ -38,28 +39,15 @@ module Rigor
38
39
  # callers (today only `ExpressionTyper`) own the fail-soft fallback
39
40
  # and decide whether to record a `FallbackTracer` event.
40
41
  #
41
- # Tiers (in order):
42
+ # Tier order is documented inline in `resolve`; the precise-tier
43
+ # group is built from `PRECISE_TIERS_HEAD`, `STDLIB_SINGLETON_FOLDERS`,
44
+ # and `PRECISE_TIERS_TAIL`. `ShapeDispatch` runs above {RbsDispatch}
45
+ # so a precise per-position/per-key answer wins over the projected
46
+ # `Array#[]`/`Hash#fetch` RBS answer.
42
47
  #
43
- # 1. {ConstantFolding}: executes the Ruby operation directly when
44
- # the receiver and argument are `Constant` carriers and the
45
- # method is on the curated whitelist. Slice 2.
46
- # 2. {ShapeDispatch}: returns the precise element/value type for a
47
- # curated catalogue of `Tuple`/`HashShape` element-access
48
- # methods (`first`, `last`, `[]` with a static integer/key,
49
- # `fetch`, `dig`, `size`/`length`/`count`). Slice 5 phase 2.
50
- # 3. {RbsDispatch}: looks up the receiver's class in the RBS
51
- # environment carried by the scope and translates the method's
52
- # return type into a Rigor::Type. Slice 4.
53
- #
54
- # `ShapeDispatch` deliberately runs *above* {RbsDispatch} so the
55
- # precise per-position/per-key answer wins over the projected
56
- # `Array#[]`/`Hash#fetch` answer; it falls through (`nil`) when
57
- # the call cannot be proved against the static shape, in which
58
- # case the projection answer from {RbsDispatch} applies.
59
- #
60
- # The dispatcher's public signature reserves space for `block_type:`
61
- # and ADR-2 plugin extensions (later slices), so call sites added
62
- # now do not have to be rewritten when those tiers arrive.
48
+ # The `block_type:` and plugin contribution (`dynamic_return`) tiers
49
+ # landed in Slice 6 phase C and v0.1.1 Track 2 respectively; all
50
+ # call sites pass through `dispatch`/`resolve` unchanged.
63
51
  module MethodDispatcher # rubocop:disable Metrics/ModuleLength
64
52
  module_function
65
53
 
@@ -326,19 +314,6 @@ module Rigor
326
314
  Type::Combinator.untyped
327
315
  end
328
316
 
329
- # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
330
- # slice 7; ADR-52 WD3 — consults each loaded plugin's gated
331
- # `dynamic_return` rules, wraps the contributed types as
332
- # `FlowContribution` bundles, merges them through
333
- # `FlowContribution::Merger`, and returns the merged
334
- # `return_type` slot (or nil when no plugin contributed a
335
- # return type).
336
- #
337
- # Plugins whose hook raises have their contribution
338
- # silently dropped for this call so the dispatch chain
339
- # keeps moving — the run-level diagnostic envelope (per
340
- # ADR-2 § "Plugin Trust and I/O Policy") is owned by
341
- # `Analysis::Runner#plugin_emitted_diagnostics`.
342
317
  # ADR-20 slice 3 — looks up the receiver / method pair
343
318
  # in {Rigor::Builtins::HktBuiltins::METHOD_RETURN_OVERRIDES}
344
319
  # and returns the reduced HKT type. Only fires when the
@@ -413,6 +388,19 @@ module Rigor
413
388
  end
414
389
  end
415
390
 
391
+ # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
392
+ # slice 7; ADR-52 WD3 — consults each loaded plugin's gated
393
+ # `dynamic_return` rules, wraps the contributed types as
394
+ # `FlowContribution` bundles, merges them through
395
+ # `FlowContribution::Merger`, and returns the merged
396
+ # `return_type` slot (or nil when no plugin contributed a
397
+ # return type).
398
+ #
399
+ # Plugins whose hook raises have their contribution
400
+ # silently dropped for this call so the dispatch chain
401
+ # keeps moving — the run-level diagnostic envelope (per
402
+ # ADR-2 § "Plugin Trust and I/O Policy") is owned by
403
+ # `Analysis::Runner#plugin_emitted_diagnostics`.
416
404
  def try_plugin_contribution(call_node, scope, receiver_type)
417
405
  return nil if call_node.nil? || scope.nil?
418
406
 
@@ -425,16 +413,6 @@ module Rigor
425
413
  FlowContribution::Merger.merge(contributions).return_type
426
414
  end
427
415
 
428
- # ADR-10 slice 2b-ii. Consults the per-run
429
- # `Analysis::DependencySourceInference::Index` carried by
430
- # the environment for `(class_name, method_name)`
431
- # observations harvested from opt-in gems' `roots:`. On a
432
- # hit, returns `Combinator.untyped` so the call site
433
- # carries the `Dynamic[top]` provenance (per ADR-10's
434
- # "Inference contract": gem-source-inferred shapes never
435
- # publish as ground-truth `T`). Returns `nil` when the
436
- # environment carries no index, the index has no entry, or
437
- # the receiver has no nominal class to look up.
438
416
  # ADR-16 synthetic-method tier. Slice 2b shipped the floor —
439
417
  # a match short-circuits at the right precedence (above
440
418
  # dep-source / discovered / user-class-fallback; below RBS)
@@ -556,6 +534,16 @@ module Rigor
556
534
  Type::Combinator.dynamic(entry.return_type)
557
535
  end
558
536
 
537
+ # ADR-10 slice 2b-ii. Consults the per-run
538
+ # `Analysis::DependencySourceInference::Index` carried by
539
+ # the environment for `(class_name, method_name)`
540
+ # observations harvested from opt-in gems' `roots:`. On a
541
+ # hit, returns `Combinator.untyped` so the call site
542
+ # carries the `Dynamic[top]` provenance (per ADR-10's
543
+ # "Inference contract": gem-source-inferred shapes never
544
+ # publish as ground-truth `T`). Returns `nil` when the
545
+ # environment carries no index, the index has no entry, or
546
+ # the receiver has no nominal class to look up.
559
547
  def try_dependency_source(receiver_type, method_name, environment)
560
548
  index = environment&.dependency_source_index
561
549
  return nil if index.nil? || index.empty?
@@ -759,10 +747,6 @@ module Rigor
759
747
  # below this chain and is invoked by the outer `dispatch`
760
748
  # method.
761
749
  #
762
- # `BlockFolding` runs last among the precision tiers because
763
- # its rules apply only to block-taking calls, so the cheaper
764
- # arity-based fold tiers above it filter out the common
765
- # cases first. When `block_type` is nil the tier is a no-op.
766
750
  # The precise-tier folders, consulted in order via the uniform
767
751
  # `_DispatchTier` interface (`try_dispatch(CallContext) -> Type?`).
768
752
  # Order is significant: ConstantFolding's exact-value folds win
@@ -815,6 +799,14 @@ module Rigor
815
799
  data_result = DataFolding.try_dispatch(context)
816
800
  return data_result if data_result
817
801
 
802
+ # ADR-48 Struct follow-up — runs in the same band and for the same
803
+ # reason as DataFolding: `meta_new` would otherwise intercept every
804
+ # `Singleton[*].new` (the old `struct_new_lift` produced a bare
805
+ # `Singleton[Struct]`), masking a Struct class's precise instance.
806
+ # Only fires on Struct receivers, so it never shadows meta's lifts.
807
+ struct_result = StructFolding.try_dispatch(context)
808
+ return struct_result if struct_result
809
+
818
810
  meta_result = try_meta_introspection(context.receiver, context.method_name, context.args)
819
811
  return meta_result if meta_result
820
812
 
@@ -67,18 +67,12 @@ module Rigor
67
67
  # ...; @warning_issued = true`). Requires tracking the
68
68
  # first-write position; flow-sensitive but orthogonal.
69
69
  # - **Local-variable mutation inside a block body** (e.g.
70
- # `arr = []; xs.each { |x| arr << x }`). Block bodies
71
- # create a child scope; the existing closure-escape model
72
- # only widens outer locals when the block ESCAPES the
73
- # call. An in-place mutator inside a non-escaping block on
74
- # an outer LOCAL does not yet flow back. **Ivar mutations
75
- # inside a block ARE handled** (ivars live in the
76
- # method-body scope, not the block-local scope) — the
77
- # widening fires from inside the block and the new ivar
78
- # binding is visible to the outer scope.
70
+ # `arr = []; xs.each { |x| arr << x }`) landed as
71
+ # ADR-56 slice A (`widen_after_block`). **Ivar mutations
72
+ # inside a block ARE also handled** (ivars live in the
73
+ # method-body scope, not the block-local scope).
79
74
  #
80
- # Those four are documented as "G2 remaining" in
81
- # `docs/CURRENT_WORK.md` and are intentionally deferred.
75
+ # The remaining three items above are demand-gated; see ADR-56.
82
76
  module MutationWidening
83
77
  # Array mutators that change either the size or the element
84
78
  # set of a literal-shape carrier (Tuple). Receiver-mutating
@@ -11,7 +11,8 @@ require_relative "../builtins/regex_refinement"
11
11
 
12
12
  module Rigor
13
13
  module Inference
14
- # Slice 6 phase 1 minimal narrowing surface.
14
+ # Control-flow predicate narrowing and type-lattice narrowing
15
+ # primitives.
15
16
  #
16
17
  # `Rigor::Inference::Narrowing` answers two related questions:
17
18
  #
@@ -19,22 +20,19 @@ module Rigor
19
20
  # truthy fragment, its falsey fragment, its nil fragment, and its
20
21
  # non-nil fragment? These primitives understand the value-lattice
21
22
  # algebra (`Constant`, `Nominal`, `Singleton`, `Tuple`, `HashShape`,
22
- # `Union`) and stay conservative on `Top` and `Dynamic[T]`, where
23
- # the analyzer cannot prove the boundary either way.
23
+ # `Union`) and stay conservative on `Top` and `Dynamic[T]`.
24
24
  # 2. Predicate-level narrowing: given a Prism predicate node and an
25
25
  # entry scope, what are the truthy-edge scope and the falsey-edge
26
- # scope after the predicate has been evaluated? The phase 1
27
- # catalogue covers truthiness on `LocalVariableReadNode`, `nil?`
28
- # against a local, the unary `!` inverter, parenthesised
29
- # predicates, and short-circuiting `&&` / `||` chains.
26
+ # scope? The catalogue covers truthiness, `nil?`, `!`, `&&`/`||`,
27
+ # class-membership (`is_a?`, `kind_of?`, `instance_of?`), trusted
28
+ # equality/inequality against static literals, `case`/`when`,
29
+ # regex match globals, string predicates (`start_with?` etc.),
30
+ # key-presence, array emptiness, numeric comparison, and
31
+ # `respond_to?`.
30
32
  #
31
- # Predicate-level narrowing is consumed by
32
- # `Rigor::Inference::StatementEvaluator` to refine the `then` and
33
- # `else` scopes of `IfNode`/`UnlessNode`. Phase 1 narrows local
34
- # bindings on truthiness and `nil?`; phase 2 extends the catalogue
35
- # with class-membership predicates (`is_a?`, `kind_of?`,
36
- # `instance_of?`) and trusted equality/inequality checks against
37
- # static literals.
33
+ # Consumed by `Rigor::Inference::StatementEvaluator` to refine
34
+ # `then`/`else` scopes of `IfNode`/`UnlessNode` and
35
+ # `case`/`when` branches.
38
36
  #
39
37
  # The module is pure: every public function returns fresh values and
40
38
  # MUST NOT mutate its inputs. Unrecognised predicate shapes degrade
@@ -43,8 +41,8 @@ module Rigor
43
41
  # `[truthy_scope, falsey_scope]` pair (the entry scope twice when no
44
42
  # rule matches).
45
43
  #
46
- # See docs/internal-spec/inference-engine.md (Slice 6 — Narrowing)
47
- # and docs/type-specification/control-flow-analysis.md for the
44
+ # See docs/internal-spec/inference-engine.md (Narrowing) and
45
+ # docs/type-specification/control-flow-analysis.md for the
48
46
  # binding contract.
49
47
  # rubocop:disable Metrics/ModuleLength
50
48
  module Narrowing