rigortype 0.1.18 → 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.
- checksums.yaml +4 -4
- data/README.md +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +756 -132
- data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
- data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/fact_store.rb +5 -4
- data/lib/rigor/analysis/rule_catalog.rb +153 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
- data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
- data/lib/rigor/analysis/runner.rb +75 -27
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
- data/lib/rigor/analysis/worker_session.rb +31 -25
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +5 -3
- data/lib/rigor/cli/annotate_command.rb +122 -16
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/check_command.rb +118 -16
- data/lib/rigor/cli/coverage_command.rb +148 -16
- data/lib/rigor/cli/coverage_scan.rb +57 -0
- data/lib/rigor/cli/explain_command.rb +2 -0
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
- data/lib/rigor/cli/mutation_protection_report.rb +73 -0
- data/lib/rigor/cli/options.rb +9 -0
- data/lib/rigor/cli/plugins_command.rb +4 -5
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/protection_renderer.rb +63 -0
- data/lib/rigor/cli/protection_report.rb +68 -0
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -1
- data/lib/rigor/cli/trace_command.rb +2 -1
- data/lib/rigor/cli/triage_command.rb +8 -4
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_command.rb +2 -1
- data/lib/rigor/cli.rb +12 -3
- data/lib/rigor/configuration/dependencies.rb +2 -4
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +100 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
- data/lib/rigor/environment/class_registry.rb +4 -3
- data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
- data/lib/rigor/environment/lockfile_resolver.rb +1 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
- data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
- data/lib/rigor/environment/rbs_loader.rb +74 -5
- data/lib/rigor/environment.rb +17 -7
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution.rb +3 -5
- data/lib/rigor/inference/acceptance.rb +17 -9
- data/lib/rigor/inference/block_parameter_binder.rb +2 -3
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +1072 -71
- data/lib/rigor/inference/hkt_body.rb +8 -11
- data/lib/rigor/inference/hkt_body_parser.rb +10 -12
- data/lib/rigor/inference/hkt_registry.rb +10 -11
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
- data/lib/rigor/inference/method_dispatcher.rb +112 -49
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +147 -11
- data/lib/rigor/inference/narrowing.rb +284 -53
- data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
- data/lib/rigor/inference/project_patched_methods.rb +4 -7
- data/lib/rigor/inference/project_patched_scanner.rb +2 -13
- data/lib/rigor/inference/protection_scanner.rb +86 -0
- data/lib/rigor/inference/scope_indexer.rb +821 -76
- data/lib/rigor/inference/statement_evaluator.rb +1179 -102
- data/lib/rigor/inference/struct_fold_safety.rb +181 -0
- data/lib/rigor/inference/synthetic_method.rb +7 -7
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/language_server/completion_provider.rb +6 -12
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
- data/lib/rigor/language_server/hover_provider.rb +2 -3
- data/lib/rigor/language_server/hover_renderer.rb +2 -11
- data/lib/rigor/language_server/server.rb +9 -17
- data/lib/rigor/language_server.rb +4 -5
- data/lib/rigor/plugin/base.rb +245 -87
- data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
- data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
- data/lib/rigor/plugin/macro.rb +6 -8
- data/lib/rigor/plugin/manifest.rb +49 -90
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +18 -18
- data/lib/rigor/plugin/type_node_resolver.rb +6 -8
- data/lib/rigor/protection/mutation_scanner.rb +120 -0
- data/lib/rigor/protection/mutator.rb +246 -0
- data/lib/rigor/rbs_extended.rb +24 -36
- data/lib/rigor/reflection.rb +4 -7
- data/lib/rigor/scope/discovery_index.rb +16 -2
- data/lib/rigor/scope.rb +185 -16
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observed_call.rb +3 -3
- data/lib/rigor/sig_gen/writer.rb +40 -2
- data/lib/rigor/source/constant_path.rb +62 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/bound_method.rb +2 -11
- data/lib/rigor/type/combinator.rb +45 -3
- data/lib/rigor/type/constant.rb +2 -11
- data/lib/rigor/type/data_class.rb +2 -11
- data/lib/rigor/type/data_instance.rb +2 -11
- data/lib/rigor/type/hash_shape.rb +2 -11
- data/lib/rigor/type/integer_range.rb +2 -11
- data/lib/rigor/type/intersection.rb +2 -11
- data/lib/rigor/type/nominal.rb +2 -11
- data/lib/rigor/type/plain_lattice.rb +37 -0
- data/lib/rigor/type/refined.rb +72 -13
- data/lib/rigor/type/singleton.rb +2 -11
- data/lib/rigor/type/struct_class.rb +75 -0
- data/lib/rigor/type/struct_instance.rb +93 -0
- data/lib/rigor/type/tuple.rb +5 -15
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +18 -1
- data/sig/rigor/type.rbs +37 -1
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +25 -2
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -81,6 +81,7 @@ module Rigor
|
|
|
81
81
|
sum: :tuple_sum,
|
|
82
82
|
min: :tuple_min,
|
|
83
83
|
max: :tuple_max,
|
|
84
|
+
minmax: :tuple_minmax_pair,
|
|
84
85
|
sort: :tuple_sort,
|
|
85
86
|
reverse: :tuple_reverse,
|
|
86
87
|
to_a: :tuple_to_a,
|
|
@@ -98,9 +99,16 @@ module Rigor
|
|
|
98
99
|
uniq: :tuple_uniq,
|
|
99
100
|
index: :tuple_find_index,
|
|
100
101
|
find_index: :tuple_find_index,
|
|
101
|
-
rindex: :tuple_rindex
|
|
102
|
+
rindex: :tuple_rindex,
|
|
103
|
+
flatten: :tuple_flatten,
|
|
104
|
+
join: :tuple_join
|
|
102
105
|
}.freeze
|
|
103
106
|
|
|
107
|
+
# Byte cap on a folded `tuple.join` result — a huge tuple times a
|
|
108
|
+
# long separator must not materialise an unbounded `Constant`.
|
|
109
|
+
TUPLE_JOIN_BYTE_LIMIT = 4096
|
|
110
|
+
private_constant :TUPLE_JOIN_BYTE_LIMIT
|
|
111
|
+
|
|
104
112
|
HASH_SHAPE_HANDLERS = {
|
|
105
113
|
size: :hash_size,
|
|
106
114
|
length: :hash_size,
|
|
@@ -228,15 +236,8 @@ module Rigor
|
|
|
228
236
|
end
|
|
229
237
|
|
|
230
238
|
def dispatch_nominal_size(nominal, method_name, args)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return string_binary if string_binary
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
if nominal.class_name == "Integer" && args.size == 1
|
|
237
|
-
integer_binary = dispatch_integer_binary_from_arg(method_name, args.first)
|
|
238
|
-
return integer_binary if integer_binary
|
|
239
|
-
end
|
|
239
|
+
projection = nominal_projection(nominal, method_name, args)
|
|
240
|
+
return projection if projection
|
|
240
241
|
|
|
241
242
|
return nil unless args.empty?
|
|
242
243
|
|
|
@@ -246,6 +247,106 @@ module Rigor
|
|
|
246
247
|
Type::Combinator.non_negative_int
|
|
247
248
|
end
|
|
248
249
|
|
|
250
|
+
# Arg-/method-driven precision projections for a `Nominal`
|
|
251
|
+
# receiver, consulted ahead of the no-arg size tier. Each
|
|
252
|
+
# branch gates on the class name first so unrelated nominals
|
|
253
|
+
# skip the work. Returns nil when no projection applies.
|
|
254
|
+
def nominal_projection(nominal, method_name, args)
|
|
255
|
+
case nominal.class_name
|
|
256
|
+
when "String"
|
|
257
|
+
dispatch_string_binary_from_arg(method_name, args.first) if args.size == 1
|
|
258
|
+
when "Integer"
|
|
259
|
+
dispatch_integer_binary_from_arg(method_name, args.first) if args.size == 1
|
|
260
|
+
when "Array"
|
|
261
|
+
case method_name
|
|
262
|
+
when :flatten then array_nominal_flatten(nominal, args)
|
|
263
|
+
when :compact then array_nominal_compact(nominal, args)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# `Array[T]#compact` — `compact` removes every `nil` element,
|
|
269
|
+
# so the result element type is `T` with its `nil` constituent
|
|
270
|
+
# stripped (`Array[Node?]#compact` → `Array[Node]`). Mirrors
|
|
271
|
+
# the `Tuple#compact` constant fold for the generic element
|
|
272
|
+
# case. Declines when the receiver carries no type argument
|
|
273
|
+
# (the RBS `Array[untyped]` answer is already maximal) or when
|
|
274
|
+
# `T` has no `nil` constituent to remove (the result equals the
|
|
275
|
+
# receiver, so the RBS tier's answer is already precise).
|
|
276
|
+
def array_nominal_compact(nominal, args)
|
|
277
|
+
return nil unless args.empty?
|
|
278
|
+
|
|
279
|
+
element = nominal.type_args&.first
|
|
280
|
+
return nil if element.nil?
|
|
281
|
+
|
|
282
|
+
stripped = strip_nil_constituent(element)
|
|
283
|
+
return nil if stripped.equal?(element)
|
|
284
|
+
|
|
285
|
+
Type::Combinator.nominal_of("Array", type_args: [stripped])
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Removes the `nil` constituent from a (possibly union) type,
|
|
289
|
+
# returning the same object when there is nothing to remove so
|
|
290
|
+
# callers can detect the no-op cheaply. Kept local to the
|
|
291
|
+
# dispatch tier to avoid a dependency on the narrowing module.
|
|
292
|
+
def strip_nil_constituent(type)
|
|
293
|
+
case type
|
|
294
|
+
when Type::Constant
|
|
295
|
+
type.value.nil? ? Type::Combinator.bot : type
|
|
296
|
+
when Type::Nominal
|
|
297
|
+
type.class_name == "NilClass" ? Type::Combinator.bot : type
|
|
298
|
+
when Type::Union
|
|
299
|
+
kept = type.members.map { |m| strip_nil_constituent(m) }
|
|
300
|
+
return type if kept.zip(type.members).all? { |k, m| k.equal?(m) }
|
|
301
|
+
|
|
302
|
+
Type::Combinator.union(*kept)
|
|
303
|
+
else
|
|
304
|
+
type
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# `Array[T]#flatten` (and `flatten(depth)`). When `T` is a
|
|
309
|
+
# nested `Array[U]` nominal, one flatten level yields the
|
|
310
|
+
# joined inner element type — `Array[Array[U]]#flatten` →
|
|
311
|
+
# `Array[U]`. When `T` is non-nested the result is `Array[T]`
|
|
312
|
+
# unchanged (Ruby returns a copy with the same element type).
|
|
313
|
+
# Multi-level nesting is handled conservatively: each level
|
|
314
|
+
# joins its element types, and a `depth` argument that does
|
|
315
|
+
# not fully resolve the nesting still produces a sound
|
|
316
|
+
# superset. Declines on an `Array` with no type argument
|
|
317
|
+
# (the RBS `Array[untyped]` answer is already as precise as
|
|
318
|
+
# we can be) and on a non-static depth argument.
|
|
319
|
+
def array_nominal_flatten(nominal, args)
|
|
320
|
+
element = nominal.type_args&.first
|
|
321
|
+
return nil if element.nil?
|
|
322
|
+
|
|
323
|
+
depth = tuple_flatten_depth(args)
|
|
324
|
+
return nil if depth == :decline
|
|
325
|
+
|
|
326
|
+
flattened = flatten_nominal_element(element, depth)
|
|
327
|
+
Type::Combinator.nominal_of("Array", type_args: [flattened])
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Resolves the element type of a flattened `Array[element]`.
|
|
331
|
+
# Each `Array[U]` nesting level contributes `U`; the per-level
|
|
332
|
+
# element types are unioned. `depth < 0` recurses without
|
|
333
|
+
# bound; `depth == 0` stops (Ruby's `flatten(0)` is a no-op
|
|
334
|
+
# copy and returns the element unchanged).
|
|
335
|
+
def flatten_nominal_element(element, depth)
|
|
336
|
+
return element if depth.zero?
|
|
337
|
+
return element unless array_nominal?(element)
|
|
338
|
+
|
|
339
|
+
inner = element.type_args.first
|
|
340
|
+
return element if inner.nil?
|
|
341
|
+
|
|
342
|
+
flatten_nominal_element(inner, depth - 1)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def array_nominal?(type)
|
|
346
|
+
type.is_a?(Type::Nominal) && type.class_name == "Array" && !type.type_args.nil? &&
|
|
347
|
+
!type.type_args.empty?
|
|
348
|
+
end
|
|
349
|
+
|
|
249
350
|
# Arg-type-driven String binary projections for any String-typed
|
|
250
351
|
# receiver (including Nominal, Refined, and Difference fallbacks).
|
|
251
352
|
# Called before the no-arg size guard so binary operators are seen.
|
|
@@ -479,8 +580,17 @@ module Rigor
|
|
|
479
580
|
%i[lowercase upcase] => :uppercase_string,
|
|
480
581
|
%i[uppercase upcase] => :refined_self,
|
|
481
582
|
%i[uppercase downcase] => :lowercase_string,
|
|
583
|
+
# `numeric-string` is the full Ruby numeric-literal
|
|
584
|
+
# grammar (since the predicate delegates to the
|
|
585
|
+
# parser). `#downcase` preserves it — lowercasing a
|
|
586
|
+
# literal (hex digits, `0X` / `E` prefixes) yields a
|
|
587
|
+
# valid lowercase literal — but `#upcase` does NOT:
|
|
588
|
+
# the rational / imaginary suffixes are lowercase-only
|
|
589
|
+
# (`"1r".upcase == "1R"` is not a literal), so `upcase`
|
|
590
|
+
# drops to the plain base `String` — still sound (the
|
|
591
|
+
# result is a String), just no longer numeric.
|
|
482
592
|
%i[numeric downcase] => :refined_self,
|
|
483
|
-
%i[numeric upcase] => :
|
|
593
|
+
%i[numeric upcase] => :base_string,
|
|
484
594
|
# Digit-only strings are case-invariant; the prefix
|
|
485
595
|
# letters in `0o…` / `0x…` are accepted by the
|
|
486
596
|
# predicate in either case so the predicate-subset
|
|
@@ -493,19 +603,19 @@ module Rigor
|
|
|
493
603
|
%i[hex_int downcase] => :refined_self,
|
|
494
604
|
%i[hex_int upcase] => :refined_self,
|
|
495
605
|
# v0.1.1 Track 1 slice 2 — `to_i` / `to_int` on a
|
|
496
|
-
#
|
|
497
|
-
#
|
|
498
|
-
#
|
|
499
|
-
#
|
|
500
|
-
#
|
|
501
|
-
#
|
|
502
|
-
#
|
|
503
|
-
#
|
|
504
|
-
#
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
%i[
|
|
508
|
-
%i[
|
|
606
|
+
# `decimal-int-string` parses to an `Integer`. The
|
|
607
|
+
# carrier is `universal_int`, NOT `non-negative-int`:
|
|
608
|
+
# the predicate `/\A-?\d+\z/` admits a leading sign, so
|
|
609
|
+
# `"-7"` is a valid decimal-int-string and
|
|
610
|
+
# `"-7".to_i == -7 < 0`. `String#to_i` is total (never
|
|
611
|
+
# raises), so the projection is sound — just signed.
|
|
612
|
+
# `numeric-string` is deliberately NOT projected to
|
|
613
|
+
# `to_i` at all: it now spans the full numeric-literal
|
|
614
|
+
# grammar, so a `"1.5"` / `"2i"` inhabitant has a
|
|
615
|
+
# fractional or non-Integer parse — it falls through to
|
|
616
|
+
# the RBS `Integer`.
|
|
617
|
+
%i[decimal_int to_i] => :universal_int,
|
|
618
|
+
%i[decimal_int to_int] => :universal_int
|
|
509
619
|
})
|
|
510
620
|
private_constant :REFINED_STRING_PROJECTIONS
|
|
511
621
|
|
|
@@ -530,6 +640,8 @@ module Rigor
|
|
|
530
640
|
when :uppercase_string then Type::Combinator.uppercase_string
|
|
531
641
|
when :lowercase_string then Type::Combinator.lowercase_string
|
|
532
642
|
when :non_negative_int then Type::Combinator.non_negative_int
|
|
643
|
+
when :universal_int then Type::Combinator.universal_int
|
|
644
|
+
when :base_string then refined.base
|
|
533
645
|
end
|
|
534
646
|
end
|
|
535
647
|
|
|
@@ -697,6 +809,37 @@ module Rigor
|
|
|
697
809
|
Type::Combinator.constant_of(values.sum)
|
|
698
810
|
end
|
|
699
811
|
|
|
812
|
+
# `tuple.join(sep = "")` — fold to the joined `Constant[String]`
|
|
813
|
+
# when every element is a `Constant` (its `to_s` is deterministic
|
|
814
|
+
# for the scalar value classes) and the separator is absent or a
|
|
815
|
+
# `Constant[String]`. Capped at `TUPLE_JOIN_BYTE_LIMIT`.
|
|
816
|
+
def tuple_join(tuple, _method_name, args)
|
|
817
|
+
sep = tuple_join_separator(args)
|
|
818
|
+
return nil if sep.nil?
|
|
819
|
+
|
|
820
|
+
values = constant_values(tuple.elements)
|
|
821
|
+
return nil if values.nil?
|
|
822
|
+
|
|
823
|
+
result = values.join(sep)
|
|
824
|
+
return nil if result.bytesize > TUPLE_JOIN_BYTE_LIMIT
|
|
825
|
+
|
|
826
|
+
Type::Combinator.constant_of(result)
|
|
827
|
+
rescue StandardError
|
|
828
|
+
nil
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# The join separator: `""` for the no-arg form, the value of a
|
|
832
|
+
# single `Constant[String]` arg, or `nil` to decline.
|
|
833
|
+
def tuple_join_separator(args)
|
|
834
|
+
return "" if args.empty?
|
|
835
|
+
return nil unless args.size == 1
|
|
836
|
+
|
|
837
|
+
arg = args.first
|
|
838
|
+
return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
|
|
839
|
+
|
|
840
|
+
arg.value
|
|
841
|
+
end
|
|
842
|
+
|
|
700
843
|
# `tuple.min` / `tuple.max` — fold when every element is
|
|
701
844
|
# a `Constant` whose values share a Ruby-comparable
|
|
702
845
|
# domain. Empty tuples fold to `Constant[nil]`.
|
|
@@ -721,6 +864,32 @@ module Rigor
|
|
|
721
864
|
nil
|
|
722
865
|
end
|
|
723
866
|
|
|
867
|
+
# `tuple.minmax` — the `[min, max]` pair as a 2-slot
|
|
868
|
+
# `Tuple[Constant[min], Constant[max]]`, mirroring the
|
|
869
|
+
# `Range#minmax` fold. Every element must be a `Constant`
|
|
870
|
+
# and the values must Ruby-compare; an empty tuple folds to
|
|
871
|
+
# `Tuple[nil, nil]` (Ruby's `[].minmax`), incomparable
|
|
872
|
+
# mixed-class values decline.
|
|
873
|
+
def tuple_minmax_pair(tuple, _method_name, args)
|
|
874
|
+
return nil unless args.empty?
|
|
875
|
+
|
|
876
|
+
if tuple.elements.empty?
|
|
877
|
+
nil_const = Type::Combinator.constant_of(nil)
|
|
878
|
+
return Type::Combinator.tuple_of(nil_const, nil_const)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
values = constant_values(tuple.elements)
|
|
882
|
+
return nil if values.nil?
|
|
883
|
+
|
|
884
|
+
low, high = values.minmax
|
|
885
|
+
Type::Combinator.tuple_of(
|
|
886
|
+
Type::Combinator.constant_of(low),
|
|
887
|
+
Type::Combinator.constant_of(high)
|
|
888
|
+
)
|
|
889
|
+
rescue StandardError
|
|
890
|
+
nil
|
|
891
|
+
end
|
|
892
|
+
|
|
724
893
|
# `tuple.sort` — every element must be a `Constant` and
|
|
725
894
|
# the values must Ruby-compare. The result is a Tuple
|
|
726
895
|
# with the same elements in sorted order. Comparison
|
|
@@ -875,6 +1044,50 @@ module Rigor
|
|
|
875
1044
|
constant_index(tuple, args) { |elements, value| elements.index { |e| e.value == value } }
|
|
876
1045
|
end
|
|
877
1046
|
|
|
1047
|
+
# `tuple.flatten` / `tuple.flatten(depth)` — recursively
|
|
1048
|
+
# flattens nested Tuple elements into a single Tuple. With
|
|
1049
|
+
# no argument the flatten is unbounded (matching Ruby's
|
|
1050
|
+
# `Array#flatten`); a `Constant[Integer]` depth bounds it.
|
|
1051
|
+
# Non-Tuple elements (scalars, `Array[T]` nominals, …) pass
|
|
1052
|
+
# through unchanged at their level. A non-static depth
|
|
1053
|
+
# argument (or a non-Integer one) declines so RBS answers.
|
|
1054
|
+
def tuple_flatten(tuple, _method_name, args)
|
|
1055
|
+
depth = tuple_flatten_depth(args)
|
|
1056
|
+
return nil if depth == :decline
|
|
1057
|
+
|
|
1058
|
+
Type::Combinator.tuple_of(*flatten_elements(tuple.elements, depth))
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
# Returns the requested flatten depth: `-1` for the no-arg
|
|
1062
|
+
# (unbounded) form, the Integer for a `Constant[Integer]`
|
|
1063
|
+
# argument, or `:decline` for any non-static / wrong-arity
|
|
1064
|
+
# argument shape.
|
|
1065
|
+
def tuple_flatten_depth(args)
|
|
1066
|
+
return -1 if args.empty?
|
|
1067
|
+
return :decline unless args.size == 1
|
|
1068
|
+
|
|
1069
|
+
arg = args.first
|
|
1070
|
+
return arg.value if arg.is_a?(Type::Constant) && arg.value.is_a?(Integer)
|
|
1071
|
+
|
|
1072
|
+
:decline
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# Flattens a list of element types to `depth` levels.
|
|
1076
|
+
# `depth < 0` means unbounded. A Tuple element is spliced
|
|
1077
|
+
# in (recursing with `depth - 1`); everything else passes
|
|
1078
|
+
# through at this level.
|
|
1079
|
+
def flatten_elements(elements, depth)
|
|
1080
|
+
return elements if depth.zero?
|
|
1081
|
+
|
|
1082
|
+
elements.flat_map do |element|
|
|
1083
|
+
if element.is_a?(Type::Tuple)
|
|
1084
|
+
flatten_elements(element.elements, depth - 1)
|
|
1085
|
+
else
|
|
1086
|
+
[element]
|
|
1087
|
+
end
|
|
1088
|
+
end
|
|
1089
|
+
end
|
|
1090
|
+
|
|
878
1091
|
# `rindex(obj)` → the LAST matching index, same decidability gate.
|
|
879
1092
|
def tuple_rindex(tuple, _method_name, args)
|
|
880
1093
|
constant_index(tuple, args) { |elements, value| elements.rindex { |e| e.value == value } }
|
|
@@ -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
|