rigortype 0.4.2 → 0.4.3

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -97,7 +97,7 @@ module Rigor
97
97
  # for the overlay, instead of aborting the run outright (see {#hkt_registry}).
98
98
  @hkt_scan_failure = FailureSlot.new
99
99
  @constant_type_cache = ConstantTypeCacheHolder.new
100
- # ADR-82 WD9 — `[gem_name, version]` pairs for the locked gems with no resolvable RBS. The
100
+ # ADR-82 WD9 — `[gem_name, version, variants]` entries for the locked gems with no resolvable RBS. The
101
101
  # root-constant ownership index over them is built lazily (first unresolved constant read) so runs
102
102
  # whose constants all resolve never pay the entry-file scan.
103
103
  @missing_rbs_gems = missing_rbs_gems.freeze
@@ -444,8 +444,8 @@ module Rigor
444
444
  end
445
445
 
446
446
  # The `:missing` classification (locked gems with no RBS through any resolution path), computed once
447
- # and consumed twice: as `[gem_name, version]` pairs for the ADR-82 WD9 constant-ownership index, and
448
- # as the eligible-gem set for the ADR-72 overlay resolution. Returns `[pairs, overlay_paths]`.
447
+ # and consumed twice: as `[gem_name, version, variants]` entries for the ADR-82 WD9 constant-ownership
448
+ # index, and as the eligible-gem set for the ADR-72 overlay resolution. Returns `[entries, overlay_paths]`.
449
449
  def missing_gems_and_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
450
450
  rbs_collection_paths:, plugin_registry:, signature_paths: [],
451
451
  bundle_root: nil)
@@ -458,7 +458,7 @@ module Rigor
458
458
  missing_gem_names: rows.map(&:gem_name), plugin_registry: plugin_registry,
459
459
  signature_paths: signature_paths
460
460
  )
461
- [rows.map { |row| [row.gem_name, row.version] }, overlays]
461
+ [index_entries(rows, locked), overlays]
462
462
  end
463
463
 
464
464
  # Issue #530 — a project with no `Gemfile.lock` still has gems on disk, and before this it handed the
@@ -472,7 +472,14 @@ module Rigor
472
472
  return [[], []] if installed.empty?
473
473
 
474
474
  rows = missing_rows(installed, default_libraries, bundle_sig_paths, rbs_collection_paths)
475
- [rows.map { |row| [row.gem_name, row.version] }, []]
475
+ [index_entries(rows, installed), []]
476
+ end
477
+
478
+ # The constant-ownership index's input: each `:missing` row with every `[version, platform]` variant its
479
+ # gem was locked (or found installed) under, because Bundler installs the variant matching the host —
480
+ # `commonmarker-2.10.0-arm64-darwin/` under a lockfile whose representative is the `ruby` one (#1844).
481
+ def index_entries(rows, gems)
482
+ rows.map { |row| [row.gem_name, row.version, gems[row.gem_name]&.variants] }
476
483
  end
477
484
 
478
485
  def missing_rows(gems, default_libraries, bundle_sig_paths, rbs_collection_paths)
@@ -16,6 +16,9 @@ module Rigor
16
16
  # (`Integer#quo` and `File#to_path`, whose CRuby owners are `Numeric` and `IO`), and declares a few CRuby no
17
17
  # longer defines (`Process::Status#&`). A class no catalogue covers answers false, as does a method its
18
18
  # catalogue does not list: the caller reads both as "not proven". The catalogue is one CRuby snapshot.
19
+ #
20
+ # Issue #1752 — the singleton side reads the catalogue's `singleton_methods` (each `rb_define_singleton_method`
21
+ # on the class), the record that `Integer.sqrt` is CRuby's own method of `Integer`'s singleton class.
19
22
  module CRubyDefiners
20
23
  module_function
21
24
 
@@ -25,7 +28,15 @@ module Rigor
25
28
  !names.nil? && names.include?(method_name.to_s)
26
29
  end
27
30
 
28
- def build_index
31
+ # Does the catalogue prove CRuby defines `method_name` on `class_name`'s singleton class itself?
32
+ def singleton_defines?(class_name, method_name)
33
+ names = SINGLETON_INDEX[class_name.to_s.delete_prefix("::")]
34
+ !names.nil? && names.include?(method_name.to_s)
35
+ end
36
+
37
+ # `{class name => Set[method name]}` from each catalogued class's instance methods and aliases, or from its
38
+ # `singleton_methods` when `side` is `:singleton`.
39
+ def build_index(side = :instance)
29
40
  index = Hash.new { |hash, key| hash[key] = Set.new }
30
41
  MethodCatalog.topic_paths.each do |path|
31
42
  data = YAML.safe_load_file(path, permitted_classes: [Symbol])
