rigortype 0.1.4 → 0.1.6

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. metadata +92 -1
@@ -4,6 +4,8 @@ require_relative "../reflection"
4
4
  require_relative "../type"
5
5
  require_relative "../flow_contribution"
6
6
  require_relative "../flow_contribution/merger"
7
+ require_relative "../builtins/hkt_builtins"
8
+ require_relative "../builtins/static_return_refinements"
7
9
  require_relative "method_dispatcher/constant_folding"
8
10
  require_relative "method_dispatcher/literal_string_folding"
9
11
  require_relative "method_dispatcher/shape_dispatch"
@@ -62,7 +64,7 @@ module Rigor
62
64
  # @param environment [Rigor::Environment, nil] required for
63
65
  # RBS-backed dispatch; when nil only constant folding can fire.
64
66
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
65
- def dispatch(receiver_type:, method_name:, arg_types:,
67
+ def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength
66
68
  block_type: nil, environment: nil,
67
69
  call_node: nil, scope: nil)
68
70
  return nil if receiver_type.nil?
@@ -88,6 +90,32 @@ module Rigor
88
90
  plugin_result = try_plugin_contribution(call_node, scope)
89
91
  return plugin_result if plugin_result
90
92
 
93
+ # ADR-20 slice 3 — Rigor-bundled HKT-builtin return-
94
+ # type tier. Sits ABOVE `RbsDispatch.try_dispatch` so
95
+ # the handful of stdlib methods whose upstream RBS
96
+ # signature is `untyped` but whose runtime shape Rigor
97
+ # models via a Lightweight HKT (`json::value`,
98
+ # eventually `dry_monads::result`, …) get the reduced
99
+ # type instead of `Dynamic[Top]`. The table that
100
+ # populates this tier lives in
101
+ # `Rigor::Builtins::HktBuiltins::METHOD_RETURN_OVERRIDES`;
102
+ # plugin-supplied per-method overrides are out of
103
+ # scope for slice 3 and continue to flow through the
104
+ # `try_plugin_contribution` tier above.
105
+ hkt_builtin_result = try_hkt_builtin_return(receiver_type, method_name, arg_types, environment)
106
+ return hkt_builtin_result if hkt_builtin_result
107
+
108
+ # Rigor-bundled static refinement tier. Sits between HKT
109
+ # and RBS so stdlib methods whose upstream RBS is broader
110
+ # than the documented behaviour (e.g. `Kernel#__dir__`
111
+ # declared `() -> String?` when the documented return is
112
+ # `non-empty-string | nil`) get the tightened type
113
+ # without modifying the vendored `ruby/rbs` submodule.
114
+ # The override table lives in
115
+ # `Rigor::Builtins::StaticReturnRefinements::OVERRIDES`.
116
+ static_refinement = try_static_refinement(receiver_type, method_name, arg_types)
117
+ return static_refinement if static_refinement
118
+
91
119
  rbs_result = RbsDispatch.try_dispatch(
92
120
  receiver: receiver_type, method_name: method_name, args: arg_types,
93
121
  environment: environment, block_type: block_type
@@ -97,6 +125,34 @@ module Rigor
97
125
  return rbs_result
98
126
  end
99
127
 
128
+ # ADR-16 Tier B / Tier C — synthetic-method tier. Sits
129
+ # BELOW RBS dispatch (per WD13: user-authored RBS overrides
130
+ # substrate synthesis) and ABOVE the dependency-source
131
+ # inference tier so a plugin's declared emit table beats
132
+ # the generic gem-source fallback for the same class. Slice
133
+ # 6a-TierB (origin_module dispatch) lands precise return
134
+ # types for Tier B emissions; Tier C emissions still return
135
+ # `Dynamic[T]` at this tier (slice 6b is the Tier C
136
+ # promotion via ADR-13's resolver chain).
137
+ synthetic_result = try_synthetic_method(
138
+ receiver_type, method_name, arg_types, block_type, environment
139
+ )
140
+ return synthetic_result if synthetic_result
141
+
142
+ # ADR-17 slice 2 — project-side patched-method tier.
143
+ # Sits BELOW the substrate / plugin tiers and ABOVE
144
+ # dependency-source inference per ADR-17 § "Inference
145
+ # contract". When the user's `pre_eval:` list named a
146
+ # file that re-opens a class (e.g.,
147
+ # `lib/core_ext/string_extensions.rb` declaring
148
+ # `class String; def to_url; end; end`), the pre-pass
149
+ # populated `ProjectPatchedMethods` with the `(class,
150
+ # method, kind)` triple; this tier surfaces it as
151
+ # `Dynamic[top]` so the patched call resolves
152
+ # cross-file without `call.undefined-method`.
153
+ patched_result = try_project_patched_method(receiver_type, method_name, environment)
154
+ return patched_result if patched_result
155
+
100
156
  # ADR-10 slice 2b-ii — dependency-source inference tier.
101
157
  # Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
102
158
  # stubs / plugin contracts always win) and ABOVE the
@@ -203,6 +259,80 @@ module Rigor
203
259
  # keeps moving — the run-level diagnostic envelope (per
204
260
  # ADR-2 § "Plugin Trust and I/O Policy") is owned by
205
261
  # `Analysis::Runner#plugin_emitted_diagnostics`.
262
+ # ADR-20 slice 3 — looks up the receiver / method pair
263
+ # in {Rigor::Builtins::HktBuiltins::METHOD_RETURN_OVERRIDES}
264
+ # and returns the reduced HKT type. Only fires when the
265
+ # receiver is a {Rigor::Type::Singleton} (the
266
+ # `JSON.parse` shape) and the registry-backed reduction
267
+ # succeeds; returns `nil` otherwise so the dispatcher
268
+ # falls through to RBS.
269
+ def try_hkt_builtin_return(receiver_type, method_name, arg_types, environment)
270
+ return nil if environment.nil?
271
+ return nil unless receiver_type.is_a?(Type::Singleton)
272
+
273
+ Rigor::Builtins::HktBuiltins.method_return_override(
274
+ class_name: receiver_type.class_name,
275
+ method_name: method_name,
276
+ kind: :singleton,
277
+ arg_types: arg_types,
278
+ hkt_registry: environment.hkt_registry
279
+ )
280
+ end
281
+
282
+ # Consults the Rigor-bundled static refinement table for a
283
+ # (owner-class, method-name, kind) entry. Kernel methods
284
+ # are mixed into every non-BasicObject class, so an
285
+ # implicit-self `__dir__` call (receiver_type =
286
+ # Nominal[ClassName]) is matched by looking up Kernel as
287
+ # the owner. Explicit `Kernel.__dir__` (receiver_type =
288
+ # Singleton[Kernel]) and instance-side calls
289
+ # (receiver_type = Nominal[Klass]) share the `:both` row.
290
+ #
291
+ # The receiver-side ancestor check is intentionally cheap:
292
+ # any non-BasicObject Nominal / Singleton matches every
293
+ # Kernel-owned override. BasicObject explicitly excludes
294
+ # Kernel and is therefore rejected. The narrow risk of a
295
+ # user-defined `def __dir__` shadowing Kernel's method
296
+ # would also alter the runtime answer; users with that
297
+ # configuration opt out via a `signature_paths` overlay
298
+ # declaring their own return type.
299
+ def try_static_refinement(receiver_type, method_name, arg_types)
300
+ candidates = Rigor::Builtins::StaticReturnRefinements.owners_for(method_name)
301
+ return nil if candidates.empty?
302
+
303
+ owner = static_refinement_owner_for(receiver_type, candidates)
304
+ return nil unless owner
305
+
306
+ kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
307
+ Rigor::Builtins::StaticReturnRefinements.lookup(
308
+ owner_class_name: owner,
309
+ method_name: method_name,
310
+ kind: kind,
311
+ arg_types: arg_types
312
+ )
313
+ end
314
+
315
+ # Picks the most specific override owner the receiver
316
+ # honours. For Kernel-owned overrides the receiver simply
317
+ # needs to be a real-class Nominal / Singleton (i.e. not
318
+ # BasicObject and not a Dynamic / Constant / shape carrier
319
+ # — those carriers go through their own narrower tiers).
320
+ def static_refinement_owner_for(receiver_type, candidates)
321
+ receiver_class = static_refinement_class_for(receiver_type)
322
+ return nil unless receiver_class
323
+
324
+ return "Kernel" if candidates.include?("Kernel") && receiver_class != "BasicObject"
325
+
326
+ candidates.find { |owner| owner == receiver_class }
327
+ end
328
+
329
+ def static_refinement_class_for(receiver_type)
330
+ case receiver_type
331
+ when Type::Singleton, Type::Nominal
332
+ receiver_type.class_name
333
+ end
334
+ end
335
+
206
336
  def try_plugin_contribution(call_node, scope)
207
337
  return nil if call_node.nil? || scope.nil?
208
338
 
@@ -225,6 +355,125 @@ module Rigor
225
355
  # publish as ground-truth `T`). Returns `nil` when the
226
356
  # environment carries no index, the index has no entry, or
227
357
  # the receiver has no nominal class to look up.
358
+ # ADR-16 synthetic-method tier. Slice 2b shipped the floor —
359
+ # a match short-circuits at the right precedence (above
360
+ # dep-source / discovered / user-class-fallback; below RBS)
361
+ # and returns `Dynamic[T]`. Slice 6 (precision promotion):
362
+ # - Tier B path (slice 6a, `provenance[:origin_module]`
363
+ # recorded by the slice-3b scanner): redispatch on
364
+ # `Nominal[origin_module]` via `RbsDispatch` so the
365
+ # module's authored RBS return type wins. Devise's
366
+ # `valid_password?` returns `bool`, not `Dynamic[T]`.
367
+ # - Tier C path (slice 6b, plain `return_type:` string from
368
+ # the manifest's emit table): look up
369
+ # `environment.nominal_for_name(return_type)` so
370
+ # `attribute :avatar, Types::String` emits a synthetic
371
+ # reader returning `Nominal[ActiveStorage::Attached::One]`
372
+ # (when the class is in RBS). Unparameterised class names
373
+ # only — parameterised forms (`Array[String]`,
374
+ # `Hash[K, V]`) and plugin-supplied utility-type names
375
+ # (`Pick<T, K>`) require routing through the full ADR-13
376
+ # `Plugin::TypeNodeResolver` chain, which slice 6 does
377
+ # not yet wire in (the resolver chain is consulted only
378
+ # for `%a{rigor:v1:…}` payloads as of ADR-13 slice 3).
379
+ def try_synthetic_method(receiver_type, method_name, arg_types, block_type, environment)
380
+ index = environment&.synthetic_method_index
381
+ return nil if index.nil? || index.empty?
382
+
383
+ class_name = synthetic_method_class_name(receiver_type)
384
+ return nil if class_name.nil?
385
+
386
+ matches = case receiver_type
387
+ when Type::Singleton then index.lookup_singleton(class_name, method_name)
388
+ else index.lookup_instance(class_name, method_name)
389
+ end
390
+ return nil if matches.empty?
391
+
392
+ promoted = promote_synthetic_match(matches, method_name, arg_types, block_type, environment)
393
+ promoted || Type::Combinator.untyped
394
+ end
395
+
396
+ # First non-nil promotion wins. Tier B (origin_module) and
397
+ # Tier C (return_type nominal lookup) are tried in the
398
+ # same registration-order pass per WD11 first-wins —
399
+ # the slice-3b scanner sets `origin_module` for Tier B
400
+ # entries and leaves it absent for Tier C, so the two
401
+ # paths self-route per match.
402
+ def promote_synthetic_match(matches, method_name, arg_types, block_type, environment)
403
+ return nil if environment.nil?
404
+
405
+ matches.each do |synthetic|
406
+ promoted =
407
+ promote_via_origin_module(synthetic, method_name, arg_types, block_type, environment) ||
408
+ promote_via_return_type(synthetic, environment)
409
+ return promoted if promoted
410
+ end
411
+ nil
412
+ end
413
+
414
+ # Slice 6a-TierB. For Tier B emissions (origin_module
415
+ # recorded in provenance), redispatch the call on the
416
+ # included module's `Nominal[...]` type via `RbsDispatch`.
417
+ # Returns nil when the SyntheticMethod is not a Tier B
418
+ # entry or when the origin_module is not in the RBS env.
419
+ def promote_via_origin_module(synthetic, method_name, arg_types, block_type, environment)
420
+ module_name = synthetic.provenance[:origin_module]
421
+ return nil unless module_name
422
+
423
+ module_type = Type::Combinator.nominal_of(module_name)
424
+ RbsDispatch.try_dispatch(
425
+ receiver: module_type, method_name: method_name, args: arg_types,
426
+ environment: environment, block_type: block_type
427
+ )
428
+ end
429
+
430
+ # Slice 6b-TierC. For Tier C emissions, look up the
431
+ # manifest-declared `return_type:` string via
432
+ # `environment.nominal_for_name`. Skips the placeholder
433
+ # `"untyped"` (Tier B's record-but-do-not-resolve marker
434
+ # from the slice-3b scanner) and the `"void"` keyword
435
+ # (RBS-style absent return). Falls back to nil when the
436
+ # class is not in the env — caller then returns Dynamic[T].
437
+ TIER_C_PLACEHOLDER_RETURNS = %w[untyped void].freeze
438
+ private_constant :TIER_C_PLACEHOLDER_RETURNS
439
+
440
+ def promote_via_return_type(synthetic, environment)
441
+ return_type = synthetic.return_type
442
+ return nil if return_type.nil? || TIER_C_PLACEHOLDER_RETURNS.include?(return_type)
443
+
444
+ environment.nominal_for_name(return_type)
445
+ end
446
+
447
+ def synthetic_method_class_name(receiver_type)
448
+ case receiver_type
449
+ when Type::Nominal, Type::Singleton then receiver_type.class_name
450
+ end
451
+ end
452
+
453
+ # ADR-17 slice 2 — project-side patched-method tier.
454
+ # Slice 3a uses the registry's heuristic-extracted
455
+ # `return_type` (populated via the same
456
+ # `Analysis::DependencySourceInference::ReturnTypeHeuristic`
457
+ # the ADR-10 walker uses): a `def to_url; "hello"; end`
458
+ # patched onto `String` now resolves `s.to_url` to
459
+ # `Dynamic[Nominal[String]]` instead of the pre-3a
460
+ # `Dynamic[Top]`. Falls back to `Dynamic[Top]` when the
461
+ # heuristic declined (non-literal tail expression).
462
+ def try_project_patched_method(receiver_type, method_name, environment)
463
+ registry = environment&.project_patched_methods
464
+ return nil if registry.nil? || registry.empty?
465
+
466
+ class_name = synthetic_method_class_name(receiver_type)
467
+ return nil if class_name.nil?
468
+
469
+ kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
470
+ entry = registry.lookup(class_name: class_name, method_name: method_name, kind: kind)
471
+ return nil if entry.nil?
472
+ return Type::Combinator.untyped if entry.return_type.nil?
473
+
474
+ Type::Combinator.dynamic(entry.return_type)
475
+ end
476
+
228
477
  def try_dependency_source(receiver_type, method_name, environment)
229
478
  index = environment&.dependency_source_index
230
479
  return nil if index.nil? || index.empty?
@@ -241,8 +490,8 @@ module Rigor
241
490
  # inference must not contribute behind their backs.
242
491
  return nil if plugin_owns_receiver?(class_name, environment)
243
492
 
244
- contribution_kind = index.contribution_for(class_name: class_name, method_name: method_name)
245
- return Type::Combinator.untyped if contribution_kind
493
+ contribution = index.contribution_for(class_name: class_name, method_name: method_name)
494
+ return dependency_source_return_type(contribution) if contribution
246
495
 
247
496
  # ADR-10 5b — β budget semantics. On a catalog miss,
248
497
  # if the receiver class belongs to a budget-exceeded
@@ -294,6 +543,17 @@ module Rigor
294
543
  )
