rigortype 0.3.1 → 0.3.2

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -20,12 +20,31 @@ module Rigor
20
20
  # decode. Returns `Constant[String]`.
21
21
  # * `encode_uri_component(str)` / `decode_uri_component(str)` — Same encoding but may preserve
22
22
  # additional reserved chars (Ruby 3.2+). Returns `Constant[String]`.
23
+ # * `encode_www_form(enum)` — the whole-form encoder, over a `Tuple` of `[key, value]` `Tuple`s (an
24
+ # array literal) or a `HashShape` (a hash literal) whose keys and values are all `Constant`.
25
+ # Returns `Constant[String]`.
26
+ # * `decode_www_form(str)` — the inverse, over a `Constant[String]`. Returns a `Tuple` of two-element
27
+ # `Tuple[Constant[String], Constant[String]]` pairs rather than the RBS tier's
28
+ # `Array[[String, String]]`, so a destructuring read of a known form gets the concrete strings.
29
+ # * `extract(str)` — the URIs a constant string contains, as a `Tuple` of `Constant[String]` rather
30
+ # than `Array[String]`. Single-argument form only (see {.fold_extract}).
31
+ #
32
+ # === Deliberately NOT folded
33
+ #
34
+ # `URI.parse` and `URI.join` are the two remaining gaps in this module's coverage note, and both are
35
+ # declined on the repo's own existing rule rather than on judgement: they return `URI::Generic` and
36
+ # friends, which are absent from `ConstantFolding::FOLDABLE_CONSTANT_CLASSES`, so there is no
37
+ # `Constant[…]` for this tier to produce. Narrowing `URI.parse`'s ten-arm return union to the one
38
+ # scheme class a constant string selects WOULD be a real precision win, but it makes a gradually-valid
39
+ # dispatch precise and can therefore surface a diagnostic that does not fire today — which puts it in
40
+ # the bucket-3 / P0 category, not in this FP-safe fold category ([#121](
41
+ # https://github.com/rigortype/rigor/issues/121)).
23
42
  #
24
43
  # === Non-constant / unsupported cases
25
44
  #
26
45
  # Returns `nil` (deferring to the next dispatcher tier) when:
27
46
  # - the receiver is not `Singleton[URI]`,
28
- # - the first argument is not a `Constant[String]`,
47
+ # - an argument is not the constant shape the method's fold requires,
29
48
  # - the method is not in the supported set.
30
49
  module URIFolding
31
50
  URI_COMPONENT_METHODS = Set[
@@ -34,6 +53,12 @@ module Rigor
34
53
  ].freeze
35
54
  private_constant :URI_COMPONENT_METHODS
36
55
 
56
+ # A form long enough to suggest generated or accumulated data rather than a literal a reader wrote;
57
+ # past it the folded `Constant[String]` stops being useful in a diagnostic and only costs memory.
58
+ # Mirrors the byte ceiling `ShapeDispatch`'s own `tuple_join` fold applies for the same reason.
59
+ FORM_PAIR_LIMIT = 64
60
+ private_constant :FORM_PAIR_LIMIT
61
+
37
62
  module_function
38
63
 
39
64
  # @return [Rigor::Type, nil] folded result, or nil to defer.
@@ -42,11 +67,109 @@ module Rigor
42
67
  method_name = context.method_name
43
68
  args = context.args
44
69
  return nil unless SingletonFolding.receiver?(receiver, "URI")
70
+ return fold_encode_www_form(args) if method_name == :encode_www_form
71
+ return fold_decode_www_form(args) if method_name == :decode_www_form
72
+ return fold_extract(args) if method_name == :extract
45
73
  return nil unless URI_COMPONENT_METHODS.include?(method_name)
46
74
 
47
75
  fold_uri_call(method_name, args)
48
76
  end
49
77
 