@@ -35,9 +46,7 @@ module Rigor
35
46
  classes.each do |name, klass|
36
47
  next if klass["rdoc_only"]
37
48
 
38
- names = index[name]
39
- names.merge(defined_names(klass["instance_methods"]))
40
- names.merge(defined_names(klass["aliases"]))
49
+ merge_names(index[name], klass, side)
41
50
  end
42
51
  end
43
52
  Ractor.make_shareable(index.to_h { |name, names| [name, names.freeze] })
@@ -45,6 +54,13 @@ module Rigor
45
54
  Ractor.make_shareable({})
46
55
  end
47
56
 
57
+ def merge_names(names, klass, side)
58
+ return names.merge(defined_names(klass["singleton_methods"])) if side == :singleton
59
+
60
+ names.merge(defined_names(klass["instance_methods"]))
61
+ names.merge(defined_names(klass["aliases"]))
62
+ end
63
+
48
64
  # The names of `entries` CRuby defines at runtime: one the catalogue marks `rdoc_only` was registered only for
49
65
  # RDoc, inside an `#if 0` block.
50
66
  def defined_names(entries)
@@ -54,7 +70,8 @@ module Rigor
54
70
  end
55
71
 
56
72
  INDEX = build_index
57
- private_constant :INDEX
73
+ SINGLETON_INDEX = build_index(:singleton)
74
+ private_constant :INDEX, :SINGLETON_INDEX
58
75
  end
59
76
  end
60
77
  end
@@ -310,23 +310,63 @@ module Rigor
310
310
 
311
311
  # The mutation sites under `body` whose reads name a variable {.captured_target?} accepts, keyed by name.
312
312
  # The block yields the names the enclosing construct introduces, asked only when a local site is found.
313
+ #
314
+ # A site whose receiver is a body local that aliases a capture (`l2 = log; l2 << e`) mutates that capture's
315
+ # object, so it is attributed to the capture as well ({.aliased_captures}): left out, the next iteration
316
+ # read `log` at its entry `[]` and `[1, 2].inject(0) { |a, e| r = a + log.size; l2 = log; l2 << e; r }`
317
+ # folded to `0` where Ruby answers `1` (issue #1332).
313
318
  def body_mutation_sites(body, base_scope, non_locals, &)
314
319
  sites = nil
315
320
  evaluator = nil
321
+ aliases = nil
316
322
  Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
317
323
  # A callee is resolved in the call-site scope, as the straight-line callee floor resolves it.
318
324
  site = mutation_site(descendant) { evaluator ||= StatementEvaluator.new(scope: base_scope) }
319
325
  next if site.nil?
320
326
 
321
327
  site_reads(site).each do |read|
322
- next unless captured_target?(read, ancestors, base_scope, non_locals, &)
323
-
324
- ((sites ||= {})[read.name] ||= []) << site
328
+ targets = captured_target?(read, ancestors, base_scope, non_locals, &) ? [read] : []
329
+ if read.is_a?(Prism::LocalVariableReadNode)
330
+ aliases ||= local_aliases(body)
331
+ targets.concat(aliased_captures(read.name, aliases, base_scope, non_locals, Set.new, &))
332
+ end
333
+ targets.each { |target| ((sites ||= {})[target.name] ||= []) << site }
325
334
  end
326
335
  end
327
336
  sites || NO_SITES
328
337
  end
329
338
 
339
+ # `{ name => [[read, ancestors], ...] }`: every variable read a local write under `body` may store
340
+ # ({ReceiverAlias.candidates} of its value), with the ancestors {.captured_target?} resolves it against.
341
+ def local_aliases(body)
342
+ aliases = {}
343
+ Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
344
+ next unless node.is_a?(Prism::LocalVariableWriteNode) || node.is_a?(Prism::LocalVariableOrWriteNode)
345
+
346
+ ReceiverAlias.candidates(node.value).each do |read|
347
+ (aliases[node.name] ||= []) << [read, ancestors + [node]]
348
+ end
349
+ end
350
+ aliases
351
+ end
352
+
353
+ # The captured reads the local `name` may alias, followed through local-to-local copies (`l3 = l2 = log`).
354
+ # It over-approximates — a write in any branch, or in a nested scope reusing the name, counts — which only
355
+ # widens a capture's binding.
356
+ def aliased_captures(name, aliases, base_scope, non_locals, visited, &)
357
+ return [] unless visited.add?(name)
358
+
359
+ aliases.fetch(name, NO_NAMES).flat_map do |read, ancestors|
360
+ if captured_target?(read, ancestors, base_scope, non_locals, &)
361
+ [read]
362
+ elsif read.is_a?(Prism::LocalVariableReadNode)
363
+ aliased_captures(read.name, aliases, base_scope, non_locals, visited, &)
364
+ else
365
+ NO_NAMES
366
+ end
367
+ end
368
+ end
369
+
330
370
  # The mutation site `node` is — the node itself, or a {UnknownStoreWidening::CalleeStore} for a self-call
