rigortype 0.4.1 → 0.4.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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +6 -0
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/03-configuration.md +1 -1
  27. data/docs/manual/04-diagnostics.md +2 -1
  28. data/docs/manual/06-baseline.md +2 -0
  29. data/docs/manual/07-plugins.md +1 -1
  30. data/docs/manual/18-removing-dead-code.md +32 -2
  31. data/docs/manual/plugins/README.md +6 -1
  32. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  33. data/docs/manual/plugins/rigor-alba.md +60 -0
  34. data/docs/manual/plugins/rigor-minitest.md +22 -4
  35. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  36. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  37. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  38. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  39. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  40. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  41. data/lib/rigor/analysis/check_rules.rb +289 -57
  42. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  43. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  44. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  45. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  46. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  47. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  48. data/lib/rigor/analysis/incremental.rb +29 -9
  49. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  50. data/lib/rigor/analysis/incremental_run_slot.rb +3 -1
  51. data/lib/rigor/analysis/incremental_session.rb +14 -1
  52. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  53. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  54. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  55. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  56. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +87 -5
  57. data/lib/rigor/analysis/runner/pool_coordinator.rb +50 -11
  58. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  59. data/lib/rigor/analysis/runner.rb +34 -3
  60. data/lib/rigor/analysis/worker_session.rb +7 -4
  61. data/lib/rigor/cache/incremental_snapshot.rb +22 -0
  62. data/lib/rigor/cli/annotate_command.rb +5 -0
  63. data/lib/rigor/cli/baseline_command.rb +7 -18
  64. data/lib/rigor/cli/check_command.rb +2 -1
  65. data/lib/rigor/cli/trace_command.rb +5 -0
  66. data/lib/rigor/cli/type_of_command.rb +5 -3
  67. data/lib/rigor/cli/unused_command.rb +13 -7
  68. data/lib/rigor/cli.rb +10 -2
  69. data/lib/rigor/configuration/severity_profile.rb +6 -0
  70. data/lib/rigor/configuration.rb +46 -2
  71. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  72. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  73. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  74. data/lib/rigor/environment/rbs_loader.rb +283 -14
  75. data/lib/rigor/environment/required_features.rb +206 -0
  76. data/lib/rigor/environment.rb +21 -3
  77. data/lib/rigor/inference/acceptance.rb +28 -3
  78. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  79. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  81. data/lib/rigor/inference/expression_typer.rb +215 -16
  82. data/lib/rigor/inference/global_write_census.rb +7 -4
  83. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  84. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  85. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  86. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  87. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  88. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  89. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  90. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  91. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  92. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  93. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  94. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  95. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  96. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  97. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  98. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  99. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  100. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +259 -28
  101. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  103. data/lib/rigor/inference/module_function_state.rb +2 -1
  104. data/lib/rigor/inference/narrowing.rb +20 -6
  105. data/lib/rigor/inference/object_mixins.rb +180 -0
  106. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  107. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  108. data/lib/rigor/inference/refine_census.rb +239 -0
  109. data/lib/rigor/inference/refine_self.rb +317 -0
  110. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  111. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  112. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  113. data/lib/rigor/inference/scope_indexer.rb +687 -60
  114. data/lib/rigor/inference/statement_evaluator.rb +71 -20
  115. data/lib/rigor/inference/string_mutation.rb +9 -3
  116. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  117. data/lib/rigor/inference/version_guard.rb +19 -10
  118. data/lib/rigor/language_server/project_context.rb +31 -0
  119. data/lib/rigor/language_server/server.rb +18 -0
  120. data/lib/rigor/plugin/inflector.rb +1 -1
  121. data/lib/rigor/plugin/io_boundary.rb +42 -3
  122. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  123. data/lib/rigor/project_environment.rb +29 -6
  124. data/lib/rigor/protection/mutation_cache.rb +1 -1
  125. data/lib/rigor/protection/mutator.rb +1 -1
  126. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  127. data/lib/rigor/reflection/constant_path.rb +13 -2
  128. data/lib/rigor/reflection.rb +40 -8
  129. data/lib/rigor/scope/discovery_index.rb +40 -1
  130. data/lib/rigor/scope/resolution_chain.rb +159 -11
  131. data/lib/rigor/scope.rb +189 -10
  132. data/lib/rigor/sig_gen/classification.rb +4 -1
  133. data/lib/rigor/sig_gen/generator.rb +55 -5
  134. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  135. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  136. data/lib/rigor/source/alias_names.rb +52 -0
  137. data/lib/rigor/source/literals.rb +3 -2
  138. data/lib/rigor/source/symbol_name.rb +20 -0
  139. data/lib/rigor/type/combinator.rb +34 -2
  140. data/lib/rigor/type/float_range.rb +4 -1
  141. data/lib/rigor/type/nominal.rb +6 -1
  142. data/lib/rigor/version.rb +1 -1
  143. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  144. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  145. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  146. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  147. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  148. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  149. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  150. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  151. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  152. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  153. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  156. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  157. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  158. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  159. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  160. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  161. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  162. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  163. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  164. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  165. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  166. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  167. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  168. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  169. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  170. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  171. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  172. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  173. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  174. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  175. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  176. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  177. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  178. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  179. data/sig/rigor/scope.rbs +27 -4
  180. data/sig/rigor/source.rbs +4 -0
  181. data/sig/rigor.rbs +3 -1
  182. metadata +66 -1
