rigortype 0.1.3 → 0.1.5

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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +154 -33
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
  9. data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +26 -6
  12. data/lib/rigor/analysis/result.rb +11 -3
  13. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  14. data/lib/rigor/analysis/run_stats.rb +193 -0
  15. data/lib/rigor/analysis/runner.rb +498 -12
  16. data/lib/rigor/analysis/worker_session.rb +327 -0
  17. data/lib/rigor/builtins/imported_refinements.rb +364 -55
  18. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  19. data/lib/rigor/cache/descriptor.rb +1 -1
  20. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  21. data/lib/rigor/cache/store.rb +39 -6
  22. data/lib/rigor/cli/diff_command.rb +1 -1
  23. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  24. data/lib/rigor/cli/type_of_command.rb +1 -1
  25. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  26. data/lib/rigor/cli/type_scan_report.rb +2 -2
  27. data/lib/rigor/cli.rb +61 -3
  28. data/lib/rigor/configuration/dependencies.rb +2 -2
  29. data/lib/rigor/configuration.rb +131 -6
  30. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  31. data/lib/rigor/environment/class_registry.rb +12 -3
  32. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  33. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  34. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  35. data/lib/rigor/environment/rbs_loader.rb +194 -6
  36. data/lib/rigor/environment/reflection.rb +152 -0
  37. data/lib/rigor/environment.rb +109 -6
  38. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  39. data/lib/rigor/flow_contribution/element.rb +1 -1
  40. data/lib/rigor/flow_contribution/fact.rb +1 -1
  41. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  42. data/lib/rigor/flow_contribution/merger.rb +3 -3
  43. data/lib/rigor/flow_contribution.rb +2 -2
  44. data/lib/rigor/inference/acceptance.rb +35 -1
  45. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  46. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  47. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  48. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  49. data/lib/rigor/inference/expression_typer.rb +77 -11
  50. data/lib/rigor/inference/fallback.rb +1 -1
  51. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  52. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  53. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -41
  54. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  55. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  56. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  57. data/lib/rigor/inference/method_dispatcher/method_folding.rb +135 -0
  58. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +7 -12
  59. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  60. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -44
  61. data/lib/rigor/inference/method_dispatcher.rb +274 -5
  62. data/lib/rigor/inference/method_parameter_binder.rb +22 -14
  63. data/lib/rigor/inference/narrowing.rb +129 -12
  64. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  65. data/lib/rigor/inference/scope_indexer.rb +14 -9
  66. data/lib/rigor/inference/statement_evaluator.rb +7 -7
  67. data/lib/rigor/inference/synthetic_method.rb +86 -0
  68. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  69. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  70. data/lib/rigor/plugin/blueprint.rb +60 -0
  71. data/lib/rigor/plugin/io_boundary.rb +0 -2
  72. data/lib/rigor/plugin/loader.rb +5 -3
  73. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  74. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  75. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  76. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  77. data/lib/rigor/plugin/macro.rb +31 -0
  78. data/lib/rigor/plugin/manifest.rb +102 -10
  79. data/lib/rigor/plugin/registry.rb +43 -2
  80. data/lib/rigor/plugin/services.rb +1 -1
  81. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  82. data/lib/rigor/plugin.rb +2 -0
  83. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  84. data/lib/rigor/rbs_extended.rb +131 -32
  85. data/lib/rigor/scope.rb +25 -8
  86. data/lib/rigor/sig_gen/classification.rb +36 -0
  87. data/lib/rigor/sig_gen/generator.rb +1048 -0
  88. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  89. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  90. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  91. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  92. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  93. data/lib/rigor/sig_gen/renderer.rb +157 -0
  94. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  95. data/lib/rigor/sig_gen/write_result.rb +48 -0
  96. data/lib/rigor/sig_gen/writer.rb +530 -0
  97. data/lib/rigor/sig_gen.rb +25 -0
  98. data/lib/rigor/trinary.rb +15 -11
  99. data/lib/rigor/type/bot.rb +6 -3
  100. data/lib/rigor/type/bound_method.rb +79 -0
  101. data/lib/rigor/type/combinator.rb +207 -3
  102. data/lib/rigor/type/constant.rb +13 -0
  103. data/lib/rigor/type/hash_shape.rb +0 -2
  104. data/lib/rigor/type/integer_range.rb +7 -7
  105. data/lib/rigor/type/refined.rb +18 -12
  106. data/lib/rigor/type/top.rb +4 -3
  107. data/lib/rigor/type/union.rb +20 -1
  108. data/lib/rigor/type.rb +1 -0
  109. data/lib/rigor/type_node/generic.rb +68 -0
  110. data/lib/rigor/type_node/identifier.rb +38 -0
  111. data/lib/rigor/type_node/indexed_access.rb +41 -0
  112. data/lib/rigor/type_node/integer_literal.rb +29 -0
  113. data/lib/rigor/type_node/name_scope.rb +52 -0
  114. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  115. data/lib/rigor/type_node/string_literal.rb +32 -0
  116. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  117. data/lib/rigor/type_node/union.rb +42 -0
  118. data/lib/rigor/type_node.rb +29 -0
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +2 -0
  121. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  122. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  123. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  124. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  125. data/sig/rigor/cli/diff_command.rbs +4 -0
  126. data/sig/rigor/cli/explain_command.rbs +4 -0
  127. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  128. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  129. data/sig/rigor/environment.rbs +8 -2
  130. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  131. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  132. data/sig/rigor/inference/builtins.rbs +2 -0
  133. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  134. data/sig/rigor/plugin/base.rbs +6 -0
  135. data/sig/rigor/plugin/blueprint.rbs +7 -0
  136. data/sig/rigor/plugin/fact_store.rbs +11 -0
  137. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  138. data/sig/rigor/plugin/load_error.rbs +6 -0
  139. data/sig/rigor/plugin/loader.rbs +20 -0
  140. data/sig/rigor/plugin/manifest.rbs +9 -0
  141. data/sig/rigor/plugin/registry.rbs +16 -0
  142. data/sig/rigor/plugin/services.rbs +3 -0
  143. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  144. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  145. data/sig/rigor/plugin.rbs +8 -0
  146. data/sig/rigor/scope.rbs +4 -2
  147. data/sig/rigor/type.rbs +28 -6
  148. data/sig/rigor.rbs +35 -2
  149. metadata +90 -1