331
371
  # whose callee content-mutates a local it is passed — or nil. A self-call is resolved by the
332
372
  # `StatementEvaluator` the block yields, which the caller builds only when one is needed. A mutator name
@@ -1952,19 +1952,20 @@ module Rigor
1952
1952
  # replaces (a refined `String#upcase` must not fold, a refined `map` must not fold per element, a top-level
1953
1953
  # `def` must not bind ahead of a refinement of `Object`, a plugin models the class's own method). An unreadable
1954
1954
  # body, or a list that may hold any refinement, answers `Dynamic[top]`, never the replaced method's signature.
1955
- # A union receiver is decided per member; a `Dynamic` receiver, a class object and a call no refinement wins
1956
- # fall through. A call site no refinement is in effect at answers nil at once and records nothing: its answer
1957
- # cannot change without an edit to its own file.
1955
+ # A union receiver is decided per member; a `Dynamic` receiver and a call no refinement wins fall through. A
1956
+ # class object walks its own lookup (issue #1752): under `refine(Object) { def oo = :o }` a refinement that wins
1957
+ # `Integer.oo` answers `Dynamic[top]` ({#refined_class_object_result}). A call site no refinement is in effect
1958
+ # at answers nil at once and records nothing: its answer cannot change without an edit to its own file.
1958
1959
  #
1959
1960
  # ADR-121 WD7 (A2) — inside the span of an activation that puts an opaque module in effect
1960
- # ({InEffectRefinements.opaque_module?}), any method of any instance may be replaced, so every instance-receiver
1961
- # call answers `Dynamic[top]`; a class object falls through, as it does below.
1961
+ # ({InEffectRefinements.opaque_module?}), any method of any object may be replaced, so every call on an instance
1962
+ # or a class object answers `Dynamic[top]`.
1962
1963
  def try_refined_dispatch(node, receiver, arg_types, block_type)
1963
1964
  list = scope.in_effect_refinements(node)
1964
1965
  return nil if list.empty?
1965
1966
 
1966
1967
  InEffectRefinements.record_refinement_names(node.name)
1967
- return dynamic_top if refined_instance_receiver?(receiver) && opaque_refinement_span?(node, list)
1968
+ return dynamic_top if refined_receiver?(receiver) && opaque_refinement_span?(node, list)
1968
1969
  return nil if scope.discovered_refinements.empty?
1969
1970
 
1970
1971
  refined_indirect_result(node, receiver, list) ||
@@ -2010,6 +2011,9 @@ module Rigor
2010
2011
  def refined_name_for?(receiver, name, list)
2011
2012
  members = receiver.is_a?(Type::Union) ? receiver.members : [receiver]
2012
2013
  members.any? do |member|
2014
+ next !RefinedDispatch.winner(scope, member.class_name, name, list, kind: :singleton).nil? if
2015
+ member.is_a?(Type::Singleton)
2016
+
2013
2017
  class_name = refined_receiver_class_name(member)
2014
2018
  !class_name.nil? && !RefinedDispatch.winner(scope, class_name, name, list).nil?
2015
2019
  end
@@ -2033,11 +2037,11 @@ module Rigor
2033
2037
  InEffectRefinements.opaque_in?(scope, lexical)
2034
2038
  end
2035
2039
 
2036
- # Does `receiver` (or a member of a union) have a class the refined arm answers for: an instance, not a class
2037
- # object or `Dynamic`?
2038
- def refined_instance_receiver?(receiver)
2040
+ # Does `receiver` (or a member of a union) have a lookup the refined arm answers for: an instance of a class,
2041
+ # or a class object (issue #1752), not `Dynamic`?
2042
+ def refined_receiver?(receiver)
2039
2043
  members = receiver.is_a?(Type::Union) ? receiver.members : [receiver]
2040
- members.any? { |member| !refined_receiver_class_name(member).nil? }
2044
+ members.any? { |member| member.is_a?(Type::Singleton) || !refined_receiver_class_name(member).nil? }
2041
2045
  end
2042
2046
 
2043
2047
  def refined_receiver_result(node, receiver, list, arg_types, block_type)
@@ -2053,6 +2057,9 @@ module Rigor
2053
2057
  end
2054
2058
 
2055
2059
  def refined_member_result(node, receiver, list, arg_types, block_type)
