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
@@ -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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/node_children"
6
+
7
+ module Rigor
8
+ module Inference
9
+ module ProjectPatchedScanner
10
+ # Finds the `def`s of a `pre_eval:` file written under a definedness guard naming the same method
11
+ # (`def foo = 1 unless method_defined?(:foo)`): polyfills that run only when nothing bound the name before.
12
+ module DefinednessGuard
13
+ DEFINEDNESS_PREDICATES = %i[
14
+ method_defined? private_method_defined? public_method_defined? protected_method_defined? respond_to?
15
+ ].freeze
16
+ INSTANCE_METHOD_LISTS = %i[
17
+ instance_methods public_instance_methods private_instance_methods protected_instance_methods
18
+ ].freeze
19
+ private_constant :DEFINEDNESS_PREDICATES, :INSTANCE_METHOD_LISTS
20
+
21
+ module_function
22
+
23
+ # The `def` nodes (by identity) that sit on the "name not yet defined" side of a definedness guard naming the
24
+ # same method: `def foo = 1 unless method_defined?(:foo)`, or the same inside an `unless` / `if !` block.
25
+ def defs(root)
26
+ found = {}.compare_by_identity
27
+ collect_guarded(root, found)
28
+ found
29
+ end
30
+
31
+ def collect_guarded(node, found)
32
+ return unless node.is_a?(Prism::Node)
33
+
34
+ if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
35
+ name, negated = definedness_guard(node.predicate)
36
+ mark_guarded_defs(undefined_branch(node, negated), name, found) if name
37
+ end
38
+ node.rigor_each_child { |child| collect_guarded(child, found) }
39
+ end
40
+ private_class_method :collect_guarded
41
+
42
+ # `[method_name, negated]` for a predicate like `method_defined?(:m)` / `!respond_to?(:m)`, else nil.
43
+ def definedness_guard(predicate)
44
+ predicate = predicate.body if predicate.is_a?(Prism::ParenthesesNode) && predicate.body
45
+ predicate = predicate.body.first if predicate.is_a?(Prism::StatementsNode) && predicate.body.size == 1
46
+ if predicate.is_a?(Prism::CallNode) && predicate.name == :! && predicate.receiver
47
+ name, negated = definedness_guard(predicate.receiver)
48
+ return name && [name, !negated]
49
+ end
50
+ name = guard_method_name(predicate)
51
+ name && [name, false]
52
+ end
53
+ private_class_method :definedness_guard
54
+
55
+ def guard_method_name(call)
56
+ return unless call.is_a?(Prism::CallNode)
57
+
58
+ listed = %i[include? member?].include?(call.name)
59
+ listed &&= call.receiver.is_a?(Prism::CallNode) && INSTANCE_METHOD_LISTS.include?(call.receiver.name)
60
+ return unless listed || DEFINEDNESS_PREDICATES.include?(call.name)
61
+
62
+ arg = call.arguments&.arguments&.first
63
+ arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
64
+ end
65
+ private_class_method :guard_method_name
66
+
67
+ # The branch that runs when the guarded name is NOT defined yet.
68
+ def undefined_branch(node, negated)
69
+ branch_when_true = node.is_a?(Prism::IfNode) ? node.statements : node.else_clause
70
+ branch_when_false = node.is_a?(Prism::IfNode) ? node.subsequent : node.statements
71
+ # The predicate is true when the name is defined (unless negated).
72
+ negated ? branch_when_true : branch_when_false
73
+ end
74
+ private_class_method :undefined_branch
75
+
76
+ def mark_guarded_defs(node, name, found)
77
+ return unless node.is_a?(Prism::Node)
78
+
79
+ found[node] = true if node.is_a?(Prism::DefNode) && node.name == name
80
+ node.rigor_each_child { |child| mark_guarded_defs(child, name, found) } unless node.is_a?(Prism::DefNode)
81
+ end
82
+ private_class_method :mark_guarded_defs
83
+ end
84
+ end
85
+ end
86
+ end
@@ -36,14 +36,14 @@ module Rigor
36
36
  attr_reader :by_key, :write_census
37
37
 
38
38
  # @param entries — flat list of declarations observed during the pre-pass.
39
- # First-write-wins on `(class_name, method_name, kind)` duplicates so the
39
+ # Last-write-wins on `(class_name, method_name, kind)` duplicates, as at runtime (ADR-17); the
40
40
  # `pre-eval.duplicate-declaration` diagnostic emission stays decoupled from registry behaviour.
41
41
  # @param write_census — issue #1367: the `pre_eval:` files' {GlobalWriteCensus}, which the `global.*` write
42
42
  # rules join with the project's, since a patch file is loaded ahead of the code it patches.
43
43
  def initialize(entries: [], write_census: GlobalWriteCensus::EMPTY)
44
44
  @by_key = entries.each_with_object({}) do |entry, acc|
45
45
  key = [entry.class_name, entry.method_name, entry.kind]
46
- acc[key] ||= entry
46
+ acc[key] = entry
47
47
  end.freeze
48
48
  @write_census = write_census.frozen? ? write_census : write_census.dup.freeze
49
49
  freeze
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "project_patched_methods"
6
+ require_relative "project_patched_definedness_guard"
6
7
  require_relative "../analysis/dependency_source_inference/return_type_heuristic"
7
8
  require_relative "../source/alias_names"
8
9
  require_relative "../source/constant_path"
@@ -35,6 +36,13 @@ module Rigor
35
36
  PendingAlias = Data.define(:class_name, :new_name, :old_name, :kind, :source_path, :source_line)
36
37
  private_constant :PendingAlias
37
38
 
