rigortype 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -39,6 +39,9 @@ module Rigor
39
39
  Hit = Data.define(:answer, :owner, :index, :side)
40
40
 
41
41
  QUESTIONS = %i[definer visibility arity override own_side].freeze
42
+ # The questions whose answer reads a recorded visibility: only they treat a contested visibility slot as a
43
+ # possible definer ({#possible?}).
44
+ VISIBILITY_QUESTIONS = %i[visibility override].freeze
42
45
  NONE = :rigor_definer_resolution_none
43
46
  STRAY = :rigor_definer_resolution_stray
44
47
  private_constant :NONE, :STRAY
@@ -58,12 +61,13 @@ module Rigor
58
61
  flavor = question == :arity ? :arity : :methods
59
62
  chain = Scope::ResolutionChain.for(scope, class_name.to_s, side, flavor)
60
63
 
61
- hits = candidates(scope, chain, method_name, from, answer_in)
64
+ reads_visibility = VISIBILITY_QUESTIONS.include?(question)
65
+ hits = candidates(scope, chain, method_name, from, answer_in, reads_visibility)
62
66
  return UNKNOWN if hits.nil?
63
67
 
64
68
  verdict = chain.settle(scope, outcomes(hits), owner: hits.first&.owner, unknown_for: method_name) do |retro|
65
69
  retro_from = retro_position(chain, retro, from)
66
- outcomes(retro_from && candidates(scope, retro, method_name, retro_from, answer_in))
70
+ outcomes(retro_from && candidates(scope, retro, method_name, retro_from, answer_in, reads_visibility))
67
71
  end
68
72
  return UNKNOWN unless verdict == :chain
69
73
  return UNKNOWN if side == :singleton && SingletonHookDecline.decline?(scope, chain, method_name, from, hits)
@@ -101,7 +105,7 @@ module Rigor
101
105
  # follows (the absent candidate). A cut chain, and a chain on which an external ancestor ahead of the
102
106
  # candidate (or, with no candidate, the implicit `Object`) may define the name, is a decline, answered by
103
107
  # nil instead of a set.
104
- def candidates(scope, chain, method_name, from, answer_in)
108
+ def candidates(scope, chain, method_name, from, answer_in, reads_visibility)
105
109
  hits = []
106
110
  position = from
107
111
  loop do
@@ -113,7 +117,9 @@ module Rigor
113
117
  return nil if hit.nil? && chain.truncated?
114
118
 
115
119
  hits << hit
116
- return decided(scope, chain, method_name, from, hits) if hit.nil? || !possible?(scope, hit, method_name)
120
+ if hit.nil? || !possible?(scope, hit, method_name, reads_visibility)
121
+ return decided(scope, chain, method_name, from, hits)
122
+ end
117
123
 
118
124
  position = hit.index + 1
119
125
  end
@@ -188,15 +194,18 @@ module Rigor
188
194
  end
189
195
 
190
196
  # A definer that rests on a fact the walk could not prove executes: the member's `possible` sibling, or a
191
- # contested slot of one of the single-valued members.
192
- def possible?(scope, hit, method_name)
197
+ # contested slot of one of the single-valued members. A contested visibility slot counts only for a question
198
+ # that reads the visibility (`reads_visibility`): a slot is contested there for a visibility call the walk
199
+ # cannot place (an uncertain toggle, `private(*names)`, `private def self.x`), which leaves the definer and its
200
+ # `def` certain; a possible `def` or `attr_*` call contests its def-node slot or its name's `possible` side too.
201
+ def possible?(scope, hit, method_name, reads_visibility)
193
202
  discovery = scope.discovery
194
203
  owner = hit.owner
195
204
  kind = hit.side == :singleton ? :singleton : :instance
196
205
  nodes = kind == :singleton ? :discovered_singleton_def_nodes : :discovered_def_nodes
197
206
  discovery.possible_method?(owner, method_name, kind) ||
198
207
  discovery.contested?(nodes, [owner, method_name.to_sym]) ||
199
- discovery.contested?(:discovered_method_visibilities, [owner, method_name.to_sym]) ||
208
+ (reads_visibility && discovery.contested?(:discovered_method_visibilities, [owner, method_name.to_sym])) ||
200
209
  discovery.contested?(:discovered_parameter_envelopes, [owner, [kind, method_name.to_sym]])