@@ -12,6 +12,7 @@ require_relative "method_dispatcher/iterator_dispatch"
12
12
  require_relative "method_dispatcher/block_folding"
13
13
  require_relative "method_dispatcher/file_folding"
14
14
  require_relative "method_dispatcher/kernel_dispatch"
15
+ require_relative "method_dispatcher/method_folding"
15
16
 
16
17
  module Rigor
17
18
  module Inference
@@ -61,11 +62,18 @@ module Rigor
61
62
  # @param environment [Rigor::Environment, nil] required for
62
63
  # RBS-backed dispatch; when nil only constant folding can fire.
63
64
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
64
- def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/ParameterLists
65
+ def dispatch(receiver_type:, method_name:, arg_types:,
65
66
  block_type: nil, environment: nil,
66
67
  call_node: nil, scope: nil)
67
68
  return nil if receiver_type.nil?
68
69
 
70
+ bound_method_result = MethodFolding.try_backward(
71
+ receiver: receiver_type, method_name: method_name, args: arg_types,
72
+ block_type: block_type, environment: environment,
73
+ call_node: call_node, scope: scope
74
+ )
75
+ return bound_method_result if bound_method_result
76
+
69
77
  precise = dispatch_precise_tiers(receiver_type, method_name, arg_types, block_type)
70
78
  return precise if precise
71
79
 
@@ -84,7 +92,24 @@ module Rigor
84
92
  receiver: receiver_type, method_name: method_name, args: arg_types,
85
93
  environment: environment, block_type: block_type
86
94
  )
87
- return rbs_result if rbs_result
95
+ if rbs_result
96
+ record_boundary_cross_if_applicable(receiver_type, method_name, rbs_result, environment)
97
+ return rbs_result
98
+ end
99
+
100
+ # ADR-16 Tier B / Tier C — synthetic-method tier. Sits
101
+ # BELOW RBS dispatch (per WD13: user-authored RBS overrides
102
+ # substrate synthesis) and ABOVE the dependency-source
103
+ # inference tier so a plugin's declared emit table beats
104
+ # the generic gem-source fallback for the same class. Slice
105
+ # 6a-TierB (origin_module dispatch) lands precise return
106
+ # types for Tier B emissions; Tier C emissions still return
107
+ # `Dynamic[T]` at this tier (slice 6b is the Tier C
108
+ # promotion via ADR-13's resolver chain).
109
+ synthetic_result = try_synthetic_method(
110
+ receiver_type, method_name, arg_types, block_type, environment
111
+ )
112
+ return synthetic_result if synthetic_result
88
113
 
89
114
  # ADR-10 slice 2b-ii — dependency-source inference tier.
90
115
  # Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
@@ -98,6 +123,21 @@ module Rigor
98
123
  dep_source_result = try_dependency_source(receiver_type, method_name, environment)
99
124
  return dep_source_result if dep_source_result
100
125
 