78
+ # `URI.encode_www_form(enum)` — deterministic over its whole input, so it folds only when EVERY key
79
+ # and value is already a `Constant`. One non-constant pair declines the whole call: a partially
80
+ # known form has no `Constant[String]` to stand for it.
81
+ def fold_encode_www_form(args)
82
+ return nil unless args.size == 1
83
+
84
+ pairs = form_pairs(args.first)
85
+ return nil if pairs.nil? || pairs.size > FORM_PAIR_LIMIT
86
+
87
+ Type::Combinator.constant_of(URI.encode_www_form(pairs))
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ # The `[key, value]` pairs of a literal form argument, or nil when the shape is not fully known:
93
+ # a `Tuple` of two-element `Tuple`s (the array-of-pairs spelling) or a `HashShape` (the hash
94
+ # spelling). `HashShape` pairs are read only when the shape is CLOSED and carries no optional key —
95
+ # an open or partially-optional shape describes a hash whose real contents this fold cannot see.
96
+ def form_pairs(arg)
97
+ case arg
98
+ when Type::Tuple then tuple_form_pairs(arg)
99
+ when Type::HashShape then hash_form_pairs(arg)
100
+ end
101
+ end
102
+
103
+ def tuple_form_pairs(tuple)
104
+ tuple.elements.map do |element|
105
+ return nil unless element.is_a?(Type::Tuple) && element.elements.size == 2
106
+ return nil unless element.elements.all?(Type::Constant)
107
+
108
+ element.elements.map(&:value)
109
+ end
110
+ end
111
+
112
+ def hash_form_pairs(shape)
113
+ return nil unless shape.closed? && shape.optional_keys.empty?
114
+
115
+ shape.pairs.map do |key, value|
116
+ return nil unless value.is_a?(Type::Constant)
117
+
118
+ [key, value.value]
119
+ end
120
+ end
121
+
122
+ # `URI.decode_www_form(str)` — the pair list of a constant form, as a `Tuple` of two-element
123
+ # `Tuple`s so a destructuring read (`k, v = URI.decode_www_form(s).first`) reaches the concrete
124
+ # strings instead of the RBS tier's `Array[[String, String]]`.
125
+ def fold_decode_www_form(args)
126
+ return nil unless args.size == 1
127
+
128
+ str = SingletonFolding.constant_string(args.first)
129
+ return nil if str.nil?
130
+
131
+ pairs = URI.decode_www_form(str)
132
+ return nil if pairs.size > FORM_PAIR_LIMIT
133
+
134
+ Type::Combinator.tuple_of(*pairs.map { |key, value| constant_pair(key, value) })
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def constant_pair(key, value)
140
+ Type::Combinator.tuple_of(Type::Combinator.constant_of(key), Type::Combinator.constant_of(value))
141
+ end
142
+
143
+ # `URI.extract(str)` — the URIs a constant string contains, as a `Tuple` of `Constant[String]`
144
+ # instead of the RBS tier's `Array[String]`. Only the single-argument form folds: the optional
145
+ # second argument is a schema FILTER, and honouring it means reproducing `URI.extract`'s own
146
+ # argument handling rather than the one call this fold makes, so it declines instead.
147
+ #
148
+ # Ruby emits an obsolescence warning for this method under `$VERBOSE`; the fold calls it with
149
+ # warnings silenced, because a warning about the ANALYZED program's API choice must not appear on
150
+ # the analyzer's own stderr, where a reader would attribute it to Rigor.
151
+ def fold_extract(args)
152
+ return nil unless args.size == 1
153
+
154
+ str = SingletonFolding.constant_string(args.first)
155
+ return nil if str.nil?
156
+
157
+ found = silence_warnings { URI.extract(str) }
158
+ return nil if found.nil? || found.size > FORM_PAIR_LIMIT
159
+
160
+ Type::Combinator.tuple_of(*found.map { |uri| Type::Combinator.constant_of(uri) })
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def silence_warnings
166
+ previous = $VERBOSE
167
+ $VERBOSE = nil
168
+ yield
169
+ ensure
170
+ $VERBOSE = previous
171
+ end
172
+
50
173
  def fold_uri_call(method_name, args)
51
174
  return nil unless args.size == 1
52
175
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../type"
4
4
  require_relative "../source/node_children"
5
+ require_relative "receiver_alias"
5
6
 
6
7
  module Rigor
7
8
  module Inference
@@ -45,6 +46,10 @@ module Rigor
45
46
  #
46
47
  # - `arr.<mutator>(...)` where `arr` is a local variable.
47
48
  # - `@arr.<mutator>(...)` where `@arr` is an instance variable.
49
+ # - a receiver that *selects* among such variables rather than naming one —
50
+ # `(kind == :required ? required : optional)[key] = info`, an `if`/`else`, `||` / `&&`. Every
51
+ # variable the expression can evaluate to is a possible mutation target, so every one widens
52
+ # (issue #277). See {ReceiverAlias}.
48
53
  #
49
54
  # Out of scope (left for a separate cycle):
50
55
  #
@@ -100,10 +105,10 @@ module Rigor
100
105
  PURE_SELF_RETURNERS.include?(method_name)
101
106
  end
102
107
 