@@ -18,7 +18,7 @@ module Rigor
18
18
  # assert_kind_of(T, x) → narrow x to T
19
19
  # assert_instance_of(T, x) → narrow x to T
20
20
  # assert_nil(x) → narrow x to Constant<nil>
21
- # assert_equal(literal, x) → narrow x to Constant<literal>
21
+ # assert_equal(literal, x) → meet x with Constant<literal> (see "Equality is not identity")
22
22
  # assert_match(regex, x) → narrow x to String
23
23
  #
24
24
  # ### (2) Minitest / Test::Unit `refute_*` / `assert_not_*` (negative) ###
@@ -26,7 +26,7 @@ module Rigor
26
26
  # refute_kind_of(T, x) → narrow x AWAY from T
27
27
  # refute_instance_of(T, x) → narrow x AWAY from T
28
28
  # refute_nil(x) → narrow x AWAY from nil
29
- # refute_equal(literal, x) → narrow x AWAY from Constant<literal>
29
+ # refute_equal(literal, x) → negative Constant<literal> fact (the engine keeps x unchanged)
30
30
  #
31
31
  # Test::Unit's `assert_not_nil(x)` / `assert_not_equal(...)` / `assert_not_kind_of(...)` /
32
32
  # `assert_not_instance_of(...)` share the recognizer with `refute_*` (they're aliases).
@@ -36,7 +36,7 @@ module Rigor
36
36
  # _(x).must_be_kind_of(T) → narrow x to T
37
37
  # _(x).must_be_instance_of(T) → narrow x to T
38
38
  # _(x).must_be_nil → narrow x to Constant<nil>
39
- # _(x).must_equal(literal) → narrow x to Constant<literal>
39
+ # _(x).must_equal(literal) → meet x with Constant<literal> (see "Equality is not identity")
40
40
  # _(x).must_match(regex) → narrow x to String
41
41
  #
42
42
  # The legacy bare `x.must_be_kind_of(T)` form (Minitest < 6.0 monkey-patched onto Object) is
@@ -48,7 +48,25 @@ module Rigor
48
48
  #
49
49
  # _(x).wont_be_kind_of(T) → narrow x AWAY from T
50
50
  # _(x).wont_be_nil → narrow x AWAY from nil
51
- # _(x).wont_equal(literal) → narrow x AWAY from Constant<literal>
51
+ # _(x).wont_equal(literal) → negative Constant<literal> fact (the engine keeps x unchanged)
52
+ #
53
+ # ## Equality is not identity
54
+ #
55
+ # Both `assert_equal(exp, act)` and `_(act).must_equal(exp)` pass when `exp == act`, with the literal as
56
+ # the receiver. A positive equality fact never replaces the local's type (issue #1678):
57
+ #
58
+ # - `nil` / `true` / `false` / Symbol literals inherit identity `==` (`BasicObject#==`, `Symbol#==`),
59
+ # so `exp == act` holds only for the literal object itself and the local narrows to it.
60
+ # - Integer, Float and String literals compare across classes: `Integer#==` and `Float#==` defer to
61
+ # `act == exp` for a non-numeric argument (a `ModInt` with its own `==` passes `assert_equal 9, m`),
62
+ # numerics compare by value (`1 == 1.0`, `1 == 1r`, `0.0 == -0.0`), and `String#==` defers to
63
+ # `act == exp` for an argument that responds to `to_str`. The local narrows only when every member of
64
+ # its current type is already of the literal's own class, or a `nil` / boolean / Symbol the literal
65
+ # cannot equal; it is then a meet (`Integer` or `0..9` to `Constant<9>`, `1 | 2` to `Constant<2>`).
66
+ # A `Dynamic` local, a member of any other class, and a `0.0` literal keep the local unchanged.
67
+ #
68
+ # The negative forms emit a negative `Constant` fact, which the engine's fact narrowing does not act
69
+ # on, so `refute_equal` / `wont_equal` leave the local unchanged.
52
70
  #
