rigortype 0.1.4 → 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -13
  3. data/lib/rigor/analysis/fact_store.rb +15 -3
  4. data/lib/rigor/analysis/result.rb +11 -3
  5. data/lib/rigor/analysis/run_stats.rb +193 -0
  6. data/lib/rigor/analysis/runner.rb +387 -12
  7. data/lib/rigor/analysis/worker_session.rb +327 -0
  8. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  9. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  10. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  11. data/lib/rigor/cache/store.rb +40 -7
  12. data/lib/rigor/cli.rb +52 -2
  13. data/lib/rigor/configuration.rb +131 -6
  14. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  15. data/lib/rigor/environment/class_registry.rb +12 -3
  16. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  17. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  18. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  19. data/lib/rigor/environment/rbs_loader.rb +194 -6
  20. data/lib/rigor/environment/reflection.rb +152 -0
  21. data/lib/rigor/environment.rb +78 -6
  22. data/lib/rigor/inference/acceptance.rb +35 -1
  23. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  24. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  25. data/lib/rigor/inference/expression_typer.rb +12 -2
  26. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  29. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  32. data/lib/rigor/inference/method_dispatcher.rb +128 -3
  33. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  34. data/lib/rigor/inference/narrowing.rb +127 -8
  35. data/lib/rigor/inference/synthetic_method.rb +86 -0
  36. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  37. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  38. data/lib/rigor/plugin/blueprint.rb +60 -0
  39. data/lib/rigor/plugin/loader.rb +3 -1
  40. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  41. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  42. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  43. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  44. data/lib/rigor/plugin/macro.rb +31 -0
  45. data/lib/rigor/plugin/manifest.rb +78 -7
  46. data/lib/rigor/plugin/registry.rb +32 -2
  47. data/lib/rigor/plugin.rb +1 -0
  48. data/lib/rigor/trinary.rb +15 -11
  49. data/lib/rigor/type/bot.rb +6 -3
  50. data/lib/rigor/type/combinator.rb +12 -1
  51. data/lib/rigor/type/integer_range.rb +7 -7
  52. data/lib/rigor/type/refined.rb +18 -12
  53. data/lib/rigor/type/top.rb +4 -3
  54. data/lib/rigor/type_node/generic.rb +7 -1
  55. data/lib/rigor/type_node/identifier.rb +9 -1
  56. data/lib/rigor/type_node/string_literal.rb +4 -1
  57. data/lib/rigor/version.rb +1 -1
  58. data/sig/rigor/environment.rbs +5 -2
  59. data/sig/rigor/plugin/blueprint.rbs +7 -0
  60. data/sig/rigor/plugin/manifest.rbs +1 -1
  61. data/sig/rigor/plugin/registry.rbs +14 -1
  62. data/sig/rigor.rbs +35 -2
  63. metadata +39 -1
@@ -97,6 +97,20 @@ module Rigor
97
97
  return rbs_result
98
98
  end
99
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
113
+
100
114
  # ADR-10 slice 2b-ii — dependency-source inference tier.
101
115
  # Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
102
116
  # stubs / plugin contracts always win) and ABOVE the
@@ -225,6 +239,101 @@ module Rigor
225
239
  # publish as ground-truth `T`). Returns `nil` when the
226
240
  # environment carries no index, the index has no entry, or
227
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
+
228
337
  def try_dependency_source(receiver_type, method_name, environment)
229
338
  index = environment&.dependency_source_index
230
339
  return nil if index.nil? || index.empty?
@@ -469,9 +578,25 @@ module Rigor
469
578
  Type::Combinator.nominal_of(receiver_type.class_name)
470
579
  end
471
580
 
472
- CONSTANT_CONSTRUCTORS = {
473
- "Pathname" => ->(arg) { Pathname.new(arg) }
474
- }.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
+ })
475
600
  private_constant :CONSTANT_CONSTRUCTORS
476
601
 
477
602
  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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-16 Tier C output — one synthetic method declared by a