2060
+ return refined_class_object_result(node, receiver, list, arg_types, block_type) if
2061
+ receiver.is_a?(Type::Singleton)
2062
+
2056
2063
  class_name = refined_receiver_class_name(receiver)
2057
2064
  return nil if class_name.nil?
2058
2065
 
@@ -2064,6 +2071,16 @@ module Rigor
2064
2071
  dynamic_top
2065
2072
  end
2066
2073
 
2074
+ # Issue #1752 — a call on a class object, walked through its singleton levels. A refinement that wins answers
2075
+ # `Dynamic[top]`, never its refine body's type: a hook on any ancestor (an RBS-declared `include Singleton`, a
2076
+ # gem concern, `define_singleton_method(:included)`, a `Module#included` patch) can install the class object's
2077
+ # own method where no table sees it, and the body's type then fires downstream on correct code. A walk that finds
2078
+ # no winner keeps the class object's own answer (`Integer.sqrt(16)` stays `Integer`).
2079
+ def refined_class_object_result(node, receiver, list, _arg_types, _block_type)
2080
+ winner = RefinedDispatch.winner(scope, receiver.class_name, node.name, list, kind: :singleton)
2081
+ dynamic_top unless winner.nil?
2082
+ end
2083
+
2067
2084
  # The winning refine body's inferred return, re-typed with the receiver's class as `self` and the call's
2068
2085
  # argument types bound as an undeclared method's are; nil when the body is not readable here.
2069
2086
  #
@@ -2082,8 +2099,9 @@ module Rigor
2082
2099
  nil
2083
2100
  end
2084
2101
 
2085
- # The class an instance-side receiver is an instance of, or nil for one a refinement does not answer here: a
2086
- # class object (refinements of singleton classes are not recorded), `Dynamic`, `top`, or a shape with no class.
2102
+ # The class an instance-side receiver is an instance of, or nil for one this instance walk does not answer: a
2103
+ # class object (it walks its own lookup, {#refined_class_object_result}), `Dynamic`, `top`, or a shape with no
2104
+ # class.
2087
2105
  def refined_receiver_class_name(receiver)
2088
2106
  case receiver
2089
2107
  when Type::Nominal, Type::StructInstance, Type::DataInstance then receiver.class_name
@@ -2453,7 +2471,7 @@ module Rigor
2453
2471
  return every_definer_raises_type(raising, receiver, call_node, arg_types, block_type) if def_node.nil?
2454
2472
 
2455
2473
  result = infer_user_method_return(def_node, receiver, arg_types,
2456
- self_fold_safe: fold_safe_call_receiver?(call_node, receiver),
2474
+ self_fold_safe: fold_safe_call_receiver?(call_node),
2457
2475
  yield_type: block_type)
2458
2476
  return result if result.nil?
2459
2477
 
@@ -2488,11 +2506,11 @@ module Rigor
2488
2506
  # This is only a property of the receiver expression; whether the grant is actually issued also needs
2489
2507
  # a `StructInstance` carrier and a body that survives the self-use scan, both decided in
2490
2508
  # {#build_user_method_body_scope}.
2491
- def fold_safe_call_receiver?(call_node, receiver)
2509
+ def fold_safe_call_receiver?(call_node)
2492
2510
  return false if call_node.nil?
2493
2511
 
2494
2512
  case call_node.receiver
2495
- when Prism::CallNode then materialization_call?(call_node.receiver, receiver)
2513
+ when Prism::CallNode then materialization_call?(call_node.receiver)
2496
2514
  when Prism::LocalVariableReadNode then scope.struct_fold_safe?(call_node.receiver.name)
2497
2515
  when nil, Prism::SelfNode then scope.struct_fold_safe?(:self)
2498
2516
  else false
@@ -2502,10 +2520,10 @@ module Rigor
2502
2520
  # Issue #595 — the materialisation test lives ONCE, in
2503
2521
  # [`struct_materialization.rb`](method_dispatcher/struct_materialization.rb), because the direct
2504
2522
  # member-read gate ({MethodDispatcher::StructFolding}`#fresh_receiver?`) needs the identical answer;
2505
- # see {MethodDispatcher::StructMaterialization.materialization_call?}. It resolves its own `.with`
2506
- # guard through `Scope`, so neither consumer supplies a lookup and neither can answer differently.
2507
- def materialization_call?(node, receiver)
2508
- MethodDispatcher::StructMaterialization.materialization_call?(node, receiver, scope)
2523
+ # see {MethodDispatcher::StructMaterialization.materialization_call?}. A `.with` call is never one
2524
+ # (`Struct` has no `#with`, #1335), so the test needs no lookup from either consumer.
2525
+ def materialization_call?(node)
2526
+ MethodDispatcher::StructMaterialization.materialization_call?(node, scope)
2509
2527
  end