295
544
  end
296
545
 
546
+ # Maps a {DependencySourceInference::Walker::CatalogEntry}
547
+ # to the Type the dispatcher returns at the call site.
548
+ # When the heuristic recovered a static facet, wrap it in
549
+ # `Dynamic[T]` per ADR-10's gem-boundary contract;
550
+ # otherwise fall back to the pre-heuristic `Dynamic[top]`.
551
+ def dependency_source_return_type(contribution)
552
+ return Type::Combinator.untyped if contribution.return_type.nil?
553
+
554
+ Type::Combinator.dynamic(contribution.return_type)
555
+ end
556
+
297
557
  # Composite preflight for {#record_boundary_cross_if_applicable}.
298
558
  # Returns the receiver class name only when every prerequisite
299
559
  # for emitting the diagnostic is satisfied (environment carries
@@ -469,9 +729,25 @@ module Rigor
469
729
  Type::Combinator.nominal_of(receiver_type.class_name)
470
730
  end
471
731
 
472
- CONSTANT_CONSTRUCTORS = {
473
- "Pathname" => ->(arg) { Pathname.new(arg) }
474
- }.freeze
732
+ # ADR-15 Phase 4b.x — `Ractor.make_shareable` on both the
733
+ # outer Hash and each lambda value. A plain `.freeze` leaves
734
+ # the Procs unshareable; reading `CONSTANT_CONSTRUCTORS[class]`
735
+ # from a worker Ractor would raise `Ractor::IsolationError`,
736
+ # which the `rescue StandardError` in
737
+ # `constant_constructor_lift` silently swallows — `meta_new`
738
+ # then falls back to `Nominal[Pathname]` in pool mode while
739
+ # sequential builds the `Constant<Pathname>` lift. The
740
+ # divergence surfaces downstream as a spurious
741
+ # `call.argument-type-mismatch` (sequential's
742
+ # `argument_type_diagnostic` short-circuits on Constant<Pathname>
743
+ # because Pathname is not in its CONSTANT_CLASSES table; pool's
744
+ # Nominal[Pathname] doesn't short-circuit). Surfaced on GitLab
745
+ # FOSS via `lib/gitlab/mail_room.rb:17`.
746
+ CONSTANT_CONSTRUCTORS = Ractor.make_shareable({
747
+ "Pathname" => Ractor.make_shareable(lambda { |arg|
748
+ Pathname.new(arg)
749
+ })
750
+ })
475
751
  private_constant :CONSTANT_CONSTRUCTORS