103
- # Returns a scope with the call's receiver widened, when the receiver is a
104
- # local-/instance-variable read whose current binding is a literal-shape carrier
108
+ # Returns a scope with the call's receiver widened, for every variable the receiver expression
109
+ # can evaluate to ({ReceiverAlias.candidates}) whose current binding is a literal-shape carrier
105
110
  # (`Tuple` / `HashShape`) or an empty-witness refinement (`non-empty-array` /
106
- # `non-empty-hash`) AND the call name is a known in-place mutator for that shape.
111
+ # `non-empty-hash`) AND whose call name is a known in-place mutator for that shape.
107
112
  # Returns `current_scope` unchanged otherwise.
108
113
  #
109
114
  # @param call_node [Prism::CallNode]
@@ -115,13 +120,8 @@ module Rigor
115
120
  receiver = call_node.receiver
116
121
  return current_scope if receiver.nil?
117
122
 
118
- case receiver
119
- when Prism::LocalVariableReadNode
120
- widen_local(call_node.name, receiver.name, current_scope)
121
- when Prism::InstanceVariableReadNode
122
- widen_ivar(call_node.name, receiver.name, current_scope)
123
- else
124
- current_scope
123
+ ReceiverAlias.candidates(receiver).reduce(current_scope) do |acc, read|
124
+ widen_alias_read(call_node.name, read, acc)
125
125
  end
126
126
  end
127
127
 
@@ -130,13 +130,13 @@ module Rigor
130
130
  # LOCALS are otherwise invisible to the post-call outer scope (ivars are handled correctly
131
131
  # already because they live in the method-body scope, not the block-local scope).
132
132
  #
133
- # Walks the block AST for `<receiver>.<method>(...)` calls whose receiver is either a
134
- # `LocalVariableReadNode` with `depth > 0` (a captured outer local Prism's `depth`
135
- # counts scope hops outward; `depth == 0` means a block-local) or an
136
- # `InstanceVariableReadNode` (always method-scope), and applies `widen_after_call` for
137
- # each one against the outer scope. The widening is always safe — it can only LOSE
138
- # precision — so blindly propagating is sound regardless of whether the block actually
139
- # runs.
133
+ # Walks the block AST for `<receiver>.<method>(...)` calls, resolves the receiver expression
134
+ # to the variables it can evaluate to ({ReceiverAlias.candidates}), and widens each one
135
+ # against the outer scope. A `LocalVariableReadNode` with `depth == 0` is skipped Prism's
136
+ # `depth` counts scope hops outward, so `0` means a block-local, not a capture; an
137
+ # `InstanceVariableReadNode` is always method-scope and always applies. The widening is
138
+ # always safe — it can only LOSE precision — so blindly propagating is sound regardless of
139
+ # whether the block actually runs.
140
140
  #
141
141
  # Recurses into nested expression nodes so chained / nested forms (`arr << f(x); arr <<
142
142
  # g(y)`, `arr.push(x) if cond`) are all caught. Does NOT recurse into nested
@@ -174,15 +174,20 @@ module Rigor
174
174
  receiver = call_node.receiver
175
175
  return scope if receiver.nil?
176
176
 
177
- case receiver
178
- when Prism::LocalVariableReadNode
179
- return scope if receiver.depth.zero?
177
+ ReceiverAlias.candidates(receiver).reduce(scope) do |acc, read|
178
+ # A block-local read (`depth == 0`) is not a capture of the outer scope, so widening its
179
+ # name against the OUTER scope would hit an unrelated same-named binding.
180
+ next acc if read.is_a?(Prism::LocalVariableReadNode) && read.depth.zero?
180
181
 
181
- widen_local(call_node.name, receiver.name, scope)
182
- when Prism::InstanceVariableReadNode
183
- widen_ivar(call_node.name, receiver.name, scope)
184
- else
185
- scope
182
+ widen_alias_read(call_node.name, read, acc)
183
+ end
184
+ end
185
+
186
+ def widen_alias_read(method_name, read, scope)
187
+ case read
188
+ when Prism::LocalVariableReadNode then widen_local(method_name, read.name, scope)
189
+ when Prism::InstanceVariableReadNode then widen_ivar(method_name, read.name, scope)
190
+ else scope
186
191
  end
187
192
  end
188
193
 
@@ -1169,7 +1169,9 @@ module Rigor
1169
1169
  # so the proven-absent key reads `nil`. Returns the input unchanged when nothing
1170
1170
  # applies (caller detects "no narrowing"). Only an *optional* present key is removed: a
