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
@@ -0,0 +1,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../plugin/macro/heredoc_template"
6
+ require_relative "../plugin/macro/trait_registry"
7
+ require_relative "synthetic_method"
8
+ require_relative "synthetic_method_index"
9
+
10
+ module Rigor
11
+ module Inference
12
+ # ADR-16 slice 2b pre-pass — scans the project's source paths
13
+ # for class-level DSL calls that match any registered plugin's
14
+ # `Plugin::Macro::HeredocTemplate` entry, instantiates the
15
+ # corresponding {SyntheticMethod} records, and returns a frozen
16
+ # {SyntheticMethodIndex} the dispatcher consults below the RBS
17
+ # tier (per WD13 — user-authored RBS overrides substrate
18
+ # synthesis).
19
+ #
20
+ # Two-phase walk:
21
+ #
22
+ # 1. **Hierarchy collection.** Visit every `class X < Y` decl
23
+ # in the project source set and record the parent chain in
24
+ # a lexical inheritance map. Cross-file ordering does not
25
+ # matter — every class in `paths:` is observed before
26
+ # matching starts.
27
+ # 2. **Match + emit.** Re-walk each class body looking for
28
+ # `Prism::CallNode` whose name matches a template's
29
+ # `method_name` and whose argument at
30
+ # `symbol_arg_position` is a literal Symbol. The enclosing
31
+ # class must equal or inherit (lexically OR through the
32
+ # RBS env) from the template's `receiver_constraint`.
33
+ #
34
+ # Per WD4 the pre-pass mechanism is "scan all files once at
35
+ # startup, populate the index before per-file inference."
36
+ # Slice 2b ships this strategy; future iterations may revisit
37
+ # to lazy emit (per WD4 alternatives) if the warm-cache profile
38
+ # justifies it.
39
+ #
40
+ # Per WD13 floor — `return_type` is recorded but not resolved.
41
+ # `Macro::HeredocTemplate::Emit#returns` strings round-trip
42
+ # through {SyntheticMethod#return_type} verbatim; the
43
+ # dispatcher's slice-2b tier translates every match to
44
+ # `Dynamic[T]`. Precise resolution via the ADR-13 resolver
45
+ # chain is the ceiling, deferred.
46
+ module SyntheticMethodScanner # rubocop:disable Metrics/ModuleLength
47
+ module_function
48
+
49
+ # @param plugin_registry [Rigor::Plugin::Registry]
50
+ # @param paths [Array<String>] absolute paths to the project
51
+ # source files to scan.
52
+ # @param environment [Rigor::Environment, nil] used for
53
+ # inheritance resolution against RBS-known classes
54
+ # (ActiveRecord::Base, Dry::Struct, etc.) that aren't
55
+ # declared in project source.
56
+ # @return [Rigor::Inference::SyntheticMethodIndex]
57
+ def scan(plugin_registry:, paths:, environment: nil)
58
+ templates = collect_templates(plugin_registry)
59
+ registries = collect_trait_registries(plugin_registry)
60
+ return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty?
61
+
62
+ asts = parse_paths(paths)
63
+ hierarchy = build_hierarchy(asts)
64
+ concern_index = build_concern_index(asts)
65
+
66
+ entries = []
67
+ asts.each do |path, ast|
68
+ walk_class_bodies(ast) do |class_name, call_node|
69
+ collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path)
70
+ collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path)
71
+ collect_concern_re_targeted_entries(
72
+ entries, call_node, class_name, concern_index,
73
+ templates, registries, hierarchy, environment, path
74
+ )
75
+ end
76
+ end
77
+
78
+ SyntheticMethodIndex.new(entries: entries)
79
+ end
80
+
81
+ # Aggregates `(plugin_id, template)` pairs across every
82
+ # plugin's `manifest.heredoc_templates` in registration
83
+ # order. Empty when no plugin contributes Tier C entries.
84
+ def collect_templates(plugin_registry)
85
+ return [] if plugin_registry.nil? || plugin_registry.empty?
86
+
87
+ plugin_registry.plugins.flat_map do |plugin|
88
+ # rigor:disable undefined-method
89
+ plugin.manifest.heredoc_templates.map do |template|
90
+ [plugin.manifest.id, template]
91
+ end
92
+ end
93
+ end
94
+
95
+ # ADR-16 Tier B (slice 3b). Aggregates `(plugin_id, registry)`
96
+ # pairs across every plugin's `manifest.trait_registries` in
97
+ # registration order. Empty when no plugin contributes Tier B
98
+ # entries.
99
+ def collect_trait_registries(plugin_registry)
100
+ return [] if plugin_registry.nil? || plugin_registry.empty?
101
+
102
+ plugin_registry.plugins.flat_map do |plugin|
103
+ # rigor:disable undefined-method
104
+ plugin.manifest.trait_registries.map do |registry|
105
+ [plugin.manifest.id, registry]
106
+ end
107
+ end
108
+ end
109
+
110
+ def parse_paths(paths)
111
+ paths.to_h do |path|
112
+ source = File.read(path)
113
+ [path, Prism.parse(source).value]
114
+ rescue StandardError
115
+ [path, nil]
116
+ end
117
+ end
118
+
119
+ # ADR-16 slice 4 — Concern re-targeting index.
120
+ #
121
+ # Walks every top-level / nested `module M` decl looking for
122
+ # the ActiveSupport::Concern shape:
123
+ #
124
+ # module M
125
+ # extend ActiveSupport::Concern
126
+ # included do
127
+ # # deferred DSL calls — fire on the *includer*, not on M
128
+ # devise :database_authenticatable
129
+ # has_one_attached :avatar
130
+ # end
131
+ # end
132
+ #
133
+ # The returned Hash maps `module_name => [deferred_call_node, ...]`.
134
+ # When a class body later contains `include M`, the substrate
135
+ # replays each deferred call against the including class.
136
+ #
137
+ # Slice 4 scope (floor):
138
+ # - constant-path `include M` only (not `include some_var`).
139
+ # - one-hop: nested concerns (M's `included do; include N; end`)
140
+ # are NOT transitively replayed; deferred. Concrete demand
141
+ # is the trigger for adding the second hop.
142
+ # - `class_methods do ... end` blocks are NOT yet handled —
143
+ # singleton-level emission is out of scope per the slice-3
144
+ # floor framing.
145
+ CONCERN_NAME = "ActiveSupport::Concern"
146
+
147
+ def build_concern_index(asts)
148
+ index = {}
149
+ asts.each_value do |ast|
150
+ next if ast.nil?
151
+
152
+ walk_module_decls(ast, []) do |module_name, body|
153
+ next if module_name.nil? || body.nil?
154
+ next unless concern_module_body?(body)
155
+
156
+ deferred_calls = collect_included_do_calls(body)
157
+ index[module_name] = deferred_calls.freeze if deferred_calls.any?
158
+ end
159
+ end
160
+ index.freeze
161
+ end
162
+
163
+ def walk_module_decls(node, scope_stack, &)
164
+ return unless node.respond_to?(:compact_child_nodes)
165
+
166
+ case node
167
+ when Prism::ModuleNode
168
+ name = class_name_from(node, scope_stack)
169
+ yield name, node.body
170
+ new_stack = scope_stack + [node]
171
+ node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
172
+ when Prism::ClassNode
173
+ new_stack = scope_stack + [node]
174
+ node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
175
+ else
176
+ node.compact_child_nodes.each { |child| walk_module_decls(child, scope_stack, &) }
177
+ end
178
+ end
179
+
180
+ # Recognises a module body that begins with (or contains at
181
+ # top level) an `extend ActiveSupport::Concern` statement.
182
+ def concern_module_body?(body)
183
+ return false unless body.respond_to?(:body)
184
+
185
+ body.body.any? do |stmt|
186
+ next false unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :extend
187
+
188
+ args = stmt.arguments&.arguments || []
189
+ args.any? { |arg| const_name_string(arg) == CONCERN_NAME }
190
+ end
191
+ end
192
+
193
+ def collect_included_do_calls(body)
194
+ body.body.flat_map do |stmt|
195
+ next [] unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :included && stmt.block
196
+
197
+ block_body = stmt.block.body
198
+ next [] unless block_body.respond_to?(:body)
199
+
200
+ block_body.body.select { |inner| inner.is_a?(Prism::CallNode) && inner.receiver.nil? }
201
+ end
202
+ end
203
+
204
+ # Slice 4 hook. When the current class body contains
205
+ # `include M` and M is a Concern with deferred DSL calls,
206
+ # replay each deferred call against the including class.
207
+ # Acts as a re-targeting walker — no new manifest entries
208
+ # needed; downstream `collect_entries` /
209
+ # `collect_trait_entries` fire just as if the calls had been
210
+ # written directly in X's body.
211
+ def collect_concern_re_targeted_entries(entries, call_node, class_name, concern_index, # rubocop:disable Metrics/ParameterLists
212
+ templates, registries, hierarchy, environment, path)
213
+ return unless call_node.name == :include && call_node.receiver.nil?
214
+ return if concern_index.empty?
215
+
216
+ args = call_node.arguments&.arguments || []
217
+ args.each do |arg|
218
+ name = const_name_string(arg)
219
+ deferred = name && concern_index[name]
220
+ next unless deferred
221
+
222
+ deferred.each do |inner_call|
223
+ collect_entries(entries, templates, class_name, inner_call, hierarchy, environment, path)
224
+ collect_trait_entries(entries, registries, class_name, inner_call, hierarchy, environment, path)
225
+ end
226
+ end
227
+ end
228
+
229
+ # Builds a lexical inheritance map `class_name => parent_class_name`
230
+ # by walking every top-level / nested `class X < Y` decl
231
+ # across the AST set.
232
+ def build_hierarchy(asts)
233
+ hierarchy = {}
234
+ asts.each_value do |ast|
235
+ next if ast.nil?
236
+
237
+ walk_class_decls(ast, []) do |class_name, parent_name|
238
+ hierarchy[class_name] = parent_name if parent_name && !hierarchy.key?(class_name)
239
+ end
240
+ end
241
+ hierarchy.freeze
242
+ end
243
+
244
+ def walk_class_decls(node, scope_stack, &) # rubocop:disable Metrics/PerceivedComplexity
245
+ return unless node.respond_to?(:compact_child_nodes)
246
+
247
+ if node.is_a?(Prism::ClassNode)
248
+ name = class_name_from(node, scope_stack)
249
+ parent = parent_name_from(node, scope_stack)
250
+ yield name, parent if name
251
+ new_stack = scope_stack + [node]
252
+ node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
253
+ elsif node.is_a?(Prism::ModuleNode)
254
+ new_stack = scope_stack + [node]
255
+ node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
256
+ else
257
+ node.compact_child_nodes.each { |child| walk_class_decls(child, scope_stack, &) }
258
+ end
259
+ end
260
+
261
+ # Yields `(class_name, call_node)` for every Prism::CallNode
262
+ # at class-body top level (singleton-context calls). Nested
263
+ # method bodies, blocks, and conditionals are skipped — the
264
+ # Tier C call shapes the substrate targets all live at the
265
+ # class body's top level.
266
+ def walk_class_bodies(node, scope_stack = [], &) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
267
+ return unless node.respond_to?(:compact_child_nodes)
268
+
269
+ if node.is_a?(Prism::ClassNode)
270
+ name = class_name_from(node, scope_stack)
271
+ new_stack = scope_stack + [node]
272
+ if name && node.body.respond_to?(:body)
273
+ node.body.body.each do |stmt|
274
+ yield name, stmt if stmt.is_a?(Prism::CallNode) && stmt.receiver.nil?
275
+ end
276
+ end
277
+ node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
278
+ elsif node.is_a?(Prism::ModuleNode)
279
+ new_stack = scope_stack + [node]
280
+ node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
281
+ else
282
+ node.compact_child_nodes.each { |child| walk_class_bodies(child, scope_stack, &) }
283
+ end
284
+ end
285
+
286
+ def class_name_from(class_node, scope_stack)
287
+ local = const_name_string(class_node.constant_path)
288
+ return nil unless local
289
+
290
+ prefix = scope_stack.filter_map do |ancestor|
291
+ case ancestor
292
+ when Prism::ClassNode, Prism::ModuleNode
293
+ const_name_string(ancestor.constant_path)
294
+ end
295
+ end.join("::")
296
+ prefix.empty? ? local : "#{prefix}::#{local}"
297
+ end
298
+
299
+ def parent_name_from(class_node, _scope_stack)
300
+ return nil if class_node.superclass.nil?
301
+
302
+ const_name_string(class_node.superclass)
303
+ end
304
+
305
+ def const_name_string(node)
306
+ case node
307
+ when Prism::ConstantReadNode then node.name.to_s
308
+ when Prism::ConstantPathNode then constant_path_string(node)
309
+ end
310
+ end
311
+
312
+ def constant_path_string(node)
313
+ parent = node.parent
314
+ name = node.name.to_s
315
+ return name if parent.nil?
316
+
317
+ parent_str = const_name_string(parent)
318
+ parent_str ? "#{parent_str}::#{name}" : name
319
+ end
320
+
321
+ def collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path)
322
+ templates.each do |(plugin_id, template)|
323
+ next unless call_node.name == template.method_name
324
+ next unless class_inherits_from?(class_name, template.receiver_constraint, hierarchy, environment)
325
+
326
+ symbol_arg = literal_symbol_arg(call_node, template.symbol_arg_position)
327
+ next if symbol_arg.nil?
328
+
329
+ emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node)
330
+ end
331
+ end
332
+
333
+ # ADR-16 Tier B (slice 3b). For each matching call like
334
+ # `<X>.<method_name>(:trait_a, :trait_b)` where X inherits
335
+ # from the registry's receiver_constraint: collect every
336
+ # registered trait symbol's module (silently skipping
337
+ # unknown traits per design decision (2)) plus the
338
+ # always_included modules, then per-method-explode each
339
+ # module's RBS instance methods into the index.
340
+ #
341
+ # Per slice 3 floor (per user agreement): the synthesised
342
+ # methods adopt `return_type: "untyped"` (Dynamic[T] at
343
+ # dispatch). Precision promotion — looking up the module's
344
+ # actual RBS return type — is reserved for the ceiling slice.
345
+ def collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path)
346
+ registries.each do |(plugin_id, registry)|
347
+ next unless call_node.name == registry.method_name
348
+ next unless class_inherits_from?(class_name, registry.receiver_constraint, hierarchy, environment)
349
+
350
+ modules = resolve_trait_modules(registry, call_node)
351
+ next if modules.empty?
352
+
353
+ emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment)
354
+ end
355
+ end
356
+
357
+ # Resolves the set of modules to include from a Tier B
358
+ # call site:
359
+ #
360
+ # - `always_included` modules (unconditional);
361
+ # - one module per literal Symbol argument the call carries
362
+ # (resolved through `registry.modules_by_symbol`; unknown
363
+ # symbols silently skipped per design decision (2)).
364
+ #
365
+ # Returns an Array<String> of module names in
366
+ # `always_included` order followed by argument order.
367
+ def resolve_trait_modules(registry, call_node)
368
+ modules = registry.always_included.dup
369
+ positional_symbols(call_node, registry).each do |symbol|
370
+ module_name = registry.module_for(symbol)
371
+ modules << module_name if module_name
372
+ end
373
+ modules
374
+ end
375
+
376
+ def positional_symbols(call_node, registry)
377
+ args_node = call_node.arguments
378
+ return [] if args_node.nil?
379
+
380
+ if registry.symbol_arg_position == Rigor::Plugin::Macro::TraitRegistry::REST_POSITION
381
+ args_node.arguments.filter_map { |arg| literal_symbol_value(arg) }
382
+ else
383
+ symbol_arg = literal_symbol_arg(call_node, registry.symbol_arg_position)
384
+ symbol_arg ? [symbol_arg] : []
385
+ end
386
+ end
387
+
388
+ def literal_symbol_value(node)
389
+ case node
390
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped.to_sym
391
+ end
392
+ end
393
+
394
+ def emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment) # rubocop:disable Metrics/ParameterLists
395
+ modules.each do |module_name|
396
+ method_names = module_instance_method_names(module_name, environment)
397
+ method_names.each do |method_name|
398
+ entries << build_trait_synthetic_method(
399
+ class_name: class_name, method_name: method_name, module_name: module_name,
400
+ registry: registry, plugin_id: plugin_id, path: path, call_node: call_node
401
+ )
402
+ end
403
+ end
404
+ end
405
+
406
+ # Returns the Symbol method-name list defined on `module_name`'s
407
+ # RBS instance definition. Empty Array when the module is not
408
+ # in the RBS env (silent skip — the synthetic emit produces
409
+ # nothing rather than fabricating method names).
410
+ def module_instance_method_names(module_name, environment)
411
+ return [] if environment.nil?
412
+
413
+ loader = environment.rbs_loader
414
+ return [] if loader.nil?
415
+
416
+ definition = loader.instance_definition(module_name)
417
+ return [] if definition.nil?
418
+
419
+ definition.methods.keys
420
+ rescue StandardError
421
+ []
422
+ end
423
+
424
+ def build_trait_synthetic_method(class_name:, method_name:, module_name:, registry:, plugin_id:, path:,
425
+ call_node:)
426
+ SyntheticMethod.new(
427
+ class_name: class_name,
428
+ method_name: method_name,
429
+ return_type: "untyped",
430
+ kind: SyntheticMethod::INSTANCE,
431
+ provenance: {
432
+ plugin_id: plugin_id,
433
+ origin_module: module_name,
434
+ trait_method: registry.method_name.to_s,
435
+ template_constraint: registry.receiver_constraint,
436
+ source_path: path,
437
+ source_line: call_node.location.start_line
438
+ }
439
+ )
440
+ end
441
+
442
+ def emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node)
443
+ template.emit.each do |row|
444
+ entries << build_synthetic_method(
445
+ class_name: class_name, name_arg: symbol_arg, row: row,
446
+ template: template, plugin_id: plugin_id, path: path, call_node: call_node,
447
+ kind: SyntheticMethod::INSTANCE
448
+ )
449
+ end
450
+ template.class_level_emit.each do |row|
451
+ entries << build_synthetic_method(
452
+ class_name: class_name, name_arg: symbol_arg, row: row,
453
+ template: template, plugin_id: plugin_id, path: path, call_node: call_node,
454
+ kind: SyntheticMethod::SINGLETON
455
+ )
456
+ end
457
+ end
458
+
459
+ def build_synthetic_method(class_name:, name_arg:, row:, template:, plugin_id:, path:, call_node:, kind:) # rubocop:disable Metrics/ParameterLists
460
+ SyntheticMethod.new(
461
+ class_name: class_name,
462
+ method_name: interpolate(row.name, name_arg).to_sym,
463
+ return_type: row.returns,
464
+ kind: kind,
465
+ provenance: {
466
+ plugin_id: plugin_id,
467
+ template_method: template.method_name.to_s,
468
+ template_constraint: template.receiver_constraint,
469
+ source_path: path,
470
+ source_line: call_node.location.start_line
471
+ }
472
+ )
473
+ end
474
+
475
+ def interpolate(template_name, name_arg)
476
+ template_name.gsub(Rigor::Plugin::Macro::HeredocTemplate::NAME_PLACEHOLDER, name_arg.to_s)
477
+ end
478
+
479
+ def class_inherits_from?(class_name, constraint, hierarchy, environment)
480
+ return true if class_name == constraint
481
+
482
+ # Walk the project-side lexical chain.
483
+ current = class_name
484
+ visited = Set.new
485
+ while (parent = hierarchy[current]) && !visited.include?(parent)
486
+ return true if parent == constraint
487
+
488
+ visited << parent
489
+ current = parent
490
+ end
491
+
492
+ # Fall back to the env's RBS-aware ordering for the case
493
+ # where the chain terminates at an RBS-known class
494
+ # (ActiveRecord::Base, Dry::Struct, Sinatra::Base, …).
495
+ return false if environment.nil?
496
+
497
+ candidates = [class_name] + visited.to_a + [current]
498
+ candidates.uniq.any? { |name| rbs_subtype?(name, constraint, environment) }
499
+ end
500
+
501
+ def rbs_subtype?(class_name, constraint, environment)
502
+ ordering = environment.class_ordering(class_name, constraint)
503
+ %i[equal subclass].include?(ordering)
504
+ rescue StandardError
505
+ false
506
+ end
507
+
508
+ def literal_symbol_arg(call_node, index)
509
+ args_node = call_node.arguments
510
+ return nil if args_node.nil?
511
+
512
+ arg = args_node.arguments[index]
513
+ return nil unless arg
514
+
515
+ case arg
516
+ when Prism::SymbolNode, Prism::StringNode then arg.unescaped.to_sym
517
+ end
518
+ end
519
+ end
520
+ end
521
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Frozen, `Ractor.shareable?` description of how to materialise
6
+ # a single plugin instance inside a worker.
7
+ # [ADR-15](../../../docs/adr/15-ractor-concurrency.md) Phase 3
8
+ # introduces the carrier so the eventual worker pool can pass
9
+ # `Array<Blueprint>` across a Ractor boundary verbatim; each
10
+ # worker calls {#materialize} once at startup, then owns its
11
+ # plugin instances (and their mutable per-run accumulators)
12
+ # for the lifetime of the worker.
13
+ #
14
+ # Holds the constant path (`String`) of the plugin class — NOT
15
+ # the class object itself. Plugin gems are required from the
16
+ # main Ractor BEFORE any worker spawns, so every Ractor
17
+ # resolves the same constant via `Object.const_get`.
18
+ #
19
+ # The `config` Hash is deep-copied + made shareable at
20
+ # construction so the Blueprint stays decoupled from whatever
21
+ # Hash the project configuration emitted. The original config
22
+ # Hash held by the loader is therefore unaffected by Blueprint
23
+ # construction.
24
+ class Blueprint
25
+ attr_reader :klass_name, :config
26
+
27
+ def initialize(klass_name:, config: {})
28
+ @klass_name = normalise_klass_name(klass_name)
29
+ @config = Ractor.make_shareable(Marshal.load(Marshal.dump(config)))
30
+ freeze
31
+ end
32
+
33
+ # Resolves the plugin class via `Object.const_get`, builds a
34
+ # fresh instance bound to the supplied services container,
35
+ # and calls `#init(services)`. Mirrors
36
+ # {Rigor::Plugin::Loader#instantiate} bit-for-bit so the
37
+ # blueprint-driven path stays consistent with the
38
+ # configuration-driven load path.
39
+ def materialize(services:)
40
+ klass = Object.const_get(@klass_name)
41
+ plugin = klass.new(services: services, config: @config)
42
+ plugin.init(services)
43
+ plugin
44
+ end
45
+
46
+ private
47
+
48
+ def normalise_klass_name(name)
49
+ case name
50
+ when String
51
+ name.dup.freeze
52
+ when Module
53
+ name.name.dup.freeze
54
+ else
55
+ raise ArgumentError, "Blueprint klass_name must be a String or Module, got #{name.class}"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "blueprint"
3
4
  require_relative "registry"
4
5
  require_relative "load_error"
5
6
 
@@ -72,7 +73,8 @@ module Rigor
72
73
  plugins, sort_errors = topo_sort_plugins(plugins)
73
74
  load_errors.concat(sort_errors)
74
75
 
75
- Registry.new(plugins: plugins, load_errors: load_errors)
76
+ blueprints = plugins.map { |plugin| Blueprint.new(klass_name: plugin.class.name, config: plugin.config) }
77
+ Registry.new(plugins: plugins, blueprints: blueprints, load_errors: load_errors)
76
78
  end
77
79
 
78
80
  private