53
71
  # ## Not yet recognised
54
72
  #
@@ -59,12 +77,14 @@ module Rigor
59
77
  module AssertionAnalyzer
60
78
  module_function
61
79
 
62
- def contribution_for(call_node, environment:)
80
+ # @param scope — the scope at the call, or nil. The environment resolves class arguments; the local's
81
+ # current type decides whether an equality assertion narrows (nil scope: only identity literals do).
82
+ def contribution_for(call_node, scope:)
63
83
  return nil unless call_node.is_a?(Prism::CallNode)
64
84
 
65
85
  fact =
66
- assert_form_fact(call_node, environment: environment) ||
67
- spec_form_fact(call_node, environment: environment)
86
+ assert_form_fact(call_node, scope: scope) ||
87
+ spec_form_fact(call_node, scope: scope)
68
88
  return nil if fact.nil?
69
89
 
70
90
  Rigor::FlowContribution.new(post_return_facts: [fact])
@@ -96,7 +116,7 @@ module Rigor
96
116
  Ractor.make_shareable(ASSERT_FORM)
97
117
  private_constant :ASSERT_FORM
98
118
 
99
- def assert_form_fact(call_node, environment:)
119
+ def assert_form_fact(call_node, scope:)
100
120
  return nil unless call_node.receiver.nil?
101
121
 
102
122
  shape_negative = ASSERT_FORM[call_node.name]
@@ -104,7 +124,7 @@ module Rigor
104
124
 
105
125
  shape, negative = shape_negative
106
126
  args = call_node.arguments&.arguments || []
107
- fact_for_shape(shape, args, negative: negative, environment: environment)
127
+ fact_for_shape(shape, args, negative: negative, scope: scope)
108
128
  end
109
129
 
110
130
  # --- _(x).must_* / .wont_* form ---
@@ -134,7 +154,7 @@ module Rigor
134
154
  # `narrowing_facts methods:` gate.
135
155
  SUPPORTED_METHODS = (ASSERT_FORM.keys + SPEC_MATCHER_FORM.keys).freeze
136
156
 
137
- def spec_form_fact(call_node, environment:)
157
+ def spec_form_fact(call_node, scope:)
138
158
  shape_negative = SPEC_MATCHER_FORM[call_node.name]
139
159
  return nil if shape_negative.nil?
140
160
 
@@ -143,7 +163,7 @@ module Rigor
143
163
 
144
164
  shape, negative = shape_negative
145
165
  args = call_node.arguments&.arguments || []
146
- fact_for_spec_shape(shape, target_local, args, negative: negative, environment: environment)
166
+ fact_for_spec_shape(shape, target_local, args, negative: negative, scope: scope)
147
167
  end
148
168
 
149
169
  # `_(x)` returns a `Minitest::Expectation` wrapping x. Some specs use `value(x)` or `expect(x)`
@@ -166,7 +186,8 @@ module Rigor
166
186
 
167
187
  # --- shape resolvers ---
168
188
 
169
- def fact_for_shape(shape, args, negative:, environment:)
189
+ def fact_for_shape(shape, args, negative:, scope:)
190
+ environment = scope&.environment
170
191
  case shape
171
192
  when :class_then_local
172
193
  return nil unless args.size == 2
@@ -183,10 +204,7 @@ module Rigor
183
204
  return nil unless args.size == 2
184
205
  return nil unless args[1].is_a?(Prism::LocalVariableReadNode)
185
206
 
186
- literal = literal_value_for(args[0])
187
- return nil if literal.equal?(NO_LITERAL)
188
-
189
- fact_for(args[1].name, Rigor::Type::Combinator.constant_of(literal), negative: negative)
207
+ equality_fact(args[1].name, args[0], negative: negative, scope: scope)
190
208
  when :regex_then_local
191
209
  return nil unless args.size == 2