1171
1171
  # required key makes `key?` always true (the false edge is dead, leave the shape opaque)
1172
- # and a key absent from `pairs` already reads `nil`.
1172
+ # and a key absent from `pairs` already reads `nil` on a closed shape. On an open shape an
1173
+ # undeclared key keeps reading `untyped` on this edge — sound but imprecise, since the shape
1174
+ # carries no per-key exclusion to record what the guard just proved absent.
1173
1175
  def narrow_hash_key_absent(type, key)
1174
1176
  case type
1175
1177
  when Type::HashShape
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Marks a value whose *nil-freeness* rests on Rigor's deliberate choice to ignore core RBS's
6
+ # `%a{implicitly-returns-nil}` annotation rather than on the value's class. See
7
+ # docs/internal-spec/inference-engine.md § "That deferred-to answer is nil-free for a lookup that can
8
+ # miss": `Hash#[]` reads as `V` and `Array#first` as `E` because pessimising them to `V?` costs 25
9
+ # measured false positives on Rigor's own `lib`. The consequence the spec draws is that such a value is
10
+ # **optimistic, not proof** — `MAP[key]` reading as `"x" | "y"` asserts nothing about whether the key was
11
+ # present.
12
+ #
13
+ # A side channel in the ADR-75 / ADR-82 sense: it never participates in subtyping, consistency,
14
+ # normalization or erasure, and no diagnostic fires from it. It differs from {DynamicOrigin} in what it
15
+ # attaches to — the value here is *not* `Dynamic`; it is an ordinary `Union` / `Constant` / `Nominal`
16
+ # that happens to have been produced optimistically.
17
+ #
18
+ # Issue #286: the `if` / `unless` branch elision is a third consumer of `Narrowing.predicate_certainty`,
19
+ # and unlike `flow.always-truthy-condition` and the `&&` / `||` gate it is not constrained by the spec
20
+ # passage above. This channel is what lets a certainty judgment tell the two apart.
21
+ module OptimisticOrigin
22
+ # The core-RBS annotation `RbsDispatch` reads the return type past.
23
+ ANNOTATION = "implicitly-returns-nil"
24
+
25
+ # The single cause carried today. Kept as a symbol (rather than a bare `true`) so a later slice can
26
+ # distinguish further optimistic families without changing the table's shape.
27
+ IMPLICITLY_RETURNS_NIL = :implicitly_returns_nil
28
+
29
+ module_function
30
+
31
+ # Whether the overload the selector actually picked carries the ignored annotation. The judgment is
32
+ # per-overload, which is what makes it precise: `Array#first` is optimistic while `Array#first(3)` is
33
+ # not, and `String#[]` / `Enumerable#find` are honest because they already spell the miss as `?`.
34
+ #
35
+ # @param method_definition [RBS::Definition::Method]
36
+ # @param method_type [RBS::MethodType] the overload {OverloadSelector.select} returned
37
+ # @return [Boolean]
38
+ def optimistic_overload?(method_definition, method_type)
39
+ type_def = matching_type_def(method_definition, method_type)
40
+ return false unless type_def.respond_to?(:overload_annotations)
41
+
42
+ type_def.overload_annotations.any? { |annotation| annotation.string == ANNOTATION }
43
+ end
44
+
45
+ # `RBS::Definition::Method#defs` runs parallel to `#method_types`, and the selector returns one of the
46
+ # latter's elements verbatim (`ReceiverAffinity.reorder` permutes the array without copying its
47
+ # members), so identity resolves the overload exactly. Equality is a fallback for any future path that
48
+ # rebuilds the method type.
49
+ def matching_type_def(method_definition, method_type)
50
+ return nil unless method_definition.respond_to?(:defs)
51
+
52
+ defs = method_definition.defs
53
+ defs.find { |type_def| type_def.type.equal?(method_type) } ||
54
+ defs.find { |type_def| type_def.type == method_type }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "scope_indexer"
4
4
  require_relative "origin_lookup"
5
5
  require_relative "../source/node_walker"
6
+ require_relative "../analysis/check_rules/inferred_param_guard"
6
7
 
7
8
  module Rigor
8
9
  module Inference
@@ -20,7 +21,11 @@ module Rigor
20
21
  # A single unprotected call site.
21
22
  Site = Data.define(:line, :receiver, :method_name, :dynamic_origin)
22
23
 