476
752
 
477
753
  def constant_constructor_lift(class_name, arg_types)
@@ -211,20 +211,30 @@ module Rigor
211
211
  # (`?by:`) while the Ruby `def` lists it as required (or vice
212
212
  # versa); the binding is by-name regardless of which side
213
213
  # defines it.
214
- KEYWORD_PROVIDER = lambda do |fn, slot|
214
+ KEYWORD_PROVIDER = Ractor.make_shareable(lambda do |fn, slot|
215
215
  fn.required_keywords[slot.name]&.type || fn.optional_keywords[slot.name]&.type
216
- end
216
+ end)
217
217
  private_constant :KEYWORD_PROVIDER
218
218
 
219
- RBS_TYPE_PROVIDERS = {
220
- required_positional: ->(fn, slot) { fn.required_positionals[slot.index]&.type },
221
- optional_positional: ->(fn, slot) { fn.optional_positionals[slot.index]&.type },
222
- rest_positional: ->(fn, _slot) { fn.rest_positionals&.type },
223
- trailing_positional: ->(fn, slot) { fn.trailing_positionals[slot.index]&.type },
224
- required_keyword: KEYWORD_PROVIDER,
225
- optional_keyword: KEYWORD_PROVIDER,
226
- rest_keyword: ->(fn, _slot) { fn.rest_keywords&.type }
227
- }.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
+ })
228
238
  private_constant :RBS_TYPE_PROVIDERS
