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
@@ -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
@@ -29,7 +29,7 @@ module Rigor
29
29
  # `StructInstance`, the member map built from the call's arguments (positional or keyword per the
30
30
  # class's `keyword_init`). A form / arity mismatch degrades to `Dynamic[top]` rather than a wrong
31
31
  # member map. `.members` on the class folds.
32
- # 3. member reads + `[]` / `to_h` / `deconstruct` / `deconstruct_keys` / `members` / `with` on a
32
+ # 3. member reads + `[]` / `to_h` / `deconstruct` / `deconstruct_keys` / `members` on a
33
33
  # *fresh* `StructInstance` -> the precise projected type; member *setters* (`s.x = v`) return the
34
34
  # assigned value type. Unhandled / stored-receiver calls return nil so the pipeline projects the
35
35
  # instance to its nominal through RbsDispatch.
@@ -38,8 +38,9 @@ module Rigor
38
38
  module StructFolding
39
39
  module_function
40
40
 
41
- # The `[]` / `to_h` / `deconstruct` / `members` / `with` projections
41
+ # The `[]` / `to_h` / `deconstruct` / `members` projections
42
42
  # and the reader-redefinition guard are shared with {DataFolding}.
43
+ # `Struct` has no `#with` (issue #1335): that projection stays on {DataFolding} only.
43
44
  extend MemberShapeProjection
44
45
 
45
46
  # Issue #595 / #525 — the shared materialisation test; see {StructMaterialization}.
@@ -239,10 +240,6 @@ module Rigor
239
240
  when :deconstruct then instance_deconstruct(instance)
240
241
  when :deconstruct_keys then instance_deconstruct_keys(instance, args)
241
242
  when :members then instance_members(instance)
242
- when :with
243
- instance_with(instance, args) do |members, class_name|
244
- Type::Combinator.struct_instance_of(members: members, class_name: class_name)
245
- end
246
243
  end
247
244
  end
248
245
 
@@ -332,7 +329,7 @@ module Rigor
332
329
  node = context.call_node
333
330
  return false if node.nil?
334
331
 
335
- materialization_call?(node.receiver, context.receiver, context.scope)
332
+ materialization_call?(node.receiver, context.scope)
336
333
  end
337
334
 
338
335
  # ADR-48 slice 4 — precise mutated-member re-typing. After a `local.member = v` setter on a
@@ -34,18 +34,20 @@ module Rigor
34
34
  # Issue #595 / #525 — the ONE materialisation test. Both places that must answer "was this receiver
35
35
  # expression's struct newly built?" go through it: this module's direct member-read gate above, and
36
36
  # `ExpressionTyper`'s caller-side `:self`-grant arm. They had forked answers once (the grant arm was
37
- # tightened for #591 while this one still trusted any chained call), which is exactly how the
38
- # composed `x.dup_self.with(indent: 9).shout` shape survived — a correctly whitelisted `.with` fold
39
- # firing off a receiver the other gate should never have called fresh.
37
+ # tightened for #591 while this one still trusted any chained call), so both consumers must share this
38
+ # one answer. A `.with` call is never a materialisation: the composed `x.dup_self.with(indent: 9)`
39
+ # shape once slipped through as one, and `Struct#with` no longer exists to fold (#1335).
40
40
  #
41
41
  # @param node — the receiver EXPRESSION.
42
- # @param receiver — the carrier the expression produced.
43
- def materialization_call?(node, receiver, scope)
42
+ # @param scope — the scope the factory and ancestor lookups resolve against.
43
+ def materialization_call?(node, scope)
44
44
  return false unless node.is_a?(Prism::CallNode)
45
45
 
46
46
  case node.name
47
47
  when :new, :[] then struct_class_expression?(node.receiver, scope)
48
- when :with then !hand_written_with?(receiver, scope)
48
+ # `Struct` has no `#with` (#1335), so a `.with` call is never a materialisation here; `Data#with` is
49
+ # a frozen copy and needs no freshness gate.
50
+ when :with then false
49
51
  else fresh_factory_call?(node, scope)
50
52
  end
51
53
  end
@@ -127,26 +129,6 @@ module Rigor
127
129
  found
128
130
  end
129
131
 