126
+ # v0.1.3 — discovered-method dispatch tier. When the
127
+ # receiver class has no RBS BUT scope_indexer recorded
128
+ # `def method_name` for that class (or singleton), the
129
+ # call dispatches to `Dynamic[top]` rather than falling
130
+ # through to the user-class fallback. Sits below RBS /
131
+ # dependency-source so authoritative signatures still win.
132
+ # The scope-indexer-built table records every project-side
133
+ # `def`, `define_method`, and `alias_method`; the
134
+ # `discovered_method?` consult here closes the
135
+ # fail-soft-event hot spot on implicit-self calls
136
+ # (`sibling_private(...)`) inside `lib/rigor/`'s own
137
+ # internals (analyser private helpers don't have RBS).
138
+ discovered_result = try_discovered_method(receiver_type, method_name, scope)
139
+ return discovered_result if discovered_result
140
+
101
141
  # Slice 7 phase 10 — user-class ancestor fallback. When
102
142
  # the receiver is `Nominal[T]` or `Singleton[T]` for a
103
143
  # class not in the RBS environment (typically a
@@ -112,6 +152,58 @@ module Rigor
112
152
  try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
113
153
  end
114
154
 
155
+ # v0.1.3 — discovered-method dispatch tier. `scope` carries
156
+ # the `discovered_methods` table built once per program by
157
+ # `ScopeIndexer` (a `Hash[String, Hash[Symbol, :instance |
158
+ # :singleton]]`). When the receiver names a discovered
159
+ # class AND the requested method is recorded for that
160
+ # class's appropriate kind, return `Type::Combinator.untyped`
161
+ # — the dispatcher cannot infer a more precise return type
162
+ # from the bare `def` shape, but the call site stops being a
163
+ # fail-soft hot spot.
164
+ #
165
+ # Returns `nil` when scope / receiver class is unavailable,
166
+ # when the method is not in the discovered table, OR when
167
+ # `discovered_def_nodes` carries a re-typable body for the
168
+ # method (so the downstream
169
+ # `ExpressionTyper#try_user_method_inference` tier can
170
+ # re-type the body for a precise return type rather than
171
+ # collapsing to `Dynamic[top]` here).
172
+ #
173
+ # The tier does NOT gate on `rbs_class_known?`. RBS dispatch
174
+ # already had its turn upstream and returned `nil` (otherwise
175
+ # we wouldn't be here). When RBS knows the class but the
176
+ # particular method is missing from the sig — common for
177
+ # internal helpers and for auto-generated stubs that emit
178
+ # `class X` without enumerating every method — falling
179
+ # through to the user-class fallback would mistakenly fire
180
+ # `call.undefined-method`. Honoring the discovered table
181
+ # here keeps the sibling-private call resolution working
182
+ # under partial RBS coverage.
183
+ def try_discovered_method(receiver_type, method_name, scope)
184
+ return nil if scope.nil?
185
+
186
+ class_name, kind = discovered_method_lookup(receiver_type)
187
+ return nil if class_name.nil?
188
+ return nil unless scope.discovered_method?(class_name, method_name, kind)
189
+ return nil if kind == :instance && scope.user_def_for(class_name, method_name)
190
+
191
+ Type::Combinator.untyped
192
+ end
193
+
194
+ # Resolves the `(class_name, kind)` pair scope_indexer keys
195
+ # its `discovered_methods` table on. `Nominal[X]` looks up
196
+ # instance methods on X; `Singleton[X]` looks up singleton
197
+ # methods on X. Other carriers return `[nil, nil]` so the
198
+ # tier declines.
199
+ def discovered_method_lookup(receiver_type)
200
+ case receiver_type
201
+ when Type::Nominal then [receiver_type.class_name, :instance]
202
+ when Type::Singleton then [receiver_type.class_name, :singleton]
203
+ else [nil, nil]
204
+ end
205
+ end
206
+
115
207
  # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
116
208
  # slice 7. Walks every loaded plugin's
117
209
  # `#flow_contribution_for(call_node:, scope:)` hook,
@@ -147,6 +239,101 @@ module Rigor
147
239
  # publish as ground-truth `T`). Returns `nil` when the
148
240
  # environment carries no index, the index has no entry, or
149
241
  # the receiver has no nominal class to look up.