23
- FileResult = Data.define(:protected_count, :unprotected_count, :sites) do
24
+ # `lower_bound_typed` is a split WITHIN `protected_count`, never a move out of it (issue #263):
25
+ # a site whose receiver is concrete but rooted at a WD6b-marked inferred parameter (
26
+ # {Analysis::CheckRules::InferredParamGuard.rooted?}) is a lower-bound type — `protected_count`
27
+ # and `ratio` stay exactly as before, so `--threshold` never moves on unchanged code.
28
+ FileResult = Data.define(:protected_count, :unprotected_count, :lower_bound_typed, :sites) do
24
29
  def total = protected_count + unprotected_count
25
30
 
26
31
  # Protected ratio; a file with no dispatch sites is vacuously fully protected (nothing to get
@@ -37,6 +42,7 @@ module Rigor
37
42
  def scan(root)
38
43
  index = ScopeIndexer.index(root, default_scope: @scope)
39
44
  protected_count = 0
45
+ lower_bound_typed = 0
40
46
  sites = []
41
47
 
42
48
  Source::NodeWalker.each(root) do |node|
@@ -46,6 +52,7 @@ module Rigor
46
52
  receiver_type = scope.type_of(node.receiver)
47
53
  if concrete_receiver?(receiver_type)
48
54
  protected_count += 1
55
+ lower_bound_typed += 1 if lower_bound_typed?(node.receiver, scope)
49
56
  else
50
57
  origin = OriginLookup.origin_for(scope, node.receiver)