192
210
  return nil unless args[1].is_a?(Prism::LocalVariableReadNode)
@@ -196,7 +214,8 @@ module Rigor
196
214
  end
197
215
  end
198
216
 
199
- def fact_for_spec_shape(shape, target_local, args, negative:, environment:)
217
+ def fact_for_spec_shape(shape, target_local, args, negative:, scope:)
218
+ environment = scope&.environment
200
219
  case shape
201
220
  when :class_arg
202
221
  return nil unless args.size == 1
@@ -210,10 +229,7 @@ module Rigor
210
229
  when :literal_arg
211
230
  return nil unless args.size == 1
212
231
 
213
- literal = literal_value_for(args[0])
214
- return nil if literal.equal?(NO_LITERAL)
215
-
216
- fact_for(target_local, Rigor::Type::Combinator.constant_of(literal), negative: negative)
232
+ equality_fact(target_local, args[0], negative: negative, scope: scope)
217
233
  when :regex_arg
218
234
  return nil unless args.size == 1
219
235
  return nil unless regex_literal?(args[0])
@@ -222,6 +238,99 @@ module Rigor
222
238
  end
223
239
  end
224
240
 
241
+ # --- equality (`assert_equal` / `must_equal` and their negations) ---
242
+
243
+ # Classes whose `==` is identity (`BasicObject#==`, or `Symbol#==`), so `literal == act` holds only
244
+ # for the literal object itself, whatever `act` is.
245
+ IDENTITY_EQUALITY_CLASSES = [NilClass, TrueClass, FalseClass, Symbol].freeze
246
+ Ractor.make_shareable(IDENTITY_EQUALITY_CLASSES)
247
+ private_constant :IDENTITY_EQUALITY_CLASSES
248
+
249
+ IDENTITY_EQUALITY_CLASS_NAMES = IDENTITY_EQUALITY_CLASSES.map(&:name).freeze
250
+ Ractor.make_shareable(IDENTITY_EQUALITY_CLASS_NAMES)
251
+ private_constant :IDENTITY_EQUALITY_CLASS_NAMES
252
+
253
+ # Marks a member whose equality with the literal cannot be decided, so the whole meet gives up.
254
+ UNDECIDED = Object.new.freeze
255
+ private_constant :UNDECIDED
256
+
257
+ def equality_fact(local_name, literal_node, negative:, scope:)
258
+ literal = literal_value_for(literal_node)
259
+ return nil if literal.equal?(NO_LITERAL)
260
+
261
+ constant = Rigor::Type::Combinator.constant_of(literal)
262
+ return fact_for(local_name, constant, negative: true) if negative
263
+ return fact_for(local_name, constant, negative: false) if identity_equality?(literal)
264
+
265
+ fact_for(local_name, equality_meet(scope&.local(local_name), literal), negative: false)
266
+ end
267
+
268
+ def identity_equality?(value)
269
+ IDENTITY_EQUALITY_CLASSES.any? { |klass| value.is_a?(klass) }
270
+ end
271
+
272
+ # The members of `current` that `literal == act` admits, or nil when any member's equality with the
273
+ # literal cannot be decided (a Dynamic, a class with its own `==`, a numeric of another class) or no
274
+ # member survives. `0.0 == -0.0`, so a zero Float literal does not pin the sign and never narrows.
275
+ def equality_meet(current, literal)
276
+ return nil if current.nil?
277
+ return nil if literal.is_a?(Float) && literal.zero?
278
+
279
+ kept = meet_members(current, literal)
280
+ return nil if kept.nil? || kept.empty?
281
+
282
+ Rigor::Type::Combinator.union(*kept)
283
+ end
284
+
285
+ def meet_members(type, literal)
286
+ members = type.is_a?(Rigor::Type::Union) ? type.members : [type]
287
+ kept = []
288
+ members.each do |member|
289
+ met = meet_member(member, literal)
290
+ return nil if met.equal?(UNDECIDED)
291
+
292
+ kept << met unless met.nil?
293
+ end
294
+ kept
295
+ end
296
+
297
+ # One member's part of the meet: the narrowed carrier, nil when no value of the member equals the
298
+ # literal, or UNDECIDED.
299
+ def meet_member(member, literal)
300
+ case member
301
+ when Rigor::Type::Constant then meet_constant(member, literal)
302
+ when Rigor::Type::Nominal then meet_nominal(member, literal)
303
+ when Rigor::Type::IntegerRange
304
+ return UNDECIDED unless literal.is_a?(Integer)
305
+
306
+ member.covers?(literal) ? Rigor::Type::Combinator.constant_of(literal) : nil
307
+ else UNDECIDED
308
+ end
309
+ end
310
+
311
+ def meet_constant(member, literal)
312
+ value = member.value
313
+ if value.instance_of?(literal.class)
314
+ value == literal ? member : nil
315
+ elsif identity_equality?(value)
316
+ nil
317
+ else
318
+ UNDECIDED
319
+ end
320
+ end
321
+
322
+ def meet_nominal(member, literal)
323
+ return UNDECIDED unless member.type_args.empty?
324
+
325
+ if member.class_name == literal.class.name
326
+ Rigor::Type::Combinator.constant_of(literal)
327
+ elsif IDENTITY_EQUALITY_CLASS_NAMES.include?(member.class_name)
328
+ nil
329
+ else
330
+ UNDECIDED
331
+ end
332
+ end
333
+
225
334
  def fact_for(local_name, type, negative:)