2510
2528
 
2511
2529
  # Module-singleton call resolution (ADR-57 follow-up) — resolves `Foo.<name>` on a `Singleton[Foo]`
@@ -2674,7 +2692,7 @@ module Rigor
2674
2692
  def every_definer_raises_type(defs, receiver, call_node, arg_types, block_type)
2675
2693
  return nil if defs.nil?
2676
2694
 
2677
- self_fold_safe = fold_safe_call_receiver?(call_node, receiver)
2695
+ self_fold_safe = fold_safe_call_receiver?(call_node)
2678
2696
  defs.each do |def_node|
2679
2697
  result = infer_user_method_return(def_node, receiver, arg_types,
2680
2698
  self_fold_safe: self_fold_safe, yield_type: block_type)
@@ -5417,13 +5435,27 @@ module Rigor
5417
5435
  acc, rest = inject_constant_start(members, seed, has_seed)
5418
5436
  return nil if acc.nil?
5419
5437
 
5420
- rest.each do |element_value|
5421
- acc = inject_constant_step(block, acc, element_value)
5438
+ captured = inject_captured_bindings(block, rest.map { |value| Type::Combinator.constant_of(value) })
5439
+ rest.each_with_index do |element_value, position|
5440
+ acc = inject_constant_step(block, acc, element_value, captured, position)
5422
5441
  return nil if acc.nil?
5423
5442
  end
5424
5443
  acc
5425
5444
  end
5426
5445
 
5446
+ # Issue #1332 — the #587 (b) captured-block binding ({#captured_block_bindings}) both inject arms lay under
5447
+ # every step, as the Tuple / Range per-element folds and the per-pair `HashShape` fold do. Without it each
5448
+ # step reads a captured variable at its entry binding, so a body that rebinds or mutates one (`n += 1`,
5449
+ # `log << e`, `cache[:k] ||= e`) pinned the result: `n = 0; [1, 2].inject(0) { |a, e| r = a + n; n += 1;
5450
+ # r }` folded to `0` where Ruby answers `1`. The fixpoint binds the accumulator parameter to `Dynamic[top]`
5451
+ # rather than to a type the inject fixpoint has not converged yet: a captured name that reads the
5452
+ # accumulator widens rather than pins. `element_types` is the constant arm's steps, one position per block
5453
+ # run, which {RepeatedOrWrites} reads; nil for the nominal arm, which marks every repeated site.
5454
+ def inject_captured_bindings(block, element_types, element: nil)
5455
+ element ||= Type::Combinator.union(*element_types)
5456
+ captured_block_bindings(block, [Type::Combinator.untyped, element], element_types: element_types)
5457
+ end
5458
+
5427
5459
  # Extracts the receiver's foldable constant values, size-capped before enumeration, or nil to decline.
5428
5460
  def inject_constant_members(receiver)
5429
5461
  case receiver
@@ -5475,9 +5507,9 @@ module Rigor
5475
5507
  # Evaluates the block body once with the running constant accumulator + the next constant element
5476
5508
  # bound to the block params, returning the result when it is a foldable `Constant` within the
5477
5509
  # magnitude cap, else nil to decline the whole fold.
5478
- def inject_constant_step(block, acc, element_value)
5510
+ def inject_constant_step(block, acc, element_value, captured, position)
5479
5511
  element = Type::Combinator.constant_of(element_value)
5480
- result = type_block_body_with_param(block, [acc, element])
5512
+ result = type_block_body_with_param(block, [acc, element], captured: captured, position: position)
5481
5513
  return nil unless result.is_a?(Type::Constant)
5482
5514
  return nil unless inject_foldable?(result.value)
5483
5515
  return nil if inject_magnitude_too_large?(result.value)
@@ -5507,13 +5539,14 @@ module Rigor
5507
5539
  seed_acc = has_seed ? seed : element
5508
5540
  return nil if seed_acc.nil?
5509
5541
 
5542
+ captured = inject_captured_bindings(block, nil, element: element)
5510
5543
  converged = BodyFixpoint.converge(
5511
5544
  names: [:__inject_acc__],
5512
5545
  seed_bindings: { __inject_acc__: seed_acc },
5513
5546
  widen: method(:widen_value_pinned),
5514
5547
  evaluate_body: lambda do |bindings|
5515
5548
  acc = bindings[:__inject_acc__]
5516
- result = type_block_body_with_param(block, [acc, element])
5549
+ result = type_block_body_with_param(block, [acc, element], captured: captured)
5517
5550
  result.nil? ? {} : { __inject_acc__: result }
5518
5551
  end
5519
5552
  )
@@ -148,35 +148,26 @@ module Rigor
148
148
  # define any name), and a row whose class the walk could not name ({.class_wildcard_key?}), which reaches