130
- # `.with` copies the receiver into a new instance — unless the struct wrote its own, which is free to
131
- # return `self` and would reopen the very hole above.
132
- #
133
- # An ANONYMOUS carrier cannot have one: a `StructInstance` with no class name came from a blockless
134
- # `Struct.new(…)` (the block form defers in {fold_struct_new} before any instance exists), and a
135
- # blockless factory defines nothing but the generated accessors. So it is the NAMED carriers that
136
- # need the lookup — and a named one with no scope to ask refuses, since unproven is not fresh.
137
- def hand_written_with?(receiver, scope)
138
- return true unless receiver.is_a?(Type::StructInstance)
139
-
140
- class_name = receiver.class_name
141
- return false if class_name.nil?
142
- return true if scope.nil?
143
-
144
- # The ANCESTOR walk, not the own-class table: a `with` contributed by an included module is just as
145
- # free to return `self`. Both consumers reach the identical answer because there is one lookup here
146
- # rather than a parameter each of them supplies (#598 review).
147
- !scope.user_def_through_ancestors(class_name, :with).first.nil?
148
- end
149
-
150
132
  # An expression naming the struct class itself. All three definition forms ADR-48 supports qualify,
151
133
  # because each of them really does construct: a constant whose member layout the project side-table
152
134
  # recorded (`Point.new(…)`), a local holding a `StructClass` carrier (`c = Struct.new(:x, :y)` then
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Issue #1357 — `Kernel#Rational(x, 1)` converts its argument. CRuby's `nurat_convert` returns a
9
+ # `Rational` for a `Float` / `Integer` / `Rational` / `String`-free numerator and for a `BigDecimal` (which
10
+ # has `to_r`; NaN and Infinity raise). A `Complex` is unwrapped only when its imaginary part is an exact
11
+ # zero, otherwise `a / 1` comes back, a `Complex`, so a `Complex` numerator answers `Rational | Complex`.
12
+ # The core RBS spells the `1`-denominator form `[T < Numeric] (T value, 1) -> T`, which binds `T` to the
13
+ # argument's own class and read `Rational(f, 1)` as a `Float`. Any other numerator (a user `Numeric`
14
+ # without `to_r`) keeps the RBS answer.
15
+ module UnitDenominatorRational
16
+ module_function
17
+
18
+ CONVERTING = Set["Float", "Integer", "Rational", "BigDecimal"].freeze
19
+ private_constant :CONVERTING
20
+
21
+ # Answers `Rational` (or `Rational | Complex`), or nil when the call is not the unit-denominator form.
22
+ def answer(method_name, args)
23
+ return nil unless method_name == :Rational && args.size.between?(2, 3)
24
+ return nil if args.size == 3 && !exception_keyword?(args[2])
25
+
26
+ denominator = args[1]
27
+ return nil unless denominator.is_a?(Type::Constant) && denominator.value.eql?(1)
28
+
29
+ classes = numerator_classes(args.first)
30
+ return nil if classes.nil?
31
+
32
+ members = [Type::Combinator.nominal_of("Rational")]
33
+ members << Type::Combinator.nominal_of("Complex") if classes.include?(:complex)
34
+ Type::Combinator.union(*members)
35
+ end
36
+
37
+ # The trailing `exception:` keyword hash: a `HashShape` whose only key is `:exception`. Its value
38
+ # does not change the answer (a Float that cannot convert raises either way).
39
+ def exception_keyword?(type)
40
+ type.is_a?(Type::HashShape) && type.pairs.keys == [:exception]
41
+ end
42
+
43
+ # `[:converting]`, `[:complex]` or both for a recognised numerator; nil when any member is unknown.
44
+ def numerator_classes(type)
45
+ case type
46
+ when Type::Union then union_classes(type)
47
+ when Type::IntegerRange then [:converting]
48
+ when Type::Refined then type.base.is_a?(Type::Nominal) ? numerator_classes(type.base) : nil
49
+ when Type::Constant then class_kind(type.value.class.name)
50
+ when Type::Nominal then type.type_args.empty? ? class_kind(type.class_name) : nil
51
+ end
52
+ end
53
+
54
+ def union_classes(union)
55
+ kinds = union.members.map { |member| numerator_classes(member) }
56
+ kinds.any?(&:nil?) ? nil : kinds.flatten.uniq
57
+ end
58
+
59
+ def class_kind(name)
60
+ return [:complex] if name == "Complex"
61
+
62
+ CONVERTING.include?(name) ? [:converting] : nil
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -11,6 +11,7 @@ require_relative "dynamic_origin"
11
11
  require_relative "flow_tracer"
12
12
  require_relative "void_tail_summary"
13
13
  require_relative "method_dispatcher/call_context"
14
+ require_relative "method_dispatcher/argument_nodes"
14
15
  require_relative "method_dispatcher/constant_folding"