39
+ # A `def` written under a definedness guard (`def foo ... unless method_defined?(:foo)`): a polyfill that runs
40
+ # only when nothing bound the name before it, so it never replaces an earlier binding.
41
+ GuardedDef = Data.define(:entry) do
42
+ def source_path = entry.source_path
43
+ end
44
+ private_constant :GuardedDef
45
+
38
46
  module_function
39
47
 
40
48
  # @param paths — absolute paths to the pre-eval files. The runner has already
@@ -58,29 +66,28 @@ module Rigor
58
66
  end
59
67
 
60
68
  # ADR-17 § "Failure modes" — when two pre-eval entries declare the same `(class_name, method_name,
61
- # kind)` triple, emit one `:info` `pre-eval.duplicate-declaration` diagnostic per collision. The
62
- # registry's first-write-wins behaviour is unchanged; the diagnostic just makes the shadowing
63
- # visible so users notice when a later patch is silently masked.
69
+ # kind)` triple, the last-listed one wins, as at runtime where the later `def` replaces the earlier. Emit
70
+ # one `:info` `pre-eval.duplicate-declaration` diagnostic per SHADOWED entry, reported on that earlier
71
+ # declaration (the declaration that never takes effect) and naming the
72
+ # winning declaration it yields to.
64
73
  def duplicate_declaration_diagnostics(entries)
65
- seen = {}
66
- entries.each_with_object([]) do |entry, acc|
67
- key = [entry.class_name, entry.method_name, entry.kind]
68
- if (first = seen[key])
69
- acc << build_diagnostic(
70
- path: entry.source_path,
71
- line: entry.source_line,
72
- column: 1,
73
- severity: :info,
74
- rule: "pre-eval.duplicate-declaration",
75
- message: "pre-eval duplicate declaration: " \
76
- "#{entry.class_name}##{entry.method_name} " \
77
- "(#{entry.kind}) is already declared at " \
78
- "#{first.source_path}:#{first.source_line}. " \
79
- "The first declaration wins; this entry is shadowed."
80
- )
81
- else
82
- seen[key] = entry
83
- end
74
+ winners = entries.to_h { |entry| [[entry.class_name, entry.method_name, entry.kind], entry] }
75
+ entries.filter_map do |entry|
76
+ winner = winners[[entry.class_name, entry.method_name, entry.kind]]
77
+ next if winner.equal?(entry)
78
+
79
+ build_diagnostic(
80
+ path: entry.source_path,
81
+ line: entry.source_line,
82
+ column: 1,
83
+ severity: :info,
84
+ rule: "pre-eval.duplicate-declaration",
85
+ message: "pre-eval duplicate declaration: " \
86
+ "#{entry.class_name}##{entry.method_name} " \
87
+ "(#{entry.kind}) is declared again at " \
88
+ "#{winner.source_path}:#{winner.source_line}. " \
89
+ "The last-listed declaration wins; this entry is shadowed."
90
+ )
84
91
  end
85
92
  end
86
93
  private_class_method :duplicate_declaration_diagnostics
@@ -97,14 +104,17 @@ module Rigor
97
104
  # Within one file the later binding of a name replaces the earlier, as at runtime: the alias-method-chain
98
105
  # idiom (`def foo`, `alias_method :foo_without_x, :foo`, `alias_method :foo, :foo_with_x`) leaves `foo` the
99
106
  # alias. Bindings of one name in DIFFERENT files are what `pre-eval.duplicate-declaration` reports, and the
100
- # registry keeps the first of those, as it always has for `def`s.
107
+ # registry keeps the last of those, as Ruby does.
101
108
  def resolve_aliases(collected)
102
109
  bound = {}
103
110
  per_file = collected.group_by(&:source_path).values.map do |items|
104
111
  final = {}
105
112
  items.each do |item|
106
113
  entry = item.is_a?(PendingAlias) ? alias_entry(item, bound) : item
114
+ entry = entry.entry if entry.is_a?(GuardedDef)
107
115
  key = [entry.class_name, entry.method_name, entry.kind]
116
+ next if item.is_a?(GuardedDef) && bound.key?(key)
117
+
108
118
  bound[key] = entry
109
119
  final.delete(key)
110
120
  final[key] = entry
@@ -138,6 +148,7 @@ module Rigor
138
148
  return
139
149
  end
140
150
 
151
+ Thread.current[:rigor_guarded_defs] = DefinednessGuard.defs(parse_result.value)
141
152
  walk_node(parse_result.value, [], false, path, entries)
142
153
  # Issue #1367 — the file's `global.*` write facts: a patch file may alias a special or give a class the
143
154
  # method a setter asks for, as any project file may.
@@ -151,6 +162,8 @@ module Rigor
151
162
  "#{e.class}: #{e.message}. Pre-evaluation skipped for this file; " \
152
163
  "the rest of the run proceeds."
153
164
  )
165
+ ensure
166
+ Thread.current[:rigor_guarded_defs] = nil
154
167
  end
155
168
  private_class_method :scan_file
156
169
 
@@ -233,11 +246,12 @@ module Rigor
233
246
  kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
234
247
  line = node.location&.start_line || 1
235
248
  return_type = Analysis::DependencySourceInference::ReturnTypeHeuristic.extract(node)
236
- entries << ProjectPatchedMethods::Entry.new(
249
+ entry = ProjectPatchedMethods::Entry.new(
237
250
  class_name: class_name, method_name: node.name, kind: kind,
238
251
  source_path: source_path, source_line: line,
239
252
  return_type: return_type
240
253
  )
254
+ entries << (Thread.current[:rigor_guarded_defs]&.key?(node) ? GuardedDef.new(entry: entry) : entry)
241
255
  end
242
256
  private_class_method :record_def_node
243
257