149
149
  # every receiver.
150
150
  #
151
+ # Issue #1752 — `kind: :singleton` asks for the class object `class_name`: a refinement of any class or module
152
+ # on its lookup reaches it (`Class`, `Module`, `Object`, `Kernel`, `BasicObject`, a module it or a superclass
153
+ # extends; {RefinedDispatch::ClassObject.reach}). Where that lookup cannot be read, every row counts whose class
154
+ # a class object may reach ({RefinedDispatch::ClassObject.reachable?}).
155
+ #
151
156
  # ADR-46 — the answer is a function of every refinement of this name in the project, so the consumer
152
157
  # depends on the name whichever way it answers; a refine body edited in another file must re-check it
153
158
  # (`IncrementalSession#refinement_affected`). It also depends on the wildcard rows (`refinement:*`).
154
- def refining_modules(scope, class_name, method_name)
159
+ def refining_modules(scope, class_name, method_name, kind = :instance)
155
160
  record_refinement_names(method_name)
156
161
  refinements = scope.discovered_refinements
157
162
  return nil if refinements.empty?
158
163
 
164
+ reaches = receiver_reach(scope, class_name, kind)
159
165
  modules = nil
160
166
  refinements.each do |refined, methods|
161
167
  names = methods[method_name]
162
168
  unread = methods[WILDCARD]
163
169
  next if names.nil? && unread.nil?
164
- next unless refined_receiver_class?(scope, class_name, refined) || class_wildcard_key?(scope, refined)
165
-
166
- modules = (modules || []).concat(names || EMPTY, unread || EMPTY)
167
- end
168
- modules
169
- end
170
-
171
- # ADR-121 WD7 — {.refining_modules} for a receiver the class rows do not reach (a class object): only the rows
172
- # whose class the walk could not name ({.class_wildcard_key?}), for the name or as a names-wildcard.
173
- def class_unknown_refining_modules(scope, method_name)
174
- record_refinement_names(method_name)
175
- modules = nil
176
- scope.discovered_refinements.each do |refined, methods|
177
- names = methods[method_name]
178
- unread = methods[WILDCARD]
179
- next if (names.nil? && unread.nil?) || !class_wildcard_key?(scope, refined)
170
+ next unless reaches.call(refined) || class_wildcard_key?(scope, refined)
180
171
 
181
172
  modules = (modules || []).concat(names || EMPTY, unread || EMPTY)
182
173
  end
@@ -258,6 +249,19 @@ module Rigor
258
249
  end
259
250
  end
260
251
 
252
+ # Does a row keyed on a refined class reach the receiver? A class object's lookup is read once, on the first
253
+ # row that asks.
254
+ def receiver_reach(scope, class_name, kind)
255
+ return ->(refined) { refined_receiver_class?(scope, class_name, refined) } unless kind == :singleton
256
+
257
+ reach = nil
258
+ lambda do |refined|
259
+ reach ||= [RefinedDispatch::ClassObject.reach(scope, class_name)]
260
+ names = reach.first
261
+ names.nil? ? RefinedDispatch::ClassObject.reachable?(scope, refined) : names.include?(refined)
262
+ end
263
+ end
264
+
261
265
  def refined_receiver_class?(scope, class_name, refined)
262
266
  return true if class_name == refined
263
267
 
@@ -16,7 +16,7 @@ require_relative "last_line"
16
16
 
17
17
  module Rigor
18
18
  module Inference
19
- # Which code may rebind the regex match globals (`$~` and the `$&` / `` $` `` / `$'` / `$+` / `$1`..`$9` family
19
+ # Which code may rebind the regex match globals (`$~` and the `$&` / `` $` `` / `$'` / `$+` / `$1`..`$N` family
20
20
  # derived from it) that a scope's narrowing speaks for. Ruby keeps them in the method frame's special-variable
21
21
  # slot, and every block and closure made in the method reaches that same slot, so a match a block body runs
22
22
  # rebinds the enclosing method's `$~` (issue #1358). A `def`, class or module body runs in a frame of its own,
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ module MethodDispatcher
6
+ # What a call's AST says about its argument types that the types alone cannot: shared by the RBS tier's
7
+ # overload selection and the constructor folds, so the two readings of one call cannot drift.
8
+ module ArgumentNodes
9
+ module_function
10
+
11
+ # Issue #1727 — whether `args`' last entry is the call's keyword hash (`f(a, k: 1)`), which the overload
12
+ # selector then matches against an overload's keywords. Only the AST tells a keyword hash from a
13
+ # braced positional one (`f(a, { k: 1 })`), so a dispatch without the call node, or one whose
14
+ # arguments are not the node's own (a forwarded `...`, a `send` re-dispatch), answers false and keeps
15
+ # reading every argument positionally.
16
+ def keyword_hash_last?(call_node, args)
17
+ return false if args.empty? || !call_node.respond_to?(:arguments)
18
+
19
+ arguments = call_node.arguments&.arguments
20
+ !arguments.nil? && arguments.size == args.size && arguments.last.is_a?(Prism::KeywordHashNode)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../../type"
4
4
  require_relative "constant_folding"