226
335
  return nil if type.nil?
227
336
 
@@ -61,12 +61,14 @@ module Rigor
61
61
  # ADR-37 slice 2 — emits `post_return_facts` for every recognised assertion, method-gated by the
62
62
  # engine. The engine routes `:local`-kind facts through `StatementEvaluator#apply_local_post_return_fact`.
63
63
  narrowing_facts methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
64
- AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
64
+ AssertionAnalyzer.contribution_for(call_node, scope: scope)&.post_return_facts
65
65
  end
66
66
 
67
- # ADR-88 WD1 — the narrowing facts this plugin contributes are derived purely from each assertion call's
68
- # own AST at its call site (`assert_kind_of(String, x)` ⇒ `x` is `String` on the continuation); there is
69
- # no cross-file catalog a cached diagnostic could depend on. A change to a file's assertions changes that
67
+ # ADR-88 WD1 — the narrowing facts this plugin contributes are derived from each assertion call's own AST
68
+ # at its call site (`assert_kind_of(String, x)` ⇒ `x` is `String` on the continuation) and, for
69
+ # `assert_equal` / `must_equal`, from the local's current type in the call's scope, which the engine
70
+ # derives and already tracks as a dependency; there is no cross-file catalog of the plugin's own that a
71
+ # cached diagnostic could depend on. A change to a file's assertions changes that
70
72
  # file's own content (re-analysed by the incremental graph already). A stable sentinel declares "no
71
73
  # cross-file fact surface", keeping the plugin incremental-capable (a contributing plugin with NO
72
74
  # fact / producer / hook makes the incremental snapshot un-reusable every run); `--verify-incremental`
@@ -126,10 +126,10 @@ module Rigor
126
126
  end
127
127
 
128
128
  def literal_symbol_or_string(node)
129
- case node
130
- when Prism::SymbolNode then node.unescaped
131
- when Prism::StringNode then node.unescaped
132
- end
129
+ return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
130
+
131
+ # nil for bytes no Symbol holds: the name is later interned (`PolicyIndex#normalize`), which would raise.
132
+ Rigor::Source::SymbolName.of(node.unescaped)&.to_s
133
133
  end
134
134
 
135
135
  def constant_receiver_name(node)
@@ -237,7 +237,7 @@ module Rigor
237
237
  key_node = element.key
238
238
  case key_node
239
239
  when Prism::SymbolNode then key_node.unescaped.to_sym
240
- when Prism::StringNode then key_node.unescaped.to_sym
240
+ when Prism::StringNode then Rigor::Source::Literals.symbol_or_string(key_node)
241
241
  end
242
242
  end
243
243
  end
@@ -1184,7 +1184,7 @@ module Rigor
1184
1184
  def load_drawn_routes(node, context)
1185
1185
  return unless context.file_reader
1186
1186
 
1187
- name = symbol_argument(node, 0) || string_argument(node, 0)&.to_sym
1187
+ name = symbol_argument(node, 0) || Rigor::Source::SymbolName.of(string_argument(node, 0))
1188
1188
  return unless name
1189
1189
 
1190
1190
  sub_contents = context.file_reader.call("#{name}.rb")
@@ -123,6 +123,7 @@ module Rigor
123
123
  return nil if source.empty?
124
124
 
125
125
  result = ::Prism.parse(source)
126
+ source, result = scrub_stray_comment_bytes(source, result)
126
127
  _, result = neutralize_rdoc_directives(source, result)