15
16
  require_relative "method_dispatcher/literal_string_folding"
16
17
  require_relative "method_dispatcher/shape_dispatch"
@@ -39,6 +40,7 @@ require_relative "method_dispatcher/kernel_dispatch"
39
40
  require_relative "method_dispatcher/universal_object_dispatch"
40
41
  require_relative "method_dispatcher/singleton_mixin_dispatch"
41
42
  require_relative "method_dispatcher/method_folding"
43
+ require_relative "method_dispatcher/optional_count"
42
44
 
43
45
  module Rigor
44
46
  module Inference
@@ -105,6 +107,16 @@ module Rigor
105
107
  call_node: nil, scope: nil, plugin_typed_sink: nil, skip_project_patched: false)
106
108
  return nil if receiver_type.nil?
107
109
 
110
+ # Issue #1393 — a `nil` count on `min` / `max` / `min_by` / `max_by` is the no-count call.
111
+ nil_count = OptionalCount.split(receiver_type, method_name, arg_types, block: !block_type.nil?)
112
+ if nil_count
113
+ return resolve_nil_count(nil_count.first, receiver_type: receiver_type, method_name: method_name,
114
+ block_type: block_type, environment: environment,
115
+ call_node: call_node, scope: scope,
116
+ plugin_typed_sink: plugin_typed_sink,
117
+ skip_project_patched: skip_project_patched)
118
+ end
119
+
108
120
  # Build the call context once and thread it — unchanged — through every tier
109
121
  # (`_DispatchTier#try_dispatch`). The dispatcher's own private fallback tiers still read
110
122
  # the positional locals below; only the tier modules consume the context object.
@@ -288,6 +300,20 @@ module Rigor
288
300
  try_composite_receiver(context)
289
301
  end
290
302
 
303
+ # The `nil` part of an optional count dispatches as the no-argument call, the rest as written; a count
304
+ # that is both answers the union of the two, or declines when either declines. See {OptionalCount}.
305
+ def resolve_nil_count(rest, **common)
306
+ return resolve(arg_types: [], **common) if rest.nil?
307
+
308
+ # The counted leg runs first: a leg's plugin answer records on the scope (or buffers in the sink, #1101),
309
+ # and a declining counted leg must not leave the no-argument leg's record behind for a call that answers nil.
310
+ counted = resolve(arg_types: [rest], **common)
311
+ return nil if counted.nil?
312
+
313
+ without = resolve(arg_types: [], **common)
314
+ without && Type::Combinator.union(without, counted)
315
+ end
316
+
291
317
  # ADR-100 WD4 — records the transitive `-> void` provenance when the called method is a
292
318
  # discovered project `def` whose {VoidTailSummary} chain ends in an author-declared `-> void`
293
319
  # leaf on the same owner. Guards cheapest-first: the internal dispatcher callers nil `scope` /
@@ -1170,7 +1196,7 @@ module Rigor
1170
1196
  set_lift = set_new_lift(receiver_type.class_name, arg_types)
1171
1197
  return set_lift if set_lift
1172
1198
 
1173
- hash_lift = hash_new_lift(receiver_type.class_name, arg_types)
1199
+ hash_lift = hash_new_lift(receiver_type.class_name, arg_types, context&.call_node)
1174
1200
  return hash_lift if hash_lift
1175
1201
 
1176
1202
  regexp_lift = regexp_new_lift(receiver_type.class_name, arg_types)
@@ -1445,9 +1471,15 @@ module Rigor
1445
1471
  # form (`Hash.new { |h, k| … }`) keep the bare `Nominal[Hash]` answer — the block's value
1446
1472
  # type is not available at this `:new` dispatch site, and conservatively leaving the read
1447
1473
  # as today's behaviour is precision-additive.
1448
- def hash_new_lift(class_name, arg_types)
1474
+ #
1475
+ # Issue #1812 — a brace-less keyword hash is `Hash#initialize`'s `capacity:` keyword, not the
1476
+ # default (`Hash.new(capacity: 10).default` is `nil`), so it is dropped before counting the
1477
+ # positionals. A braced hash (`Hash.new({ capacity: 10 })`) is a positional and stays the default.
1478
+ def hash_new_lift(class_name, arg_types, call_node = nil)
1449
1479
  return nil unless class_name == "Hash"
1450
- return nil unless arg_types.size == 1
1480
+
1481
+ arg_types = hash_new_positionals(arg_types, call_node)
1482
+ return nil unless arg_types&.size == 1
1451
1483
 
1452
1484
  default = arg_types.first
1453
1485
  return nil if default.nil?