201
210
  end
202
211
 
@@ -321,6 +321,37 @@ module Rigor
321
321
  try_project_singleton_inference(receiver, node, arg_types, method_name: :[])
322
322
  end
323
323
 
324
+ # Issue #1889 — the type a block-form `inject` / `reduce` accumulator parameter holds across the block's runs,
325
+ # or nil when the call is not one {#try_block_inject_fold} folds. The RBS tier binds the parameter to the seed
326
+ # alone, so the statement pass and the ADR-56 write-back typed every run's accumulator as `0` for `inject(0)`,
327
+ # and a capture assigned from it (`m = a`) was written back as `0`.
328
+ #
329
+ # The fold records the answer as it types the call ({#record_inject_accumulator}): the constant arm the
330
+ # accumulator values the block receives (`0 | 1` for `[1, 2].inject(0) { |a, e| a + e }`), the nominal
331
+ # arm its seed joined with the converged accumulator. The statement pass types the call from the same scope
332
+ # just before it enters the block, so the recorded answer is read back rather than computed again — each
333
+ # computation types the body, and a nested `inject` multiplied that by its depth.
334
+ def inject_accumulator_type(call_node, receiver, arg_types)
335
+ block = call_node.block
336
+ return nil unless INJECT_METHODS.include?(call_node.name) && block.is_a?(Prism::BlockNode)
337
+
338
+ recorded = self.class.recorded_inject_accumulator(block, scope)
339
+ return recorded if recorded
340
+
341
+ try_block_inject_fold(call_node, receiver, arg_types)
342
+ self.class.recorded_inject_accumulator(block, scope)
343
+ end
344
+
345
+ INJECT_ACCUMULATOR_KEY = :__rigor_inject_accumulator__
346
+ private_constant :INJECT_ACCUMULATOR_KEY
347
+
348
+ # The accumulator type {#record_inject_accumulator} stored for `block` when the call was typed from `scope`
349
+ # itself (by identity), else nil.
350
+ def self.recorded_inject_accumulator(block, scope)
351
+ slot = Thread.current[INJECT_ACCUMULATOR_KEY]&.[](block)
352
+ slot && slot[0].equal?(scope) ? slot[1] : nil
353
+ end
354
+
324
355
  # ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
325
356
  # that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
326
357
  # recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
@@ -1047,12 +1078,15 @@ module Rigor
1047
1078
  #
1048
1079
  # The `case ... in` pattern-matching form (`CaseMatchNode`) and the predicate-less form (`case; when
1049
1080
  # c1; ...`) bypass the `===` analysis: pattern matching has richer semantics, and a predicate-less
1050
- # `case` reduces to a `if c1; ...; elsif c2` chain that statement-level narrowing already handles.
1081
+ # `case` reduces to a `if c1; ...; elsif c2` chain. Issue #1495 — like {#type_of_conditional}, that
1082
+ # chain delegates to the statement evaluator, which narrows each arm by its conditions' edges, so
1083
+ # `[case; when x.nil? then "n"; else x end]` reads `"n" | String` as the statement form does.
1051
1084
  # Issue #1429 — each arm is typed under the subject's clause narrowing (`Narrowing.case_when_scopes`), the
1052
1085
  # scope the statement evaluator runs the arm in: `when Symbol then n` on `n: Integer | Symbol` answers
1053
1086
  # `Symbol`, and the `else` arm reads the subject every earlier clause has ruled out.
1054
1087
  def type_of_case(node)
1055
- return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
1088
+ return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode)
1089
+ return scope.evaluate(node, tracer: tracer).first if node.predicate.nil?
1056
1090
 
1057
1091
  subject_type = type_of(node.predicate)
1058
1092
  candidates = []
@@ -1952,19 +1986,20 @@ module Rigor
1952
1986
  # replaces (a refined `String#upcase` must not fold, a refined `map` must not fold per element, a top-level
1953
1987
  # `def` must not bind ahead of a refinement of `Object`, a plugin models the class's own method). An unreadable
1954
1988
  # 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.