127
128
  return nil if !@require_magic_comment && !annotated?(result)
128
129
 
@@ -504,6 +505,36 @@ module Rigor
504
505
  end
505
506
  end
506
507
 
508
+ # A comment is not validated by Ruby, so a byte that is invalid in UTF-8 can sit in an ordinary one
509
+ # (`# caf\xFF`), and upstream's annotation grammar raises on it — which {#call}'s rescue would report as a
510
+ # `source-rbs-synthesis-failed` notice about "the inline-RBS comment grammar" for a file that has none.
511
+ # Such a comment is repaired in place (each bad byte becomes one `?`, so every offset stays identical) and
512
+ # the file re-parsed. A comment upstream would read as an annotation — exactly `#:`, `#[` and `#\s*@rbs`
513
+ # (rbs-inline `annotation_parser.rb`) — is left alone, so a genuinely corrupt annotation still reaches the
514
+ # WD6 fail-soft path. An RDoc directive (`#:nodoc:`) is not an annotation: {#neutralize_rdoc_directives}
515
+ # rewrites it, so it is repaired like any other comment.
516
+ STRAY_COMMENT_ANNOTATION = /\A#(?::|\[|\s*@rbs\b)/
517
+ private_constant :STRAY_COMMENT_ANNOTATION
518
+
519
+ def scrub_stray_comment_bytes(source, prism_result)
520
+ return [source, prism_result] if source.valid_encoding?
521
+
522
+ broken = prism_result.comments.reject { |comment| comment.location.slice.valid_encoding? }
523
+ annotation_like = broken.any? do |comment|
524
+ text = comment.location.slice.scrub
525
+ STRAY_COMMENT_ANNOTATION.match?(text) && !RDOC_DIRECTIVE_COMMENT.match?(text)
526
+ end
527
+ return [source, prism_result] if broken.empty? || annotation_like
528
+
529
+ bytes = source.b
530
+ broken.sort_by { |comment| -comment.location.start_offset }.each do |comment|
531
+ location = comment.location
532
+ bytes[location.start_offset, location.length] = location.slice.scrub { |run| "?" * run.bytesize }.b
533
+ end
534
+ repaired = bytes.force_encoding(source.encoding)
535
+ [repaired, ::Prism.parse(repaired)]
536
+ end
537
+
507
538
  # Rewrite every RDoc directive comment to its spaced spelling (`#:nodoc:` -> `# :nodoc:`) so
508
539
  # upstream's annotation grammar never sees it, and re-parse. Two reasons this happens here rather