5
5
  require_relative "random_folding"
6
+ require_relative "unit_denominator_rational"
6
7
 
7
8
  module Rigor
8
9
  module Inference
@@ -346,8 +347,8 @@ module Rigor
346
347
  # is a numeric Constant. The actual Ruby constructor runs at fold time (host-side), so the result
347
348
  # respects Ruby's normalisation (`Rational(2, 4)` → `Rational(1, 2)`).
348
349
  def try_numeric_constructor(method_name, args)
349
- return nil unless [1, 2].include?(args.size)
350
- return nil unless args.all? { |arg| numeric_constant?(arg) }
350
+ foldable = [1, 2].include?(args.size) && args.all? { |arg| numeric_constant?(arg) }
351
+ return UnitDenominatorRational.answer(method_name, args) unless foldable
351
352
 
352
353
  values = args.map(&:value)
353
354
  result = NUMERIC_CONSTRUCTORS[method_name].call(*values)
@@ -35,8 +35,8 @@ module Rigor
35
35
  # beginless),
36
36
  # - the slice is empty.
37
37
  #
38
- # K is read from `$1`..`$9` only: `Scope#forget_match_globals` does not clear `$10` and above (#1384), so a
39
- # binding there may belong to an earlier match.
38
+ # K is read from `$1`..`$9` only: the fold declines an index above nine rather than widen its reach.
39
+ # `Scope#forget_match_globals` clears `$10` and above too (#1384), so a binding there is never stale.
40
40
  module MatchDataFolding
41
41
  MAX_GROUP_GLOBAL = 9
42
42
  private_constant :MAX_GROUP_GLOBAL
@@ -12,9 +12,9 @@ module Rigor
12
12
  # The member-shape projections shared by {DataFolding} and {StructFolding}. A `DataInstance` and a
13
13
  # `StructInstance` expose the same surface — an ordered `members` map, `member_names`, and a
14
14
  # `class_name` — so the value projections off that surface (`[]` / `to_h` / `deconstruct` /
15
- # `deconstruct_keys` / `members` / `with`) and the reader-redefinition guard are identical between the
16
- # two folders. Only `#with`'s carrier constructor differs (`data_instance_of` vs
17
- # `struct_instance_of`), so it takes a block that builds the new instance from the merged member map.
15
+ # `deconstruct_keys` / `members`) and the reader-redefinition guard are identical between the two
16
+ # folders. `#with` is a projection of `Data` only (`Struct` has no `#with`, #1335); it takes a block that
17
+ # builds the new instance from the merged member map, so the carrier constructor stays with the caller.
18
18
  #
19
19
  # Both folders `extend` this module so the projections resolve as their own module functions