@@ -1456,6 +1488,23 @@ module Rigor
1456
1488
  Type::Combinator.nominal_of("Hash", type_args: [Type::Combinator.untyped, value])
1457
1489
  end
1458
1490
 
1491
+ # `Hash.new`'s positional argument types: `arg_types` without a trailing brace-less keyword hash. A literal
1492
+ # send (`Hash.send(:new, capacity: 1)`) dispatches with the outer call node, whose arguments are the
1493
+ # selector symbol followed by `arg_types`' own, and is read through that offset. When the node's arguments
1494
+ # correspond otherwise, a sole hash argument may be the keyword hash, so the answer is nil (decline) and the
1495
+ # bare `Hash` stands; any other argument cannot be a keyword hash and is kept.
1496
+ def hash_new_positionals(arg_types, call_node)
1497
+ return arg_types[0...-1] if ArgumentNodes.keyword_hash_last?(call_node, arg_types)
1498
+
1499
+ arguments = call_node.respond_to?(:arguments) ? call_node.arguments&.arguments : nil
1500
+ return arg_types if arguments.nil? || arguments.size == arg_types.size
1501
+ if arguments.size == arg_types.size + 1 && arguments.first.is_a?(Prism::SymbolNode)
1502
+ return arguments.last.is_a?(Prism::KeywordHashNode) ? arg_types[0...-1] : arg_types
1503
+ end
1504
+
1505
+ arg_types.last.is_a?(Type::HashShape) ? nil : arg_types
1506
+ end
1507
+
1459
1508
  # `Range.new(b, e)` / `Range.new(b, e, excl)` — folds to `Constant[Range]` when both
1460
1509
  # endpoints are `Constant[Integer]` or both are `Constant[String]`, and the optional third
1461
1510
  # argument is a `Constant[true/false]`. Nil endpoints (beginless / endless ranges) are not
@@ -11,8 +11,8 @@ module Rigor
11
11
  # [#1197](https://github.com/rigortype/rigor/issues/1197)). Four readers used to compute it in four
12
12
  # places. They disagree with each other and, in places, with Ruby. Each entry point below reproduces
13
13
  # one reader's answer exactly: this module gives the readings one place to live, and does not yet make
14
- # them agree. A change to one reading lands here, beside the other three. Examples are #1550's
15
- # named-form snapshot and the reset a bare `public`, `private` or `protected` makes.
14
+ # them agree. A change to one reading lands here, beside the other three. An example is the reset a bare
15
+ # `public`, `private` or `protected` makes.
16
16
  #
17
17
  # - The singleton def-node table (`ScopeIndexer#walk_singleton_body`) reads
18
18
  # {.each_singleton_sibling} and {.each_singleton_copy}. A bare call covers the later direct
@@ -78,23 +78,49 @@ module Rigor
78
78
  end
79
79
  end
80
80
 
81
- # The defs a named call copies onto the module's singleton, for the singleton def-node table. Yields
82
- # `name, def_node` for each Symbol or String argument that names a receiverless def among the body's
83
- # direct `statements`. The last def of that name wins wherever it sits. A def written after the call
84
- # is therefore taken, although Ruby copies the one in effect at the call (#1550), and a name defined
85
- # only after the call resolves, although Ruby raises `NameError` there. A `module_function def x`
86
- # argument names nothing here.
81
+ # The defs a named call copies onto the module's singleton, for the singleton def-node table. Ruby
82
+ # copies the method in effect at the call (#1550), so a later def of the name redefines only the
83
+ # instance method. This reading approximates that from the body's direct `statements`: it yields
84
+ # `name, def_node` for each Symbol or String argument, where `def_node` is the last receiverless def
85
+ # of that name written as a direct statement before the call.
86
+ #
87
+ # A name declines (nothing is yielded) when no def precedes the call, where Ruby raises `NameError` or
88
+ # copies a def another file may load first, and when a statement after that def and before the call
89
+ # may rebind it in a way this reading cannot name: a def nested in control flow or a block, an `alias`,
90
+ # an `undef`, or a receiverless or `self.` `alias_method`, `define_method`, `attr_*`, `remove_method`
91
+ # or `undef_method` call. Such a call or `alias` with a non-literal name anywhere before the call, even
92
+ # before the def, declines every name. A `module_function def x` argument names nothing here.
93
+ #
94
+ # It does not see `send(:define_method, ...)`, a `define_method` on an explicit module receiver, a
95
+ # `prepend`, another file's singleton defs, or that a `rescue` clause's statements may run after an
96
+ # `ensure` call's (#1969).
87
97
  def each_singleton_copy(call, statements)