229
239
 
230
240
  def rbs_type_for_slot(function, slot)
@@ -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
@@ -736,6 +744,59 @@ module Rigor
736
744
  ]
737
745
  end
738
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
+
739
800
  # `if /(?<x>...)/ =~ str` — Prism wraps the `=~` call in a
740
801
  # `MatchWriteNode` listing the named-capture targets. The
741
802
  # parent `eval_match_write` has already bound each target
@@ -916,12 +977,12 @@ module Rigor
916
977
  # zero-arg predicates on `Numeric`. We model them as
917
978
  # comparisons against the literal 0 so the existing range
918
979
  # narrowing handles them uniformly.
919
- ZERO_CLASS_PREDICATE_RULES = {
920
- positive?: { truthy: [:>, 0], falsey: [:<=, 0] },
921
- negative?: { truthy: [:<, 0], falsey: [:>=, 0] },
922
- zero?: { truthy: [:eq, 0], falsey: [:ne, 0] },
923
- nonzero?: { truthy: [:ne, 0], falsey: [:eq, 0] }
924
- }.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
+ })
925
986
  private_constant :ZERO_CLASS_PREDICATE_RULES
926
987
 
927
988
  def analyse_zero_class_predicate(node, scope, predicate:)
@@ -1202,15 +1263,73 @@ module Rigor
1202
1263
  return nil if node.arguments.nil?