242
+ # ADR-16 synthetic-method tier. Slice 2b shipped the floor —
243
+ # a match short-circuits at the right precedence (above
244
+ # dep-source / discovered / user-class-fallback; below RBS)
245
+ # and returns `Dynamic[T]`. Slice 6 (precision promotion):
246
+ # - Tier B path (slice 6a, `provenance[:origin_module]`
247
+ # recorded by the slice-3b scanner): redispatch on
248
+ # `Nominal[origin_module]` via `RbsDispatch` so the
249
+ # module's authored RBS return type wins. Devise's
250
+ # `valid_password?` returns `bool`, not `Dynamic[T]`.
251
+ # - Tier C path (slice 6b, plain `return_type:` string from
252
+ # the manifest's emit table): look up
253
+ # `environment.nominal_for_name(return_type)` so
254
+ # `attribute :avatar, Types::String` emits a synthetic
255
+ # reader returning `Nominal[ActiveStorage::Attached::One]`
256
+ # (when the class is in RBS). Unparameterised class names
257
+ # only — parameterised forms (`Array[String]`,
258
+ # `Hash[K, V]`) and plugin-supplied utility-type names
259
+ # (`Pick<T, K>`) require routing through the full ADR-13
260
+ # `Plugin::TypeNodeResolver` chain, which slice 6 does
261
+ # not yet wire in (the resolver chain is consulted only
262
+ # for `%a{rigor:v1:…}` payloads as of ADR-13 slice 3).
263
+ def try_synthetic_method(receiver_type, method_name, arg_types, block_type, environment)
264
+ index = environment&.synthetic_method_index
265
+ return nil if index.nil? || index.empty?
266
+
267
+ class_name = synthetic_method_class_name(receiver_type)
268
+ return nil if class_name.nil?
269
+
270
+ matches = case receiver_type
271
+ when Type::Singleton then index.lookup_singleton(class_name, method_name)
272
+ else index.lookup_instance(class_name, method_name)
273
+ end
274
+ return nil if matches.empty?
275
+
276
+ promoted = promote_synthetic_match(matches, method_name, arg_types, block_type, environment)
277
+ promoted || Type::Combinator.untyped
278
+ end
279
+
280
+ # First non-nil promotion wins. Tier B (origin_module) and
281
+ # Tier C (return_type nominal lookup) are tried in the
282
+ # same registration-order pass per WD11 first-wins —
283
+ # the slice-3b scanner sets `origin_module` for Tier B
284
+ # entries and leaves it absent for Tier C, so the two
285
+ # paths self-route per match.
286
+ def promote_synthetic_match(matches, method_name, arg_types, block_type, environment)
287
+ return nil if environment.nil?
288
+
289
+ matches.each do |synthetic|
290
+ promoted =
291
+ promote_via_origin_module(synthetic, method_name, arg_types, block_type, environment) ||
292
+ promote_via_return_type(synthetic, environment)
293
+ return promoted if promoted
294
+ end
295
+ nil
296
+ end
297
+
298
+ # Slice 6a-TierB. For Tier B emissions (origin_module
299
+ # recorded in provenance), redispatch the call on the
300
+ # included module's `Nominal[...]` type via `RbsDispatch`.
301
+ # Returns nil when the SyntheticMethod is not a Tier B
302
+ # entry or when the origin_module is not in the RBS env.
303
+ def promote_via_origin_module(synthetic, method_name, arg_types, block_type, environment)
304
+ module_name = synthetic.provenance[:origin_module]
305
+ return nil unless module_name
306
+
307
+ module_type = Type::Combinator.nominal_of(module_name)
308
+ RbsDispatch.try_dispatch(
309
+ receiver: module_type, method_name: method_name, args: arg_types,
310
+ environment: environment, block_type: block_type
311
+ )
312
+ end
313
+
314
+ # Slice 6b-TierC. For Tier C emissions, look up the
315
+ # manifest-declared `return_type:` string via
316
+ # `environment.nominal_for_name`. Skips the placeholder
317
+ # `"untyped"` (Tier B's record-but-do-not-resolve marker
318
+ # from the slice-3b scanner) and the `"void"` keyword
319
+ # (RBS-style absent return). Falls back to nil when the
320
+ # class is not in the env — caller then returns Dynamic[T].
321
+ TIER_C_PLACEHOLDER_RETURNS = %w[untyped void].freeze
322
+ private_constant :TIER_C_PLACEHOLDER_RETURNS
323
+
324
+ def promote_via_return_type(synthetic, environment)
325
+ return_type = synthetic.return_type
326
+ return nil if return_type.nil? || TIER_C_PLACEHOLDER_RETURNS.include?(return_type)
327
+
328
+ environment.nominal_for_name(return_type)
329
+ end
330
+
331
+ def synthetic_method_class_name(receiver_type)
332
+ case receiver_type
333
+ when Type::Nominal, Type::Singleton then receiver_type.class_name
334
+ end
335
+ end
336
+
150
337
  def try_dependency_source(receiver_type, method_name, environment)
151
338
  index = environment&.dependency_source_index
152
339
  return nil if index.nil? || index.empty?
@@ -177,6 +364,71 @@ module Rigor
177
364
  budget_silence_result(class_name, index, environment)
178
365
  end
179
366
 