88
- defs_by_name = statements.each_with_object({}) do |stmt, acc|
89
- acc[stmt.name] = stmt if stmt.is_a?(Prism::DefNode) && stmt.receiver.nil?
98
+ call_start = call.location.start_offset
99
+ in_effect = {}
100
+ statements.each do |stmt|
101
+ next unless stmt.location.end_offset <= call_start
102
+
103
+ if stmt.is_a?(Prism::DefNode)
104
+ in_effect[stmt.name] = stmt if stmt.receiver.nil?
105
+ else
106
+ each_rebound_name(stmt) { |name| in_effect[name] = :unknown }
107
+ end
90
108
  end
91
109
  call.arguments&.arguments&.each do |arg|
92
110
  name = symbol_argument_name(arg)
93
- def_node = name && defs_by_name[name]
94
- yield name, def_node if def_node
111
+ def_node = name && !in_effect.key?(ANY_NAME) && in_effect[name]
112
+ yield name, def_node if def_node.is_a?(Prism::DefNode)
95
113
  end
96
114
  end
97
115
 
116
+ # The key {.each_singleton_copy} records when a statement may rebind a name it cannot read.
117
+ ANY_NAME = Object.new.freeze
118
+
119
+ # Calls that may define, alias or remove an instance method of the receiverless module.
120
+ REBINDING_CALLS = %i[
121
+ alias_method define_method attr attr_reader attr_writer attr_accessor remove_method undef_method
122
+ ].freeze
123
+
98
124
  # The deferred-ranges reading, over one class-ish body's subtree. Appends to `offsets` the start of
99
125
  # every bare call; each def that starts after one is a module function
100
126
  # ({.deferred_module_function?}). Appends to `ranges` one row per named call, in the table's
@@ -173,6 +199,46 @@ module Rigor
173
199
  active && kind == :instance
174
200
  end
175
201
 
202
+ # Yields each instance-method name `node` may rebind on the module whose body holds it, or
203
+ # {ANY_NAME} when one is not a literal. It does not enter a nested class, module or `class <<` body,
204
+ # whose methods belong to another module.
205
+ def each_rebound_name(node, &)
206
+ case node
207
+ when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then nil
208
+ when Prism::DefNode then yield node.name if node.receiver.nil?
209
+ else
210
+ each_own_rebound_name(node, &)
211
+ node.compact_child_nodes.each { |child| each_rebound_name(child, &) }
212
+ end
213
+ end
214
+
215
+ # The names `node` itself may rebind, apart from its children: an `alias`, an `undef` or a
216
+ # {REBINDING_CALLS} call on the module.
217
+ def each_own_rebound_name(node, &)
218
+ case node
219
+ when Prism::AliasMethodNode then yield symbol_argument_name(node.new_name) || ANY_NAME
220
+ when Prism::UndefNode then node.names.each { |name| yield symbol_argument_name(name) || ANY_NAME }
221
+ when Prism::CallNode
222
+ each_rebinding_call_name(node, &) if REBINDING_CALLS.include?(node.name) &&
223
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
224
+ end
225
+ end
226
+
227
+ # The names a {REBINDING_CALLS} call may rebind: the first argument of `alias_method` and
228
+ # `define_method`, every argument of the others (and its writer, for the `attr_*` family), or
229
+ # {ANY_NAME} for a non-literal one.
230
+ def each_rebinding_call_name(call)
231
+ arguments = call.arguments&.arguments || []
232
+ arguments = arguments.first(1) if %i[alias_method define_method].include?(call.name)
233
+ arguments.each do |arg|
234
+ name = symbol_argument_name(arg)
235
+ next yield(ANY_NAME) unless name
236
+
237
+ yield name
238
+ yield :"#{name}=" if call.name.start_with?("attr")
239
+ end
240
+ end
241
+
176
242
  # The prescan's call arm.
177
243
  def prescan_call(node, qualified_prefix, in_singleton_class, offsets, ranges)
178
244
  if bare?(node)
@@ -208,7 +274,8 @@ module Rigor
208
274
  Source::Literals.symbol_or_string(arg)
209
275
  end
210
276
 
211
- private_class_method :prescan_call, :prescan_eval_call, :symbol_argument_name
277
+ private_class_method :prescan_call, :prescan_eval_call, :symbol_argument_name, :each_rebound_name,
278
+ :each_own_rebound_name, :each_rebinding_call_name
212
279
  end
213
280
  end
214
281
  end