1989
+ # A union receiver is decided per member; a `Dynamic` receiver and a call no refinement wins fall through. A
1990
+ # class object walks its own lookup (issue #1752): under `refine(Object) { def oo = :o }` a refinement that wins
1991
+ # `Integer.oo` answers `Dynamic[top]` ({#refined_class_object_result}). A call site no refinement is in effect
1992
+ # at answers nil at once and records nothing: its answer cannot change without an edit to its own file.
1958
1993
  #
1959
1994
  # 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.
1995
+ # ({InEffectRefinements.opaque_module?}), any method of any object may be replaced, so every call on an instance
1996
+ # or a class object answers `Dynamic[top]`.
1962
1997
  def try_refined_dispatch(node, receiver, arg_types, block_type)
1963
1998
  list = scope.in_effect_refinements(node)
1964
1999
  return nil if list.empty?
1965
2000
 
1966
2001
  InEffectRefinements.record_refinement_names(node.name)
1967
- return dynamic_top if refined_instance_receiver?(receiver) && opaque_refinement_span?(node, list)
2002
+ return dynamic_top if refined_receiver?(receiver) && opaque_refinement_span?(node, list)
1968
2003
  return nil if scope.discovered_refinements.empty?
1969
2004
 
1970
2005
  refined_indirect_result(node, receiver, list) ||
@@ -2010,6 +2045,9 @@ module Rigor
2010
2045
  def refined_name_for?(receiver, name, list)
2011
2046
  members = receiver.is_a?(Type::Union) ? receiver.members : [receiver]
2012
2047
  members.any? do |member|
2048
+ next !RefinedDispatch.winner(scope, member.class_name, name, list, kind: :singleton).nil? if
2049
+ member.is_a?(Type::Singleton)
2050
+
2013
2051
  class_name = refined_receiver_class_name(member)
2014
2052
  !class_name.nil? && !RefinedDispatch.winner(scope, class_name, name, list).nil?
2015
2053
  end
@@ -2033,11 +2071,11 @@ module Rigor
2033
2071
  InEffectRefinements.opaque_in?(scope, lexical)
2034
2072
  end
2035
2073
 
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)
2074
+ # Does `receiver` (or a member of a union) have a lookup the refined arm answers for: an instance of a class,
2075
+ # or a class object (issue #1752), not `Dynamic`?
2076
+ def refined_receiver?(receiver)
2039
2077
  members = receiver.is_a?(Type::Union) ? receiver.members : [receiver]
2040
- members.any? { |member| !refined_receiver_class_name(member).nil? }
2078
+ members.any? { |member| member.is_a?(Type::Singleton) || !refined_receiver_class_name(member).nil? }
2041
2079
  end
2042
2080
 
2043
2081
  def refined_receiver_result(node, receiver, list, arg_types, block_type)
@@ -2053,6 +2091,9 @@ module Rigor
2053
2091
  end
2054
2092
 
2055
2093
  def refined_member_result(node, receiver, list, arg_types, block_type)
2094
+ return refined_class_object_result(node, receiver, list, arg_types, block_type) if
2095
+ receiver.is_a?(Type::Singleton)
2096
+
2056
2097
  class_name = refined_receiver_class_name(receiver)
2057
2098
  return nil if class_name.nil?
2058
2099
 
@@ -2064,6 +2105,16 @@ module Rigor
2064
2105
  dynamic_top
2065
2106
  end
2066
2107
 
2108
+ # Issue #1752 — a call on a class object, walked through its singleton levels. A refinement that wins answers
2109
+ # `Dynamic[top]`, never its refine body's type: a hook on any ancestor (an RBS-declared `include Singleton`, a
2110
+ # gem concern, `define_singleton_method(:included)`, a `Module#included` patch) can install the class object's
2111
+ # own method where no table sees it, and the body's type then fires downstream on correct code. A walk that finds
2112
+ # no winner keeps the class object's own answer (`Integer.sqrt(16)` stays `Integer`).
2113
+ def refined_class_object_result(node, receiver, list, _arg_types, _block_type)
2114
+ winner = RefinedDispatch.winner(scope, receiver.class_name, node.name, list, kind: :singleton)
2115
+ dynamic_top unless winner.nil?
2116
+ end
2117
+
2067
2118
  # The winning refine body's inferred return, re-typed with the receiver's class as `self` and the call's
2068
2119
  # argument types bound as an undeclared method's are; nil when the body is not readable here.