367
+ # ADR-10 slice 5c — record a
368
+ # `dynamic.dependency-source.boundary-cross` event when
369
+ # RBS dispatch resolves a call AND the receiver class
370
+ # belongs to a `mode: :full` opt-in gem whose Walker
371
+ # also catalogued the same `(class_name, method_name)`.
372
+ # The dispatcher still returns the RBS answer (per
373
+ # ADR-10's tier order: authoritative-source wins), but
374
+ # the reporter accumulates the crossing for end-of-run
375
+ # audit diagnostics.
376
+ #
377
+ # Five honest fall-throughs keep the gate narrow:
378
+ #
379
+ # - environment / index / reporter missing — slice 5c
380
+ # needs all three.
381
+ # - receiver has no nominal class name (Dynamic-only
382
+ # carriers) — nothing to look up.
383
+ # - receiver class doesn't belong to a `mode: :full` gem
384
+ # — the user didn't opt this gem into the distinct
385
+ # dispatch path.
386
+ # - the gem-source catalog has no entry for the method —
387
+ # only RBS knows about it; nothing to cross.
388
+ # - the RBS-side result is itself `Dynamic[Top]` — the
389
+ # "agreement" is trivially `untyped ≈ untyped`, no
390
+ # meaningful divergence to flag.
391
+ def record_boundary_cross_if_applicable(receiver_type, method_name, rbs_result, environment)
392
+ class_name = boundary_cross_class_name(receiver_type, environment, rbs_result)
393
+ return if class_name.nil?
394
+
395
+ index = environment.dependency_source_index
396
+ return unless index.full_mode?(class_name)
397
+ return unless index.contribution_for(class_name: class_name, method_name: method_name)
398
+
399
+ environment.boundary_cross_reporter.record(
400
+ class_name: class_name, method_name: method_name,
401
+ gem_name: index.gem_for(class_name),
402
+ rbs_display: rbs_display_for(rbs_result)
403
+ )
404
+ end
405
+
406
+ # Composite preflight for {#record_boundary_cross_if_applicable}.
407
+ # Returns the receiver class name only when every prerequisite
408
+ # for emitting the diagnostic is satisfied (environment carries
409
+ # an index + reporter, receiver is a nominal carrier, RBS-side
410
+ # result is not the trivial `Dynamic[Top]` envelope). Returns
411
+ # `nil` to short-circuit otherwise.
412
+ def boundary_cross_class_name(receiver_type, environment, rbs_result)
413
+ return nil if environment.nil?
414
+ return nil if environment.dependency_source_index.nil?
415
+ return nil if environment.dependency_source_index.empty?
416
+ return nil if environment.boundary_cross_reporter.nil?
417
+ return nil if rbs_result_untyped?(rbs_result)
418
+
419
+ dep_source_class_name(receiver_type)
420
+ end
421
+
422
+ def rbs_result_untyped?(rbs_result)
423
+ rbs_result.is_a?(Type::Dynamic) && rbs_result.static_facet.is_a?(Type::Top)
424
+ end
425
+
426
+ def rbs_display_for(rbs_result)
427
+ return "untyped" if rbs_result.nil?
428
+
429
+ rbs_result.respond_to?(:describe) ? rbs_result.describe : rbs_result.inspect
430
+ end
431
+
180
432
  def budget_silence_result(class_name, index, _environment)
181
433
  return nil unless index.budget_overrun_strategy == :dependency_silence
182
434
 
@@ -242,6 +494,7 @@ module Rigor
242
494
  ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
243
495
  FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
244
496
  KernelDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
497
+ MethodFolding.try_forward(receiver: receiver_type, method_name: method_name, args: arg_types) ||
245
498
  BlockFolding.try_fold(
246
499
  receiver: receiver_type, method_name: method_name, args: arg_types, block_type: block_type
247
500
  )
@@ -325,9 +578,25 @@ module Rigor
325
578
  Type::Combinator.nominal_of(receiver_type.class_name)
326
579
  end
327
580
 
328
- CONSTANT_CONSTRUCTORS = {
329
- "Pathname" => ->(arg) { Pathname.new(arg) }
330
- }.freeze
581
+ # ADR-15 Phase 4b.x — `Ractor.make_shareable` on both the
582
+ # outer Hash and each lambda value. A plain `.freeze` leaves
583
+ # the Procs unshareable; reading `CONSTANT_CONSTRUCTORS[class]`
584
+ # from a worker Ractor would raise `Ractor::IsolationError`,
585
+ # which the `rescue StandardError` in
586
+ # `constant_constructor_lift` silently swallows — `meta_new`
587
+ # then falls back to `Nominal[Pathname]` in pool mode while
588
+ # sequential builds the `Constant<Pathname>` lift. The
589
+ # divergence surfaces downstream as a spurious
590
+ # `call.argument-type-mismatch` (sequential's
591
+ # `argument_type_diagnostic` short-circuits on Constant<Pathname>
592
+ # because Pathname is not in its CONSTANT_CLASSES table; pool's
593
+ # Nominal[Pathname] doesn't short-circuit). Surfaced on GitLab
594
+ # FOSS via `lib/gitlab/mail_room.rb:17`.
595
+ CONSTANT_CONSTRUCTORS = Ractor.make_shareable({
596
+ "Pathname" => Ractor.make_shareable(lambda { |arg|
597
+ Pathname.new(arg)
598
+ })
599
+ })
331
600
  private_constant :CONSTANT_CONSTRUCTORS