1203
1264
  return nil unless node.arguments.arguments.size == 1
1204
1265
 
1205
- class_name = static_class_name(node.arguments.arguments.first)
1206
- return nil if class_name.nil?
1266
+ bare_name = static_class_name(node.arguments.arguments.first)
1267
+ return nil if bare_name.nil?
1207
1268
 
1208
1269
  current = scope.local(node.receiver.name)
1209
1270
  return nil if current.nil?
1210
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)
1211
1283
  class_predicate_scopes(scope, node.receiver.name, current, class_name, exact: exact)
1212
1284
  end
1213
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
+
1214
1333
  def class_predicate_scopes(scope, name, current, class_name, exact:)
1215
1334
  [
1216
1335
  scope.with_local(
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-17 § "Inference contract" — project-wide patched-method
6
+ # registry populated by the pre-eval pre-pass (slice 2) from
7
+ # the user's `.rigor.yml` `pre_eval:` list.
8
+ #
9
+ # Each entry records one `def` declaration the pre-pass
10
+ # observed inside a class / module body. The dispatcher's
11
+ # `try_project_patched_method` tier consults this registry
12
+ # between the plugin tier and the dependency-source tier so
13
+ # project-side `lib/core_ext/string_extensions.rb` patches
14
+ # are visible to cross-file dispatch.
15
+ #
16
+ # Slice 2 ships the registry at the **floor**: the dispatcher
17
+ # answers `Type::Combinator.untyped` (Dynamic[Top]) on a hit;
18
+ # return-type inference for patched methods stays deferred
19
+ # (a separate slice when concrete demand surfaces — most
20
+ # real-world `core_ext` patches return shapes the analyzer
21
+ # could heuristically extract via the same machinery the
22
+ # ADR-10 walker uses, but slice 2 keeps the surface narrow).
23
+ class ProjectPatchedMethods
24
+ # Frozen value-object recording one `def` observed by the
25
+ # pre-pass. `class_name` is the qualified prefix
26
+ # (`"String"`, `"Foo::Bar"`); `method_name` is the
27
+ # declared name; `kind` is `:instance` or `:singleton`;
28
+ # `source_path` / `source_line` carry attribution for
29
+ # diagnostics; `return_type` is the
30
+ # {Analysis::DependencySourceInference::ReturnTypeHeuristic}-
31
+ # extracted static facet (a `Rigor::Type::*`) or `nil`
32
+ # when the heuristic declined. The dispatcher wraps a
33
+ # non-nil `return_type` in `Dynamic[T]`; a `nil`
34
+ # `return_type` falls back to `Dynamic[top]`.
35
+ Entry = Data.define(:class_name, :method_name, :kind, :source_path, :source_line, :return_type) do
36
+ def initialize(class_name:, method_name:, kind:, source_path:, source_line:, return_type: nil)
37
+ super
38
+ end
39
+ end
40
+
41
+ attr_reader :by_key
42
+
43
+ # @param entries [Array<Entry>] flat list of declarations
44
+ # observed during the pre-pass. First-write-wins on
45
+ # `(class_name, method_name, kind)` duplicates so the
46
+ # `pre-eval.duplicate-declaration` diagnostic emission
47
+ # stays decoupled from registry behaviour.
48
+ def initialize(entries: [])
49
+ @by_key = entries.each_with_object({}) do |entry, acc|
50
+ key = [entry.class_name, entry.method_name, entry.kind]
51
+ acc[key] ||= entry
52
+ end.freeze
53
+ freeze
54
+ end
55
+
56
+ # @return [Entry, nil] the recorded entry for the given
57
+ # `(class_name, method_name, kind)` triple, or `nil`
58
+ # when no pre-eval file declared it.
59
+ def lookup(class_name:, method_name:, kind:)
60
+ @by_key[[class_name, method_name, kind]]
61
+ end
62
+
63
+ def empty?
64
+ @by_key.empty?
65
+ end
66
+
67
+ EMPTY = new.freeze
68
+ end
69
+ end
70
+ end