2069
2120
  #
@@ -2082,8 +2133,9 @@ module Rigor
2082
2133
  nil
2083
2134
  end
2084
2135
 
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.
2136
+ # The class an instance-side receiver is an instance of, or nil for one this instance walk does not answer: a
2137
+ # class object (it walks its own lookup, {#refined_class_object_result}), `Dynamic`, `top`, or a shape with no
2138
+ # class.
2087
2139
  def refined_receiver_class_name(receiver)
2088
2140
  case receiver
2089
2141
  when Type::Nominal, Type::StructInstance, Type::DataInstance then receiver.class_name
@@ -2453,7 +2505,7 @@ module Rigor
2453
2505
  return every_definer_raises_type(raising, receiver, call_node, arg_types, block_type) if def_node.nil?
2454
2506
 
2455
2507
  result = infer_user_method_return(def_node, receiver, arg_types,
2456
- self_fold_safe: fold_safe_call_receiver?(call_node, receiver),
2508
+ self_fold_safe: fold_safe_call_receiver?(call_node),
2457
2509
  yield_type: block_type)
2458
2510
  return result if result.nil?
2459
2511
 
@@ -2488,11 +2540,11 @@ module Rigor
2488
2540
  # This is only a property of the receiver expression; whether the grant is actually issued also needs
2489
2541
  # a `StructInstance` carrier and a body that survives the self-use scan, both decided in
2490
2542
  # {#build_user_method_body_scope}.
2491
- def fold_safe_call_receiver?(call_node, receiver)
2543
+ def fold_safe_call_receiver?(call_node)
2492
2544
  return false if call_node.nil?
2493
2545
 
2494
2546
  case call_node.receiver
2495
- when Prism::CallNode then materialization_call?(call_node.receiver, receiver)
2547
+ when Prism::CallNode then materialization_call?(call_node.receiver)
2496
2548
  when Prism::LocalVariableReadNode then scope.struct_fold_safe?(call_node.receiver.name)
2497
2549
  when nil, Prism::SelfNode then scope.struct_fold_safe?(:self)
2498
2550
  else false
@@ -2502,10 +2554,10 @@ module Rigor
2502
2554
  # Issue #595 — the materialisation test lives ONCE, in
2503
2555
  # [`struct_materialization.rb`](method_dispatcher/struct_materialization.rb), because the direct
2504
2556
  # 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)
2557
+ # see {MethodDispatcher::StructMaterialization.materialization_call?}. A `.with` call is never one
2558
+ # (`Struct` has no `#with`, #1335), so the test needs no lookup from either consumer.
2559
+ def materialization_call?(node)
2560
+ MethodDispatcher::StructMaterialization.materialization_call?(node, scope)
2509
2561
  end
2510
2562
 
2511
2563
  # Module-singleton call resolution (ADR-57 follow-up) — resolves `Foo.<name>` on a `Singleton[Foo]`
@@ -2674,7 +2726,7 @@ module Rigor
2674
2726
  def every_definer_raises_type(defs, receiver, call_node, arg_types, block_type)
2675
2727
  return nil if defs.nil?
2676
2728
 
2677
- self_fold_safe = fold_safe_call_receiver?(call_node, receiver)
2729
+ self_fold_safe = fold_safe_call_receiver?(call_node)
2678
2730
  defs.each do |def_node|
2679
2731
  result = infer_user_method_return(def_node, receiver, arg_types,
2680
2732
  self_fold_safe: self_fold_safe, yield_type: block_type)
@@ -3865,6 +3917,12 @@ module Rigor
3865
3917
  # default — or `Dynamic` when the shape is open or absent; `**kwrest`, `&block`, and destructured
3866
3918
  # positionals bind `Dynamic`. Binding `Dynamic` is monotone-safe (wider in → wider out), so the only
3867
3919
  # declines left are the shapes where positional CORRESPONDENCE itself is unknowable.
3920
+ #
3921
+ # The scope carries no `source_path:`: the body is re-typed for call sites in any file, so a rule that
3922
+ # asks "did this file write it?" by comparing `scope.source_path` with census paths answers wrongly
3923
+ # through it. Record a per-file write kind in the census instead, as {ScopeIndexer::CONSTANT_MEMO} does
3924
+ # (#1276). Setting `source_path` here was rejected: plugins read it, and the return memo would become
3925
+ # caller-dependent.
3868
3926
  def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