332
601
 
333
602
  def constant_constructor_lift(class_name, arg_types)
@@ -32,7 +32,6 @@ module Rigor
32
32
  # `#singleton_method`.
33
33
  #
34
34
  # See docs/internal-spec/inference-engine.md for the binding contract.
35
- # rubocop:disable Metrics/ClassLength
36
35
  class MethodParameterBinder
37
36
  # @param environment [Rigor::Environment]
38
37
  # @param class_path [String, nil] the qualified name of the class
@@ -175,7 +174,7 @@ module Rigor
175
174
  # `non-empty-array[Integer]` describe the parameter binding
176
175
  # they actually want, not its element type.
177
176
  def apply_param_overrides(types, slots, rbs_method)
178
- override_map = RbsExtended.param_type_override_map(rbs_method)
177
+ override_map = RbsExtended.param_type_override_map(rbs_method, environment: @environment)
179
178
  return if override_map.empty?
180
179
 
181
180
  slots.each do |slot|
@@ -212,20 +211,30 @@ module Rigor
212
211
  # (`?by:`) while the Ruby `def` lists it as required (or vice
213
212
  # versa); the binding is by-name regardless of which side
214
213
  # defines it.
215
- KEYWORD_PROVIDER = lambda do |fn, slot|
214
+ KEYWORD_PROVIDER = Ractor.make_shareable(lambda do |fn, slot|
216
215
  fn.required_keywords[slot.name]&.type || fn.optional_keywords[slot.name]&.type
217
- end
216
+ end)
218
217
  private_constant :KEYWORD_PROVIDER
219
218
 
220
- RBS_TYPE_PROVIDERS = {
221
- required_positional: ->(fn, slot) { fn.required_positionals[slot.index]&.type },
222
- optional_positional: ->(fn, slot) { fn.optional_positionals[slot.index]&.type },
223
- rest_positional: ->(fn, _slot) { fn.rest_positionals&.type },
224
- trailing_positional: ->(fn, slot) { fn.trailing_positionals[slot.index]&.type },
225
- required_keyword: KEYWORD_PROVIDER,
226
- optional_keyword: KEYWORD_PROVIDER,
227
- rest_keyword: ->(fn, _slot) { fn.rest_keywords&.type }
228
- }.freeze
219
+ RBS_TYPE_PROVIDERS = Ractor.make_shareable({
220
+ required_positional: Ractor.make_shareable(lambda { |fn, slot|
221
+ fn.required_positionals[slot.index]&.type
222
+ }),
223
+ optional_positional: Ractor.make_shareable(lambda { |fn, slot|
224
+ fn.optional_positionals[slot.index]&.type
225
+ }),
226
+ rest_positional: Ractor.make_shareable(lambda { |fn, _slot|
227
+ fn.rest_positionals&.type
228
+ }),
229
+ trailing_positional: Ractor.make_shareable(lambda { |fn, slot|
230
+ fn.trailing_positionals[slot.index]&.type
231
+ }),
232
+ required_keyword: KEYWORD_PROVIDER,
233
+ optional_keyword: KEYWORD_PROVIDER,
234
+ rest_keyword: Ractor.make_shareable(lambda { |fn, _slot|
235
+ fn.rest_keywords&.type
236
+ })
237
+ })
229
238
  private_constant :RBS_TYPE_PROVIDERS
230
239
 
231
240
  def rbs_type_for_slot(function, slot)
@@ -275,6 +284,5 @@ module Rigor
275
284
  end
276
285
  end
277
286
  end
278
- # rubocop:enable Metrics/ClassLength
279
287
  end
280
288
  end
@@ -376,7 +376,7 @@ module Rigor
376
376
  # the predicate shape is recognised, or `nil` to signal "no
377
377
  # narrowing" so the public surface can fall back to the entry
378
378
  # scope.
379
- def analyse(node, scope) # rubocop:disable Metrics/CyclomaticComplexity
379
+ def analyse(node, scope)
380
380
  case node
381
381
  when Prism::ParenthesesNode
382
382
  analyse_parentheses(node, scope)
@@ -384,6 +384,14 @@ module Rigor
384
384
  analyse_statements(node, scope)
385
385
  when Prism::LocalVariableReadNode
386
386
  analyse_local_read(node, scope)
387
+ when Prism::LocalVariableWriteNode
388
+ analyse_local_write(node, scope)
389
+ when Prism::InstanceVariableWriteNode
390
+ analyse_ivar_write(node, scope)
391
+ when Prism::ClassVariableWriteNode
392
+ analyse_cvar_write(node, scope)
393
+ when Prism::GlobalVariableWriteNode
394
+ analyse_global_write(node, scope)
387
395
  when Prism::CallNode
388
396
  analyse_call(node, scope)
389
397
  when Prism::AndNode