20
20
  # (matching their `module_function` style).
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Issue #1393 — `min` / `max` / `min_by` / `max_by` take an OPTIONAL count, and Ruby reads a `nil` count as
9
+ # "no count" (except blockless `Range#min` / `Range#max`, which raise; those are left alone):
10
+ # `[1, 2].min_by(nil) { |s| s }` is `1`, not `[1]`, and `[1, 2].max(nil)` is `2`. The RBS
11
+ # declares only `() ...` and `(Integer) ...` arms, so a `nil` argument matched none of them and the selector
12
+ # fell back to the blockless `Enumerator` arm — a confidently wrong type, and a false `call.undefined-method`
13
+ # on `xs.min_by(limit) { … }.succ` where `limit` may be `nil`.
14
+ #
15
+ # This splits the count argument. A `nil` part is dispatched as the no-argument call (so every no-count
16
+ # answer, including the #1333 non-empty fold, applies); a non-`nil` part is dispatched as written; a union
17
+ # of both answers the union of the two results. A count that has no `nil` part is left to the caller
18
+ # untouched. Only the core receivers whose methods these are (`Array`, `Hash`, `Range`, `Set`, and their
19
+ # shape and constant carriers) are rewritten, so a user class's own `min(limit)` is not reinterpreted.
20
+ module OptionalCount
21
+ module_function
22
+
23
+ METHODS = Set[:min, :max, :min_by, :max_by].freeze
24
+ # `Range` defines its own blockless `min` / `max`, which raise `TypeError` for a `nil` count in Ruby 4.0;
25
+ # only with a block does it fall through to `Enumerable`'s, which read `nil` as no count.
26
+ COUNTED_BY_ENUMERABLE_ONLY = Set[:min, :max].freeze
27
+ RECEIVER_CLASSES = Set["Array", "Hash", "Range", "Set"].freeze
28
+
29
+ # @return nil when the call is not a nil-count call; otherwise a one-element list holding the non-`nil`
30
+ # part of the count (itself nil when the count is only `nil`).
31
+ def split(receiver, method_name, args, block: true)
32
+ return nil unless METHODS.include?(method_name) && args.size == 1 && core_receiver?(receiver)
33
+ return nil if !block && COUNTED_BY_ENUMERABLE_ONLY.include?(method_name) && range_receiver?(receiver)
34
+
35
+ arg = args.first
36
+ members = arg.is_a?(Type::Union) ? arg.members : [arg]
37
+ return nil unless members.any? { |m| nil_constant?(m) }
38
+
39
+ rest = members.reject { |m| nil_constant?(m) }
40
+ [rest.empty? ? nil : Type::Combinator.union(*rest)]
41
+ end
42
+
43
+ def range_receiver?(receiver)
44
+ (receiver.is_a?(Type::Constant) && receiver.value.is_a?(Range)) ||
45
+ (receiver.is_a?(Type::Nominal) && receiver.class_name == "Range")
46
+ end
47
+
48
+ def nil_constant?(type)
49
+ type.is_a?(Type::Constant) && type.value.nil?
50
+ end
51
+
52
+ def core_receiver?(receiver)
53
+ case receiver
54
+ when Type::Tuple, Type::HashShape then true
55
+ when Type::Constant then receiver.value.is_a?(Range) || receiver.value.is_a?(::Set)
56
+ when Type::Nominal then RECEIVER_CLASSES.include?(receiver.class_name)
57
+ else false
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1019,7 +1019,7 @@ module Rigor
1019
1019
  self_type = self_type_override ||
1020
1020
  (kind == :singleton ? Type::Combinator.singleton_of(class_name) : instance_type)
1021
1021
 
1022
- keywords_last = keyword_arguments_last?(call_node, args)
1022
+ keywords_last = ArgumentNodes.keyword_hash_last?(call_node, args)
1023
1023
  call_site = [class_name, method_name, kind]
1024
1024
  if keywords_last && args.last.is_a?(Type::HashShape)
1025
1025
  distributed = distributed_keyword_return(method_definition, self_type, instance_type, type_vars, args,
@@ -1129,21 +1129,9 @@ module Rigor
1129
1129
  dynamic_value && !joined.is_a?(Type::Dynamic) ? Type::Combinator.dynamic(joined) : joined
1130
1130
  end
1131
1131
 
1132
- # Issue #1727 — whether `args`' last entry is the call's keyword hash (`f(a, k: 1)`), which the overload
1133
- # selector then matches against an overload's keywords. Only the AST tells a keyword hash from a
1134
- # braced positional one (`f(a, { k: 1 })`), so a dispatch without the call node, or one whose
1135
- # arguments are not the node's own (a forwarded `...`, a `send` re-dispatch), answers false and keeps
1136
- # reading every argument positionally.
1137
- def keyword_arguments_last?(call_node, args)
1138
- return false if args.empty? || !call_node.respond_to?(:arguments)
1139
-
1140
- arguments = call_node.arguments&.arguments
1141
- !arguments.nil? && arguments.size == args.size && arguments.last.is_a?(Prism::KeywordHashNode)
1142
- end
1143
-
1144
1132
  # Issue #1801 — the indices of `args` the call passes as splat arguments (`f(*xs, k: 1)`), whose positional
1145
- # count is not known statically, or nil without one. As for {#keyword_arguments_last?}, only a call whose
1146
- # node's arguments are exactly `args` answers.
1133
+ # count is not known statically, or nil without one. As for {ArgumentNodes.keyword_hash_last?}, only a call
1134
+ # whose node's arguments are exactly `args` answers.
1147
1135
  def splat_indices(call_node, args)
1148
1136
  return nil if args.empty? || !call_node.respond_to?(:arguments)
1149
1137
 
@@ -1729,7 +1717,7 @@ module Rigor
1729
1717
 
1730
1718
  def probe_block_param_types(receiver:, method_name:, args:, environment:, scope: nil, call_node: nil)
1731
1719
  args ||= []
1732
- keywords_last = keyword_arguments_last?(call_node, args)
1720
+ keywords_last = ArgumentNodes.keyword_hash_last?(call_node, args)
1733
1721
  splat = splat_indices(call_node, args)
1734
1722
  case receiver
1735
1723
  when Type::Union