3869
3927
  params = def_node.parameters
3870
3928
  locals = bind_params_from_call_types(params, arg_types)
@@ -3875,7 +3933,7 @@ module Rigor
3875
3933
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
3876
3934
  # discovery index is inherited whole by reference (ADR-53 Track A); the hand-copied per-field list
3877
3935
  # this replaces had silently dropped `data_member_layouts` and `discovered_method_visibilities`.
3878
- Scope.new(
3936
+ body_scope = Scope.new(
3879
3937
  environment: scope.environment,
3880
3938
  locals: locals.freeze,
3881
3939
  self_type: receiver,
@@ -3893,6 +3951,7 @@ module Rigor
3893
3951
  # Issue #1358 — the callee runs in a frame of its own ({Scope#with_match_frame}).
3894
3952
  match_frame: MatchRebinding::Frame.new(def_node.body, def_node.parameters)
3895
3953
  )
3954
+ body_scope.with_repeated_or_writes(RepeatedOrWrites.straight_line_sites(def_node.body, body_scope))
3896
3955
  end
3897
3956
 
3898
3957
  # Issue #681 / #707 — the ONE place in `lib/` that answers "what `Module.nesting` was recorded for this
@@ -5417,13 +5476,39 @@ module Rigor
5417
5476
  acc, rest = inject_constant_start(members, seed, has_seed)
5418
5477
  return nil if acc.nil?
5419
5478
 
5420
- rest.each do |element_value|
5421
- acc = inject_constant_step(block, acc, element_value)
5479
+ captured = inject_captured_bindings(block, rest.map { |value| Type::Combinator.constant_of(value) })
5480
+ received = [acc]
5481
+ rest.each_with_index do |element_value, position|
5482
+ acc = inject_constant_step(block, acc, element_value, captured, position)
5422
5483
  return nil if acc.nil?
5484
+
5485
+ received << acc
5423
5486
  end
5487
+ received.pop if received.size > 1
5488
+ record_inject_accumulator(block, Type::Combinator.union(*received))
5424
5489
  acc
5425
5490
  end
5426
5491
 
5492
+ # Records `type` as the accumulator `block` receives when typed from this typer's scope
5493
+ # ({.recorded_inject_accumulator}). The keys are weak, so a finished file's nodes do not stay reachable, and
5494
+ # the values strong, so a record outlives a collection while its node does. Prism nodes compare by identity.
5495
+ def record_inject_accumulator(block, type)
5496
+ (Thread.current[INJECT_ACCUMULATOR_KEY] ||= ObjectSpace::WeakKeyMap.new)[block] = [scope, type].freeze
5497
+ end
5498
+
5499
+ # Issue #1332 — the #587 (b) captured-block binding ({#captured_block_bindings}) both inject arms lay under
5500
+ # every step, as the Tuple / Range per-element folds and the per-pair `HashShape` fold do. Without it each
5501
+ # step reads a captured variable at its entry binding, so a body that rebinds or mutates one (`n += 1`,
5502
+ # `log << e`, `cache[:k] ||= e`) pinned the result: `n = 0; [1, 2].inject(0) { |a, e| r = a + n; n += 1;
5503
+ # r }` folded to `0` where Ruby answers `1`. The fixpoint binds the accumulator parameter to `Dynamic[top]`
5504
+ # rather than to a type the inject fixpoint has not converged yet: a captured name that reads the
5505
+ # accumulator widens rather than pins. `element_types` is the constant arm's steps, one position per block
5506
+ # run, which {RepeatedOrWrites} reads; nil for the nominal arm, which marks every repeated site.
5507
+ def inject_captured_bindings(block, element_types, element: nil)
5508
+ element ||= Type::Combinator.union(*element_types)
5509
+ captured_block_bindings(block, [Type::Combinator.untyped, element], element_types: element_types)
5510
+ end
5511
+
5427
5512
  # Extracts the receiver's foldable constant values, size-capped before enumeration, or nil to decline.
5428
5513
  def inject_constant_members(receiver)
5429
5514
  case receiver
@@ -5475,9 +5560,9 @@ module Rigor
5475
5560
  # Evaluates the block body once with the running constant accumulator + the next constant element
5476
5561
  # bound to the block params, returning the result when it is a foldable `Constant` within the
5477
5562
  # magnitude cap, else nil to decline the whole fold.
5478
- def inject_constant_step(block, acc, element_value)
5563
+ def inject_constant_step(block, acc, element_value, captured, position)
5479
5564
  element = Type::Combinator.constant_of(element_value)
5480
- result = type_block_body_with_param(block, [acc, element])
5565
+ result = type_block_body_with_param(block, [acc, element], captured: captured, position: position)
5481
5566
  return nil unless result.is_a?(Type::Constant)
5482
5567
  return nil unless inject_foldable?(result.value)
5483
5568
  return nil if inject_magnitude_too_large?(result.value)
@@ -5507,17 +5592,20 @@ module Rigor
5507
5592
  seed_acc = has_seed ? seed : element
5508
5593
  return nil if seed_acc.nil?
5509
5594
 
5595
+ captured = inject_captured_bindings(block, nil, element: element)
5510
5596
  converged = BodyFixpoint.converge(
5511
5597
  names: [:__inject_acc__],
5512
5598
  seed_bindings: { __inject_acc__: seed_acc },
5513
5599
  widen: method(:widen_value_pinned),
5514
5600
  evaluate_body: lambda do |bindings|
5515
5601
  acc = bindings[:__inject_acc__]
5516
- result = type_block_body_with_param(block, [acc, element])
5602
+ result = type_block_body_with_param(block, [acc, element], captured: captured)
5517
5603
  result.nil? ? {} : { __inject_acc__: result }
5518
5604
  end
5519
5605
  )
5520
- converged[:__inject_acc__] || seed_acc
5606
+ result = converged[:__inject_acc__] || seed_acc
5607
+ record_inject_accumulator(block, Type::Combinator.union(seed_acc, result))
5608
+ result
5521
5609
  end
5522
5610
 
5523
5611
  def assemble_per_element_result(method_name, per_position, element_types)
@@ -223,6 +223,12 @@ module Rigor
223
223
  end || Type::Combinator.untyped
224
224
  end
225
225
 
226
+ # Issue #1596 — true when `name` runs code chosen by name or by a String, or a block or String it is given
227
+ # ({CODE_RUNNING_NAMES}, {BLOCK_OR_CODE_NAMES}), whatever its receiver.
228
+ def code_running_name?(name)
229
+ CODE_RUNNING_NAMES.include?(name) || BLOCK_OR_CODE_NAMES.include?(name)
230
+ end
231
+
226
232
  # True when `method_name` on a value of `type` may run foreign code ({.foreign_target?}).
227
233
  def type_method_foreign?(type, method_name, scope)
228
234
  targets = ProjectMethodOwnership.targets(type)
@@ -326,8 +332,7 @@ module Rigor
326
332
  private_class_method :scan, :guarded_ivar_write?, :scan_call, :receiver_targets, :foreign_target?,
327
333
  :universal_delegate_foreign?, :method_owner, :deferred_block_call?,
328
334
  :compound_write_foreign?,
329
- :compound_receiver_type, :compound_accessors, :compound_read_type, :variable_type,
330
- :type_method_foreign?
335
+ :compound_receiver_type, :compound_accessors, :compound_read_type, :variable_type
331
336
  end
332
337
 
333
338
  module GuardRebinding
@@ -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
 
@@ -45,7 +45,7 @@ module Rigor
45
45
  # `X.send(:define_method, :m, blk)`), whose body may be a block the file wrote anywhere, an eval of a
46
46
  # String, whose code may do any of these, and a `const_set`, which may make a class a `class` body reopens;
47
47
  # - one name: a `def` of it anywhere (`def self.gets` at the top level defines it on `main`, which
48
- # {BlockCallTiming.project_defines_anywhere?} does not record), and a Symbol or String literal naming it,
48
+ # {BlockCallTiming.project_defines_anywhere?} does not count), and a Symbol or String literal naming it,
49
49
  # which a macro may define it through (`def_delegators :@io, :gets`, `attr_reader :gets`,
50
50
  # `undef_method :gets`), other than `&:gets` and the first argument of a call that only names a method
51
51
  # (`respond_to?(:gets)`, `send(:gets)`).
@@ -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