@@ -445,7 +453,6 @@ module Rigor
445
453
  # intersects each half with the integer-domain parts of
446
454
  # `current_type`. Non-integer parts of a Union receiver
447
455
  # (nil, String, …) survive unchanged.
448
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
449
456
  def complement_integer_range(current_type, range)
450
457
  halves = integer_range_complement_halves(range)
451
458
  parts = current_type.is_a?(Type::Union) ? current_type.members : [current_type]
@@ -466,7 +473,6 @@ module Rigor
466
473
 
467
474
  Type::Combinator.union(*survivors)
468
475
  end
469
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
470
476
 
471
477
  # Returns the two open halves of an IntegerRange's
472
478
  # complement: the left half `int<-∞, a-1>` (when `a` is
@@ -738,6 +744,59 @@ module Rigor
738
744
  ]
739
745
  end
740
746
 
747
+ # Assignment-in-condition: `if name = expr` and the more
748
+ # frequent `if cond && (name = expr)` / `if cond && name =
749
+ # expr` Redmine-style guard. By the time narrowing runs,
750
+ # `StatementEvaluator#eval_local_write` has already bound
751
+ # the assigned local in `scope` to the rvalue type. The
752
+ # write's own truthiness IS the assigned value's
753
+ # truthiness, so the truthy edge narrows the local by
754
+ # `narrow_truthy(current)` and the falsey edge by
755
+ # `narrow_falsey(current)`. Mirrors `analyse_local_read`
756
+ # because the only meaningful difference between
757
+ # "predicate is `var`" and "predicate is `var = expr`" is
758
+ # which scope holds the just-bound value; the narrowing
759
+ # contract on the surrounding `if` is the same.
760
+ def analyse_local_write(node, scope)
761
+ current = scope.local(node.name)
762
+ return nil if current.nil?
763
+
764
+ [
765
+ scope.with_local(node.name, narrow_truthy(current)),
766
+ scope.with_local(node.name, narrow_falsey(current))
767
+ ]
768
+ end
769
+
770
+ def analyse_ivar_write(node, scope)
771
+ current = scope.ivar(node.name)
772
+ return nil if current.nil?
773
+
774
+ [
775
+ scope.with_ivar(node.name, narrow_truthy(current)),
776
+ scope.with_ivar(node.name, narrow_falsey(current))
777
+ ]
778
+ end
779
+
780
+ def analyse_cvar_write(node, scope)
781
+ current = scope.cvar(node.name)
782
+ return nil if current.nil?
783
+
784
+ [
785
+ scope.with_cvar(node.name, narrow_truthy(current)),
786
+ scope.with_cvar(node.name, narrow_falsey(current))
787
+ ]
788
+ end
789
+
790
+ def analyse_global_write(node, scope)
791
+ current = scope.global(node.name)
792
+ return nil if current.nil?
793
+
794
+ [
795
+ scope.with_global(node.name, narrow_truthy(current)),
796
+ scope.with_global(node.name, narrow_falsey(current))
797
+ ]
798
+ end
799
+
741
800
  # `if /(?<x>...)/ =~ str` — Prism wraps the `=~` call in a
742
801
  # `MatchWriteNode` listing the named-capture targets. The
743
802
  # parent `eval_match_write` has already bound each target
@@ -918,12 +977,12 @@ module Rigor
918
977
  # zero-arg predicates on `Numeric`. We model them as
919
978
  # comparisons against the literal 0 so the existing range
920
979
  # narrowing handles them uniformly.
921
- ZERO_CLASS_PREDICATE_RULES = {
922
- positive?: { truthy: [:>, 0], falsey: [:<=, 0] },
923
- negative?: { truthy: [:<, 0], falsey: [:>=, 0] },
924
- zero?: { truthy: [:eq, 0], falsey: [:ne, 0] },
925
- nonzero?: { truthy: [:ne, 0], falsey: [:eq, 0] }
926
- }.freeze
980
+ ZERO_CLASS_PREDICATE_RULES = Ractor.make_shareable({
981
+ positive?: { truthy: [:>, 0], falsey: [:<=, 0] },
982
+ negative?: { truthy: [:<, 0], falsey: [:>=, 0] },
983
+ zero?: { truthy: [:eq, 0], falsey: [:ne, 0] },
984
+ nonzero?: { truthy: [:ne, 0], falsey: [:eq, 0] }
985
+ })
927
986
  private_constant :ZERO_CLASS_PREDICATE_RULES
928
987
 
929
988
  def analyse_zero_class_predicate(node, scope, predicate:)
@@ -1204,15 +1263,73 @@ module Rigor
1204
1263
  return nil if node.arguments.nil?
1205
1264
  return nil unless node.arguments.arguments.size == 1
1206
1265
 