51
58
  sites << Site.new(
@@ -57,7 +64,8 @@ module Rigor
57
64
  end
58
65
  end
59
66
 
60
- FileResult.new(protected_count: protected_count, unprotected_count: sites.size, sites: sites)
67
+ FileResult.new(protected_count: protected_count, unprotected_count: sites.size,
68
+ lower_bound_typed: lower_bound_typed, sites: sites)
61
69
  end
62
70
 
63
71
  private
@@ -77,6 +85,14 @@ module Rigor
77
85
  end
78
86
  end
79
87
 
88
+ # ADR-67 WD6b (issue #263) — true when a concrete receiver is rooted at an inferred-but-undeclared
89
+ # parameter: the type is a call-site lower bound, so the negative in-body rules decline on it (no
90
+ # diagnostic can actually fire here yet). Delegates to the single-homed shared predicate every
91
+ # negative rule already consults — nothing is re-derived.
92
+ def lower_bound_typed?(receiver, scope)
93
+ Analysis::CheckRules::InferredParamGuard.rooted?(receiver, scope)
94
+ end
95
+
80
96
  def safe_describe(type)
81
97
  type.respond_to?(:describe) ? type.describe(:short) : type.to_s
82
98
  rescue StandardError
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Which variables can a receiver EXPRESSION evaluate to?
8
+ #
9
+ # A receiver-fact invalidation (see {MutationWidening}) has to name the binding it invalidates,
10
+ # and the overwhelmingly common receiver — a bare `arr` / `@arr` read — names exactly one. But a
11
+ # receiver may also *select* among variables without naming any of them:
12
+ #
13
+ # (kind == :required ? required : optional)[key] = info
14
+ #
15
+ # The mutation lands on whichever of `required` / `optional` the ternary picked, so BOTH are
16
+ # possible targets and both must forget their literal shape. Reading only the syntactic head
17
+ # left both hashes carrying the empty `HashShape` the literal `{}` wrote, and a downstream
18
+ # `.empty?` then constant-folded into a false `flow.always-truthy-condition`
19
+ # ([#277](https://github.com/rigortype/rigor/issues/277)).
20
+ #
21
+ # The recursion covers only the forms whose value IS one of the sub-expressions: `if` / `unless`
22
+ # (including the ternary spelling), the short-circuit operators, and the transparent wrappers.
23
+ # Anything else — an index read (`declared[kind] << key`), a call result, a literal — names an
24
+ # object no binding can be attributed to and yields `[]`, which is what every receiver form
25
+ # outside the single-read case already contributed. The walk is depth-capped so a pathological
26
+ # nest cannot make receiver classification unbounded.
27
+ module ReceiverAlias
28
+ # Deep enough for any hand-written selection; a nest beyond it degrades to "names no binding".
29
+ WALK_DEPTH_CAP = 6
30
+
31
+ module_function
32
+
33
+ # @param node [Prism::Node, nil] the receiver expression.
34
+ # @param depth [Integer] recursion depth, internal.
35
+ # @return [Array<Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode>] every variable
36
+ # read the expression can evaluate to; empty when it can evaluate to none.
37
+ def candidates(node, depth = 0)
38
+ return [] if node.nil? || depth > WALK_DEPTH_CAP
39
+
40
+ case node
41
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode then [node]
42
+ when Prism::ParenthesesNode then candidates(node.body, depth + 1)
43
+ when Prism::StatementsNode then candidates(node.body.last, depth + 1)
44
+ when Prism::ElseNode then candidates(node.statements, depth + 1)
45
+ when Prism::IfNode then branches(node.statements, node.subsequent, depth)
46
+ when Prism::UnlessNode then branches(node.statements, node.else_clause, depth)
47
+ when Prism::OrNode, Prism::AndNode then branches(node.left, node.right, depth)
48
+ else []
49
+ end
50
+ end
51
+
52
+ def branches(first, second, depth)
53
+ candidates(first, depth + 1) + candidates(second, depth + 1)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1275,16 +1275,40 @@ module Rigor
1275
1275
  end
1276
1276
 
1277
1277
  # Merges two `class_name => { method => kind }` tables, unioning the per-class method maps (so a seeded cross-file
1278
- # table and the current file's table combine instead of clobbering).
1278
+ # table and the current file's table combine instead of clobbering). A name recorded on both sides — an instance
1279
+ # `def` here and a `class << self` twin there — merges to {Scope::DiscoveryIndex::METHOD_KIND_BOTH} rather than
1280
+ # letting the overlay's kind win (#239).
1279
1281
  def deep_merge_class_methods(base, overlay)
1280
1282
  return overlay if base.nil? || base.empty?
1281
1283
  return base if overlay.empty?
1282
1284
 
1283
1285
  base.merge(overlay) do |_class_name, base_methods, overlay_methods|
1284
- base_methods.merge(overlay_methods)
1286
+ merge_method_kinds(base_methods, overlay_methods)
1285
1287
  end
1286
1288
  end
1287
1289
 
1290
+ # `{ method => kind }` union that promotes a kind disagreement to `METHOD_KIND_BOTH` instead of clobbering.
1291
+ def merge_method_kinds(base_methods, overlay_methods)
1292
+ base_methods.merge(overlay_methods) do |_method_name, base_kind, overlay_kind|
1293
+ base_kind == overlay_kind ? base_kind : Scope::DiscoveryIndex::METHOD_KIND_BOTH
1294
+ end
1295
+ end
1296
+
1297
+ # The single write path into a `class_name => { method => kind }` existence table. Every recorder goes through
1298
+ # it so a class that defines one name on both sides keeps both facts: the table is keyed by name alone, so a
1299
+ # bare assignment silently replaced the other side's kind and `Scope#discovered_method?` then answered false for
1300
+ # a method the source plainly defines (#239).
1301
+ def record_method_kind(accumulator, class_name, method_name, kind)
1302
+ table = (accumulator[class_name] ||= {})
1303
+ recorded = table[method_name]
1304
+ table[method_name] =
1305
+ if recorded.nil? || recorded == kind
1306
+ kind
1307
+ else
1308
+ Scope::DiscoveryIndex::METHOD_KIND_BOTH
1309
+ end
1310
+ end
1311
+
1288
1312
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1289
1313
  # Combined `walk_methods` + `walk_def_nodes` descent. The two walks had identical class / module / singleton-class
1290
1314
  # / meta-block traversals and both stopped at `DefNode`; the only divergences are leaf actions (recorded into the
@@ -1393,8 +1417,7 @@ module Rigor
1393
1417
  return if members.empty?
1394
1418
 
1395
1419
  class_name = qualified_prefix.join("::")
1396
- table = (accumulator[class_name] ||= {})
1397
- members.each { |member| table[member] ||= :instance }
1420
+ members.each { |member| record_method_kind(accumulator, class_name, member, :instance) }
1398
1421
  end
1399
1422
 
1400
1423
  # The Symbol member names of a `Data.define(*Symbol)` / `Struct.new(*Symbol [, keyword_init:])` call. For
@@ -1412,8 +1435,7 @@ module Rigor
1412
1435
  class_name = qualified_prefix.join("::")
1413
1436
  singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class)
1414
1437
  kind = singleton ? :singleton : :instance
1415
- accumulator[class_name] ||= {}
1416
- accumulator[class_name][def_node.name] = kind
1438
+ record_method_kind(accumulator, class_name, def_node.name, kind)
1417
1439
  end
1418
1440
 
1419
1441
  # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside `module Meta`) is semantically equivalent to `def
@@ -1918,7 +1940,7 @@ module Rigor
1918
1940
  class_name = qualified_prefix.join("::")
1919
1941
  new_name = alias_node.new_name.unescaped.to_sym