6
+ # plugin's `Plugin::Macro::HeredocTemplate` entry, after the
7
+ # pre-pass has interpolated the call-site literal symbol into
8
+ # the template name. Stored in {SyntheticMethodIndex} and
9
+ # consulted by {MethodDispatcher} below the RBS dispatch tier.
10
+ #
11
+ # Per ADR-16 § WD13 (cost-bounded best-effort): the v0.1.x
12
+ # delivery commitment is the floor — method names emit; their
13
+ # return types degrade to `Dynamic[T]` until slice 6
14
+ # (precision promotion) routes the recorded `return_type`
15
+ # string through ADR-13's `Plugin::TypeNodeResolver` chain.
16
+ # The string is preserved so the ceiling slice can resolve it
17
+ # without re-walking.
18
+ #
19
+ # The `provenance` Hash carries debug / `--explain` metadata:
20
+ # plugin id, the template's call shape, and the source
21
+ # location of the originating DSL call. Surfaced through the
22
+ # dispatcher's `macro.tier_c.*` provenance markers.
23
+ class SyntheticMethod
24
+ INSTANCE = :instance
25
+ SINGLETON = :singleton
26
+ VALID_KINDS = [INSTANCE, SINGLETON].freeze
27
+
28
+ attr_reader :class_name, :method_name, :return_type, :kind, :provenance
29
+
30
+ def initialize(class_name:, method_name:, return_type:, kind: INSTANCE, provenance: {})
31
+ validate!(class_name, method_name, return_type, kind, provenance)
32
+ @class_name = class_name.dup.freeze
33
+ @method_name = method_name.to_sym
34
+ @return_type = return_type.dup.freeze
35
+ @kind = kind
36
+ @provenance = provenance.transform_keys(&:to_sym).transform_values do |v|
37
+ v.is_a?(String) ? v.dup.freeze : v
38
+ end.freeze
39
+ freeze
40
+ end
41
+
42
+ def instance? = kind == INSTANCE
43
+ def singleton? = kind == SINGLETON
44
+
45
+ def to_h
46
+ {
47
+ "class_name" => class_name,
48
+ "method_name" => method_name.to_s,
49
+ "return_type" => return_type,
50
+ "kind" => kind.to_s,
51
+ "provenance" => provenance.transform_keys(&:to_s)
52
+ }
53
+ end
54
+
55
+ def ==(other)
56
+ other.is_a?(SyntheticMethod) && to_h == other.to_h
57
+ end
58
+ alias eql? ==
59
+
60
+ def hash
61
+ to_h.hash
62
+ end
63
+
64
+ private
65
+
66
+ def validate!(class_name, method_name, return_type, kind, provenance)
67
+ unless class_name.is_a?(String) && !class_name.empty?
68
+ raise ArgumentError, "SyntheticMethod#class_name must be non-empty String, got #{class_name.inspect}"
69
+ end
70
+ unless method_name.is_a?(Symbol) || (method_name.is_a?(String) && !method_name.empty?)
71
+ raise ArgumentError, "SyntheticMethod#method_name must be Symbol/non-empty String, got #{method_name.inspect}"
72
+ end
73
+ unless return_type.is_a?(String) && !return_type.empty?
74
+ raise ArgumentError, "SyntheticMethod#return_type must be non-empty String, got #{return_type.inspect}"
75
+ end
76
+ unless VALID_KINDS.include?(kind)
77
+ raise ArgumentError, "SyntheticMethod#kind must be one of #{VALID_KINDS.inspect}, got #{kind.inspect}"
78
+ end
79
+
80
+ return if provenance.is_a?(Hash)
81
+
82
+ raise ArgumentError, "SyntheticMethod#provenance must be a Hash, got #{provenance.inspect}"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "synthetic_method"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Frozen, Ractor-shareable lookup table for the synthetic
8
+ # methods emitted by ADR-16 Tier C declarations during a
9
+ # single `Analysis::Runner#run`. Constructed by the pre-pass
10
+ # scanner (see {SyntheticMethodScanner}) and consulted by
11
+ # {MethodDispatcher} below `RbsDispatch.try_dispatch` (per WD13:
12
+ # user-authored RBS overrides substrate synthesis).
13
+ #
14
+ # The index is keyed by `(class_name, method_name, kind)`. A
15
+ # single key may resolve to multiple {SyntheticMethod} records
16
+ # if two plugins emit the same name (e.g. `rigor-dry-struct`
17
+ # and a hypothetical `rigor-dry-struct-extras` both registering
18
+ # the same attribute). Per ADR-16 WD11 / the WD-discussion in
19
+ # `## Open questions` the dispatcher uses first-wins by
20
+ # registration order; this index preserves that order in
21
+ # `lookup`'s return.
22
+ #
23
+ # ## Slice 2b — return-type precision posture
24
+ #
25
+ # The recorded `SyntheticMethod#return_type` is a String
26
+ # (e.g. `"ActiveStorage::Attached::One"`), preserved verbatim
27
+ # from the manifest's emit table. Slice 2b's engine wiring
28
+ # treats every match as returning `Dynamic[T]` per WD13's
29
+ # floor — the recorded string is the input to a later slice's
30
+ # precision promotion via ADR-13's `Plugin::TypeNodeResolver`.
31
+ class SyntheticMethodIndex
32
+ attr_reader :entries
33
+
34
+ def initialize(entries: [])
35
+ unless entries.is_a?(Array) && entries.all?(SyntheticMethod)
36
+ raise ArgumentError,
37
+ "SyntheticMethodIndex#entries must be an Array of SyntheticMethod, got #{entries.inspect}"
38
+ end
39
+
40
+ @entries = Ractor.make_shareable(entries.dup)
41
+ @by_instance = Ractor.make_shareable(bucket(entries, SyntheticMethod::INSTANCE))
42
+ @by_singleton = Ractor.make_shareable(bucket(entries, SyntheticMethod::SINGLETON))
43
+ freeze
44
+ end
45
+
46
+ def empty?
47
+ entries.empty?
48
+ end
49
+
50
+ # Returns an Array of matching {SyntheticMethod} records in
51
+ # plugin-registration order. Empty Array when no plugin has
52
+ # declared a Tier C entry that interpolates to this name.
53
+ def lookup_instance(class_name, method_name)
54
+ @by_instance.fetch([class_name, method_name.to_sym], EMPTY_ROW)
55
+ end
56
+
57
+ def lookup_singleton(class_name, method_name)
58
+ @by_singleton.fetch([class_name, method_name.to_sym], EMPTY_ROW)
59
+ end
60
+
61
+ def to_h
62
+ { "entries" => entries.map(&:to_h) }
63
+ end
64
+
65
+ EMPTY_ROW = [].freeze
66
+
67
+ def bucket(entries, kind)
68
+ h = {}
69
+ entries.each do |entry|
70
+ next unless entry.kind == kind
71
+
72
+ key = [entry.class_name, entry.method_name]
73
+ (h[key] ||= []) << entry
74
+ end
75
+ h.each_value(&:freeze).freeze
76
+ end
77
+ private :bucket
78
+
79
+ EMPTY = new(entries: []).freeze
80
+ end
81
+ end
82
+ end