1207
- class_name = static_class_name(node.arguments.arguments.first)
1208
- return nil if class_name.nil?
1266
+ bare_name = static_class_name(node.arguments.arguments.first)
1267
+ return nil if bare_name.nil?
1209
1268
 
1210
1269
  current = scope.local(node.receiver.name)
1211
1270
  return nil if current.nil?
1212
1271
 
1272
+ # Resolve `bare_name` through the lexical-scope chain
1273
+ # so a name shadowed by the current class / enclosing
1274
+ # module wins over the top-level constant. Mirrors
1275
+ # Ruby's `Module.nesting`-driven constant lookup. The
1276
+ # canonical motivating case: inside
1277
+ # `Rigor::Type::Singleton#==`, `is_a?(Singleton)`
1278
+ # should resolve to `Rigor::Type::Singleton`, not the
1279
+ # top-level stdlib `Singleton` mixin (which would
1280
+ # surface as a spurious `undefined-method` on
1281
+ # subsequent `other.class_name` calls).
1282
+ class_name = resolve_class_name_lexically(bare_name, scope)
1213
1283
  class_predicate_scopes(scope, node.receiver.name, current, class_name, exact: exact)
1214
1284
  end
1215
1285
 
1286
+ # Walks the lexical-nesting chain derived from
1287
+ # `scope.self_type` and returns the first
1288
+ # `<prefix>::<bare_name>` (or bare `<bare_name>` at the
1289
+ # top level) that the environment recognises. Falls back
1290
+ # to `bare_name` itself when nothing in the chain
1291
+ # resolves; the downstream `narrow_class` then yields
1292
+ # the conservative answer for unknown receivers.
1293
+ def resolve_class_name_lexically(bare_name, scope)
1294
+ return bare_name if bare_name.include?("::") # Already qualified.
1295
+
1296
+ chain = lexical_nesting_for(scope)
1297
+ chain.each do |prefix|
1298
+ candidate = "#{prefix}::#{bare_name}"
1299
+ return candidate if class_known_to_scope?(scope, candidate)
1300
+ end
1301
+ bare_name
1302
+ end
1303
+
1304
+ # Combines the environment's RBS-known set with the
1305
+ # scope's in-source `discovered_classes` table so a
1306
+ # lexical-nesting candidate matches a class the project
1307
+ # declares but has no RBS for.
1308
+ def class_known_to_scope?(scope, candidate)
1309
+ return true if scope.environment.class_known?(candidate)
1310
+
1311
+ scope.discovered_classes.key?(candidate)
1312
+ end
1313
+
1314
+ # Approximates `Module.nesting` from the inferable
1315
+ # `self_type`. Today's implementation handles the common
1316
+ # case: when the surrounding method is a regular
1317
+ # instance method (`self_type = Nominal[T]`) or a
1318
+ # class-body / singleton (`self_type = Singleton[T]`),
1319
+ # the chain is `T`'s namespace path — `Foo::Bar::Baz`
1320
+ # → `["Foo::Bar::Baz", "Foo::Bar", "Foo"]`. Returns an
1321
+ # empty array when `self_type` is unknown.
1322
+ def lexical_nesting_for(scope)
1323
+ self_type = scope.self_type
1324
+ base = case self_type
1325
+ when Type::Nominal, Type::Singleton then self_type.class_name
1326
+ end
1327
+ return [] if base.nil? || base.empty?
1328
+
1329
+ parts = base.split("::")
1330
+ parts.each_index.map { |i| parts[0..-(i + 1)].join("::") }
1331
+ end
1332
+
1216
1333
  def class_predicate_scopes(scope, name, current, class_name, exact:)
1217
1334
  [
1218
1335
  scope.with_local(
@@ -1337,7 +1454,7 @@ module Rigor
1337
1454
  method_def = resolve_rbs_extended_method(node, scope)
1338
1455
  return nil if method_def.nil?
1339
1456
 
1340
- contribution = RbsExtended.read_flow_contribution(method_def)
1457
+ contribution = RbsExtended.read_flow_contribution(method_def, environment: scope.environment)
1341
1458
  return nil if contribution.nil?
1342
1459
 
1343
1460
  result = Rigor::FlowContribution::Merger.merge([contribution])
@@ -41,7 +41,6 @@ module Rigor
41
41
  # `Nominal[C]` regardless of which method body we are in.
42
42
  # When either argument is omitted, the corresponding token degrades
43
43
  # to Dynamic[Top].
44
- # rubocop:disable Metrics/ModuleLength
45
44
  module RbsTypeTranslator
46
45
  # Hash-based dispatch keeps `translate` linear and dodges the
47
46
  # bookkeeping costs of a 20-arm `case` (RuboCop AbcSize/CCN/Length
@@ -214,6 +213,5 @@ module Rigor
214
213
  end
215
214
  end
216
215
  end
217
- # rubocop:enable Metrics/ModuleLength
218
216
  end
219
217
  end