1920
1942
  kind = in_singleton_class ? :singleton : :instance
1921
- (accumulator[class_name] ||= {})[new_name] = kind
1943
+ record_method_kind(accumulator, class_name, new_name, kind)
1922
1944
  end
1923
1945
 
1924
1946
  # Post-pass over the `def_nodes` accumulator: for every `alias` declaration inside a class body, if the original
@@ -1981,8 +2003,7 @@ module Rigor
1981
2003
  return if method_name.nil?
1982
2004
 
1983
2005
  class_name = qualified_prefix.join("::")
1984
- accumulator[class_name] ||= {}
1985
- accumulator[class_name][method_name] = in_singleton_class ? :singleton : :instance
2006
+ record_method_kind(accumulator, class_name, method_name, in_singleton_class ? :singleton : :instance)
1986
2007
  end
1987
2008
 
1988
2009
  # The `attr_*` accessor macros that introduce methods Rigor must treat as source-declared. Without this, a class
@@ -2005,9 +2026,8 @@ module Rigor
2005
2026
  base = literal_method_name(arg)
2006
2027
  next if base.nil?
2007
2028
 
2008
- accumulator[class_name] ||= {}
2009
- accumulator[class_name][base] = kind if reader
2010
- accumulator[class_name][:"#{base}="] = kind if writer
2029
+ record_method_kind(accumulator, class_name, base, kind) if reader
2030
+ record_method_kind(accumulator, class_name, :"#{base}=", kind) if writer
2011
2031
  end
2012
2032
  end
2013
2033
 
@@ -2352,7 +2372,7 @@ module Rigor
2352
2372
  file_index[:def_nodes].each { |cn, methods| (acc[:def_nodes][cn] ||= {}).merge!(methods) }
2353
2373
  file_index[:singleton_def_nodes].each { |cn, methods| (acc[:singleton_def_nodes][cn] ||= {}).merge!(methods) }
2354
2374
  file_index[:method_visibilities].each { |cn, table| (acc[:method_visibilities][cn] ||= {}).merge!(table) }
2355
- file_index[:methods].each { |cn, table| (acc[:methods][cn] ||= {}).merge!(table) }
2375
+ file_index[:methods].each { |cn, table| acc[:methods][cn] = merge_method_kinds(acc[:methods][cn] || {}, table) }
2356
2376
  fold_def_sources(acc, :def_sources, file_index[:def_sources])
2357
2377
  fold_def_sources(acc, :singleton_def_sources, file_index[:singleton_def_sources])
2358
2378
  end
@@ -2468,10 +2488,18 @@ module Rigor
2468
2488
 
2469
2489
  # Removes, per class, the method names that have a project `def` node, leaving only
2470
2490
  # accessor/alias/define_method-introduced methods in the cross-file suppression table.
2491
+ #
2492
+ # `def_nodes` is the INSTANCE-side table, so a name recorded on both sides keeps its singleton half: that half
2493
+ # comes from a `def self.x` / `class << self` definition this rule never covered, and dropping it whole would
2494
+ # reintroduce #239's false `call.undefined-method` across files.
2471
2495
  def subtract_def_methods(methods, def_nodes)
2472
2496
  methods.each_with_object({}) do |(class_name, table), out|
2473
2497
  defs = def_nodes[class_name] || {}
2474
- kept = table.reject { |method_name, _kind| defs.key?(method_name) }
2498
+ kept = table.each_with_object({}) do |(method_name, kind), acc|
2499
+ next acc[method_name] = kind unless defs.key?(method_name)
2500
+
2501
+ acc[method_name] = :singleton if kind == Scope::DiscoveryIndex::METHOD_KIND_BOTH
2502
+ end
2475
2503
  out[class_name] = kept unless kept.empty?
2476
2504
  end
2477
2505
  end
@@ -2517,7 +2545,7 @@ module Rigor
2517
2545
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
2518
2546
  end
2519
2547
  file_methods.each do |class_name, table|
2520
- (acc[:methods][class_name] ||= {}).merge!(table)
2548
+ acc[:methods][class_name] = merge_method_kinds(acc[:methods][class_name] || {}, table)
2521
2549
  end
2522
2550
  end
2523
2551
 
@@ -2570,24 +2598,47 @@ module Rigor
2570
2598
  node.rigor_each_child { |child| collect_class_decls(child, qualified_prefix, accumulator) }
2571
2599
  end
2572
2600
 