509
540
  # than in {#annotated?}: the directive must not gate a file in, AND it must not reach the synthesis,
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/inference/declaration_walk"
6
+
7
+ require_relative "serializer_index"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ class Typelizer < Rigor::Plugin::Base
12
+ # A {Rigor::Inference::DeclarationWalk} collector over one file: the classes it declares, and whether each
13
+ # one's own body says `include Typelizer::DSL` / `extend Typelizer::DSL`.
14
+ #
15
+ # A `module` that includes the DSL is deliberately not recorded: typelizer registers the module's own
16
+ # name and `target_serializers` then calls `.descendants` on it, which a plain Module lacks. A class that
17
+ # reaches the DSL through a module's `included do ... end` hook is a known missed root (see the README).
18
+ #
19
+ # Only a statement of the class body itself counts, because only there is `self` the class: the walk is
20
+ # over the body's own statements (through `if` / `unless` / `begin`), never into a `def`, a block, a
21
+ # lambda or a nested class, so `Class.new { include Typelizer::DSL }`, `Other.class_eval { include ... }`,
22
+ # `included do include Typelizer::DSL end` and a `def` that includes it credit nothing to the lexical
23
+ # class. A `class << self` body is a different node and is not entered either.
24
+ class SerializerCollector
25
+ include Rigor::Inference::DeclarationWalk::Collector
26
+
27
+ DSL_MODULE = "Typelizer::DSL"
28
+ DSL_CALLS = %i[include extend].freeze
29
+
30
+ def initialize
31
+ @entries = {}
32
+ @nestings = {}
33
+ @dsl = {}
34
+ end
35
+
36
+ def on_declaration(node, context, body_context)
37
+ return Rigor::Inference::DeclarationWalk::DESCEND unless node.is_a?(Prism::ClassNode)
38
+
39
+ name = body_context.prefix.join("::")
40
+ unless name.empty?
41
+ superclass = superclass_name(node.superclass)
42
+ @entries[name] = superclass if superclass || !@entries.key?(name)
43
+ @nestings[name] = context.nesting || []
44
+ @dsl[name] = true if dsl_in?(node.body) && !body_context.singleton_cref
45
+ end
46
+ Rigor::Inference::DeclarationWalk::DESCEND
47
+ end
48
+
49
+ # The classes this file declares, as {SerializerIndex::ClassEntry} values.
50
+ def class_entries
51
+ @entries.map do |name, superclass|
52
+ SerializerIndex::ClassEntry.new(
53
+ name: name, superclass: superclass, nesting: @nestings.fetch(name), dsl: @dsl.fetch(name, false),
54
+ in_dirs: false
55
+ )
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # Whether a statement reachable without entering a def, block, lambda or nested declaration is a
62
+ # receiverless `include` / `extend` of the DSL module.
63
+ def dsl_in?(node)
64
+ case node
65
+ when Prism::StatementsNode then node.body.any? { |child| dsl_in?(child) }
66
+ when Prism::BeginNode then dsl_in?(node.statements) || dsl_in?(node.else_clause)
67
+ when Prism::ElseNode then dsl_in?(node.statements)
68
+ when Prism::IfNode then dsl_in?(node.statements) || dsl_in?(node.subsequent)
69
+ when Prism::UnlessNode then dsl_in?(node.statements) || dsl_in?(node.else_clause)
70
+ when Prism::CallNode then dsl_call?(node)
71
+ else false
72
+ end
73
+ end
74
+
75
+ def dsl_call?(node)
76
+ DSL_CALLS.include?(node.name) && node.receiver.nil? && node.block.nil? && dsl_argument?(node)
77
+ end
78
+
79
+ def dsl_argument?(node)
80
+ arguments = node.arguments&.arguments || []
81
+ arguments.any? { |argument| constant_name(argument)&.delete_prefix("::") == DSL_MODULE }
82
+ end
83
+
84
+ def superclass_name(node)
85
+ node && constant_name(node)
86
+ end
87
+
88
+ def constant_name(node)
89
+ case node
90
+ when Prism::ConstantReadNode then node.name.to_s
91
+ when Prism::ConstantPathNode
92
+ parent = node.parent
93
+ return "::#{node.name}" if parent.nil?
94
+
95
+ prefix = constant_name(parent)
96
+ prefix && "#{prefix}::#{node.name}"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/inference/declaration_walk"
6
+
7
+ require_relative "serializer_collector"
8
+ require_relative "serializer_index"
9
+
10
+ module Rigor
11
+ module Plugin
12
+ class Typelizer < Rigor::Plugin::Base
13
+ # Reads every `.rb` file under the configured typelizer dirs and the project's `paths:` through the plugin's `IoBoundary`, runs a
14
+ # {SerializerCollector} over each and merges the results into one {SerializerIndex}.
15
+ class SerializerDiscoverer
16
+ def initialize(io_boundary:, dirs:, project_paths: [])
17
+ @io_boundary = io_boundary
18
+ @dirs = dirs
19
+ @project_paths = project_paths
20
+ end
21
+
22
+ def discover
23
+ entries = []
24
+ dir_files = ruby_files_under(@dirs)
25
+ (dir_files | project_files).each do |path|
26
+ contents = read_safely(path)
27
+ next if contents.nil?
28
+
29
+ parsed = Prism.parse(contents)
30
+ next unless parsed.success?
31
+
32
+ collector = SerializerCollector.new
33
+ Rigor::Inference::DeclarationWalk.run(
34
+ parsed.value, [collector], Rigor::Inference::DeclarationWalk::Context.root(nesting: [])
35
+ )
36
+ in_dirs = dir_files.include?(path)
37
+ entries.concat(collector.class_entries.map { |entry| entry.with(in_dirs: in_dirs) })
38
+ end
39
+ SerializerIndex.new(classes: merge(entries))
40
+ end
41
+
42
+ private
43
+
44
+ # A class reopened across files: it registers with typelizer if any opening says so.
45
+ def merge(entries)
46
+ entries.group_by(&:name).map do |name, openings|
47
+ headed = openings.find(&:superclass) || openings.first
48
+ SerializerIndex::ClassEntry.new(
49
+ name: name, superclass: headed.superclass, nesting: headed.nesting,
50
+ dsl: openings.any?(&:dsl), in_dirs: openings.any?(&:in_dirs)
51
+ )
52
+ end
53
+ end
54
+
55
+ def read_safely(path)
56
+ @io_boundary.read_file(path)
57
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
58
+ nil
59
+ end
60
+
61
+ # The project's `paths:` entries: a directory contributes its `.rb` tree, a `.rb` file itself.
62
+ def project_files
63
+ @project_paths.flat_map do |entry|
64
+ absolute = File.expand_path(entry)
65
+ if @io_boundary.directory?(absolute)
66
+ Dir.glob(File.join(absolute, "**", "*.rb"))
67
+ elsif absolute.end_with?(".rb") && @io_boundary.file?(absolute)
68
+ [absolute]
69
+ else
70
+ []
71
+ end
72
+ end
73
+ end
74
+
75
+ def ruby_files_under(roots)
76
+ roots.flat_map do |root|
77
+ absolute = File.expand_path(root)
78
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
79
+ next [] unless @io_boundary.directory?(absolute)
80
+
81
+ Dir.glob(File.join(absolute, "**", "*.rb"))
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Typelizer < Rigor::Plugin::Base
6
+ # The project's classes (every file of the project's `paths:` and the typelizer `dirs`), as the {SerializerCollector} found them. A frozen value
7
+ # object the `:serializer_index` producer caches.
8
+ #
9
+ # {#roots} answers the one question, conservatively — a wrong "no" costs a missing root, a wrong "yes"
10
+ # hides a dead class.
11
+ class SerializerIndex
12
+ # A class the project declares. `superclass` is the constant as written (`nil` when absent) and
13
+ # `nesting` the `Module.nesting` chain (innermost first) its header was written under, which is where
14
+ # Ruby resolves that constant. `dsl` is true when its body includes or extends `Typelizer::DSL`; `in_dirs` when a file under the
15
+ # typelizer `dirs` declares it (only such a class is published as a root).
16
+ ClassEntry = Data.define(:name, :superclass, :nesting, :dsl, :in_dirs)
17
+
18
+ def initialize(classes:)
19
+ @classes = classes.to_h { |entry| [entry.name, entry] }.freeze
20
+ freeze
21
+ end
22
+
23
+ def empty?
24
+ @classes.empty?
25
+ end
26
+
27
+ # Every class typelizer generates an interface for: a class that registered itself through the DSL, and
28
+ # each subclass of one (`Typelizer.target_serializers` takes `descendants`).
29
+ def roots
30
+ @classes.keys.select { |name| @classes[name].in_dirs && typelized?(name, {}) }.sort
31
+ end
32
+
33
+ private
34
+
35
+ def typelized?(name, seen)
36
+ entry = @classes[name]
37
+ return false if entry.nil? || seen[name]
38
+
39
+ seen[name] = true
40
+ return true if entry.dsl
41
+
42
+ parent = superclass_of(entry)
43
+ !parent.nil? && typelized?(parent, seen)
44
+ end
45
+
46
+ # Resolves the superclass as written the way Ruby does: against the `Module.nesting` chain of the header,
47
+ # innermost first, then the top level. A compact header (`class Admin::UserSerializer < Base`) does not
48
+ # put `Admin` on the chain, so `Base` is not looked up under it. The first match wins, so a class that
49
+ # shadows the DSL base (`Admin::Base` with no DSL) ends the walk there.
50
+ #
51
+ # Ruby also searches the ancestors of the innermost cref before the top level. That is not modelled, so a
52
+ # name that is not found on the lexical chain is declined (nil: not typelized) when the innermost scope
53
+ # is a class with a superclass, since the constant might live on that superclass.
54
+ def superclass_of(entry)
55
+ written = entry.superclass
56
+ return nil if written.nil?
57
+
58
+ if written.start_with?("::")
59
+ rooted = written.delete_prefix("::")
60
+ return @classes.key?(rooted) ? rooted : nil
61
+ end
62
+
63
+ lexical = entry.nesting.map { |scope| "#{scope}::#{written}" }.find { |name| @classes.key?(name) }
64
+ return lexical if lexical
65
+ return nil if cref_has_ancestors?(entry.nesting.first)
66
+
67
+ @classes.key?(written) ? written : nil
68
+ end
69
+
70
+ def cref_has_ancestors?(scope)
71
+ !scope.nil? && !@classes[scope]&.superclass.nil?
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end