2573
- # T1 (template-corpora survey) — record a `Const = Class.new(Super)` (and the bare `Class.new` / `Module.new`)
2574
- # class-creating constant in the cross-file discovery table so a reference to `Const` from ANOTHER file under the
2575
- # same namespace resolves to the project class instead of falling through to a core same-named class
2601
+ # T1 (template-corpora survey) — record a class-creating constant write (`Const = Class.new(Super)`, the bare
2602
+ # `Class.new` / `Module.new`, and the `Data.define(*sym)` / `Struct.new(*sym)` forms, each with or without a
2603
+ # block) in the cross-file discovery table so a reference to `Const` from ANOTHER file under the same namespace
2604
+ # resolves to the project class instead of falling through to a same-named class elsewhere
2576
2605
  # (`Liquid::SyntaxError = Class.new(Error)` referenced in a sibling file's `rescue SyntaxError => e`, which
2577
2606
  # otherwise resolved to core `::SyntaxError`). Mirrors the single-file `in_source_constants` answer, which types
2578
- # `Class.new(Super)` as `Singleton[Super]` (the constructed class answers method lookups through Super's chain).
2579
- # The superclass name is resolved lexically against the enclosing prefix; a bare `Class.new` with no superclass
2580
- # (or `Module.new`) types as `Singleton[Const]` itself. The block form is left to the existing
2581
- # `meta_new_block_body` machinery only the plain `Class.new(Super)` constant (the namespaced-sibling-error
2582
- # idiom) is added here.
2607
+ # `Class.new(Super)` as `Singleton[Super]` (the constructed class answers method lookups through Super's chain)
2608
+ # and every other recognised form as `Singleton[Const]`.
2609
+ #
2610
+ # #271 — the Data/Struct forms are here because their absence was an ACTIVE false positive, not merely a missed
2611
+ # resolution: a nested `Const = Data.define(...)` invisible cross-file lets Ruby's lexical walk continue to the
2612
+ # PARENT namespace's same-named sibling, so `Analysis::PluginFactFingerprint.from_registry(...).opaque?` typed
2613
+ # its receiver as the unrelated `Rigor::Analysis::Result` and reported `call.undefined-method` on correct code.
2614
+ # Nested `Result` / `Entry` / `Config` Data constants shadowing a sibling are ordinary Ruby, and only the
2615
+ # DEFINING file's `in_source_constants` (never part of the project seed) knew about them.
2583
2616
  def record_class_new_constant_decl(node, qualified_prefix, accumulator)
2584
2617
  rvalue = node.value
2585
- return unless class_new_call?(rvalue) || module_new_call?(rvalue)
2586
- return if rvalue.block # block form: handled by meta_new_block_body walks
2618
+ return unless meta_new_constant_rvalue?(rvalue)
2587
2619
 
2588
2620
  full = (qualified_prefix + [node.name.to_s]).join("::")
2589
- super_name = class_new_superclass_name(rvalue, qualified_prefix, accumulator)
2590
- accumulator[full] = Type::Combinator.singleton_of(super_name || full)
2621
+ accumulator[full] = Type::Combinator.singleton_of(
2622
+ meta_new_constant_decl_name(rvalue, full, qualified_prefix, accumulator)
2623
+ )
2624
+ end
2625
+
2626
+ # The four recognised class-creating rvalue shapes at constant-write position — the same set
2627
+ # {#meta_new_block_body} recognises, so the cross-file table and the per-file block-as-method walk agree on what
2628
+ # counts as a declaration.
2629
+ def meta_new_constant_rvalue?(rvalue)
2630
+ class_new_call?(rvalue) || module_new_call?(rvalue) ||
2631
+ data_define_call?(rvalue) || struct_new_call?(rvalue)
2632
+ end
2633
+
2634
+ # The name the constant's `Singleton[...]` carries. Only a block-less `Class.new(Super)` borrows its superclass's
2635
+ # name (the constructed class answers lookups through `Super`'s chain and declares nothing of its own); every
2636
+ # other form — Data/Struct members, any block body — owns methods under its OWN qualified name, which is exactly
2637
+ # what the per-file `meta_new_constant_type` answers.
2638
+ def meta_new_constant_decl_name(rvalue, full, qualified_prefix, accumulator)
2639
+ return full if rvalue.block || data_define_call?(rvalue) || struct_new_call?(rvalue)
2640
+
2641
+ class_new_superclass_name(rvalue, qualified_prefix, accumulator) || full
2591
2642
  end
2592
2643
 
2593
2644
  # Lexically-qualified name of a `Class.new(Super)` superclass argument, or nil when there is no positional