Module: Rigor::Inference::SyntheticMethodScanner

Defined in:
lib/rigor/inference/synthetic_method_scanner.rb

Overview

ADR-16 slice 2b pre-pass — scans the project’s source paths for class-level DSL calls that match any registered plugin’s ‘Plugin::Macro::HeredocTemplate` entry, instantiates the corresponding SyntheticMethod records, and returns a frozen SyntheticMethodIndex the dispatcher consults below the RBS tier (per WD13 — user-authored RBS overrides substrate synthesis).

Two-phase walk:

  1. **Hierarchy collection.** Visit every ‘class X < Y` decl in the project source set and record the parent chain in a lexical inheritance map. Cross-file ordering does not matter — every class in `paths:` is observed before matching starts.

  2. **Match + emit.** Re-walk each class body looking for ‘Prism::CallNode` whose name matches a template’s ‘method_name` and whose argument at `symbol_arg_position` is a literal Symbol. The enclosing class must equal or inherit (lexically OR through the RBS env) from the template’s ‘receiver_constraint`.

Per WD4 the pre-pass mechanism is “scan all files once at startup, populate the index before per-file inference.” Slice 2b ships this strategy; future iterations may revisit to lazy emit (per WD4 alternatives) if the warm-cache profile justifies it.

Per WD13 floor — ‘return_type` is recorded but not resolved. `Macro::HeredocTemplate::Emit#returns` strings round-trip through Rigor::Inference::SyntheticMethod#return_type verbatim; the dispatcher’s slice-2b tier translates every match to ‘Dynamic`. Precise resolution via the ADR-13 resolver chain is the ceiling, deferred.

Constant Summary collapse

CONCERN_NAME =

ADR-16 slice 4 — Concern re-targeting index.

Walks every top-level / nested ‘module M` decl looking for the ActiveSupport::Concern shape:

module M
  extend ActiveSupport::Concern
  included do
    # deferred DSL calls — fire on the *includer*, not on M
    devise :database_authenticatable
    has_one_attached :avatar
  end
end

The returned Hash maps ‘module_name => [deferred_call_node, …]`. When a class body later contains `include M`, the substrate replays each deferred call against the including class.

Slice 4 scope (floor):

  • constant-path ‘include M` only (not `include some_var`).

  • one-hop: nested concerns (M’s ‘included do; include N; end`) are NOT transitively replayed; deferred. Concrete demand is the trigger for adding the second hop.

  • ‘class_methods do … end` blocks are NOT yet handled —singleton-level emission is out of scope per the slice-3 floor framing.

"ActiveSupport::Concern"

Class Method Summary collapse

Class Method Details

.argument_source_representation(call_node, position) ⇒ Object

Extracts the source-text qualified-constant representation of the call’s positional argument (e.g., ‘“Types::String”`). Returns nil for non-constant shapes (literals, method chains, blocks, …). The floor intentionally accepts only ConstantReadNode / ConstantPathNode per ADR-18; chained-call argument resolution stays deferred.



673
674
675
676
677
678
679
680
681
682
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 673

def argument_source_representation(call_node, position)
  args = call_node.arguments&.arguments
  return nil if args.nil? || position >= args.size

  node = args[position]
  case node
  when Prism::ConstantReadNode then node.name.to_s
  when Prism::ConstantPathNode then qualified_constant_name(node)
  end
end

.block_body_statements(block_node) ⇒ Object



237
238
239
240
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 237

def block_body_statements(block_node)
  body = block_node.body
  body.respond_to?(:body) ? body.body.compact : []
end

.body_extends?(body, constraint) ⇒ Boolean

True when the class body carries ‘extend <constraint>` (receiverless `extend` call with the constraint constant as its first argument).

Returns:

  • (Boolean)


204
205
206
207
208
209
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 204

def body_extends?(body, constraint)
  body.any? do |stmt|
    stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :extend &&
      const_name_string(first_arg(stmt)) == constraint
  end
end

.build_concern_index(asts) ⇒ Object



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 304

def build_concern_index(asts)
  index = {}
  asts.each_value do |ast|
    next if ast.nil?

    walk_module_decls(ast, []) do |module_name, body|
      next if module_name.nil? || body.nil?
      next unless concern_module_body?(body)

      deferred_calls = collect_included_do_calls(body)
      index[module_name] = deferred_calls.freeze if deferred_calls.any?
    end
  end
  index.freeze
end

.build_hierarchy(asts) ⇒ Object

Builds a lexical inheritance map ‘class_name => parent_class_name` by walking every top-level / nested `class X < Y` decl across the AST set.



389
390
391
392
393
394
395
396
397
398
399
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 389

def build_hierarchy(asts)
  hierarchy = {}
  asts.each_value do |ast|
    next if ast.nil?

    walk_class_decls(ast, []) do |class_name, parent_name|
      hierarchy[class_name] = parent_name if parent_name && !hierarchy.key?(class_name)
    end
  end
  hierarchy.freeze
end

.build_synthetic_method(class_name:, name_arg:, row:, template:, plugin_id:, path:, call_node:, kind:, fact_store: nil) ⇒ Object

rubocop:disable Metrics/ParameterLists



615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 615

def build_synthetic_method(class_name:, name_arg:, row:, template:, plugin_id:, path:, call_node:, kind:,
                           fact_store: nil)
  # rubocop:enable Metrics/ParameterLists
  SyntheticMethod.new(
    class_name: class_name,
    method_name: interpolate(row.name, name_arg).to_sym,
    return_type: resolve_emit_return_type(row, call_node, fact_store),
    kind: kind,
    provenance: {
      plugin_id: plugin_id,
      template_method: template.method_name.to_s,
      template_constraint: template.receiver_constraint,
      source_path: path,
      source_line: call_node.location.start_line
    }
  )
end

.build_trait_synthetic_method(class_name:, method_name:, module_name:, registry:, plugin_id:, path:, call_node:) ⇒ Object



579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 579

def build_trait_synthetic_method(class_name:, method_name:, module_name:, registry:, plugin_id:, path:,
                                 call_node:)
  SyntheticMethod.new(
    class_name: class_name,
    method_name: method_name,
    return_type: "untyped",
    kind: SyntheticMethod::INSTANCE,
    provenance: {
      plugin_id: plugin_id,
      origin_module: module_name,
      trait_method: registry.method_name.to_s,
      template_constraint: registry.receiver_constraint,
      source_path: path,
      source_line: call_node.location.start_line
    }
  )
end

.class_body_statements(class_node) ⇒ Object



196
197
198
199
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 196

def class_body_statements(class_node)
  body = class_node.body
  body.respond_to?(:body) ? body.body.compact : []
end

.class_inherits_from?(class_name, constraint, hierarchy, environment) ⇒ Boolean

Returns:

  • (Boolean)


699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 699

def class_inherits_from?(class_name, constraint, hierarchy, environment)
  return true if class_name == constraint

  # Walk the project-side lexical chain.
  current = class_name
  visited = Set.new
  while (parent = hierarchy[current]) && !visited.include?(parent)
    return true if parent == constraint

    visited << parent
    current = parent
  end

  # Fall back to the env's RBS-aware ordering for the case
  # where the chain terminates at an RBS-known class
  # (ActiveRecord::Base, Dry::Struct, Sinatra::Base, …).
  return false if environment.nil?

  candidates = [class_name] + visited.to_a + [current]
  candidates.uniq.any? { |name| rbs_subtype?(name, constraint, environment) }
end

.class_name_from(class_node, scope_stack) ⇒ Object



443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 443

def class_name_from(class_node, scope_stack)
  local = const_name_string(class_node.constant_path)
  return nil unless local

  prefix = scope_stack.filter_map do |ancestor|
    case ancestor
    when Prism::ClassNode, Prism::ModuleNode
      const_name_string(ancestor.constant_path)
    end
  end.join("::")
  prefix.empty? ? local : "#{prefix}::#{local}"
end

.collect_concern_re_targeted_entries(entries, call_node, class_name, concern_index, templates, registries, hierarchy, environment, path, fact_store = nil) ⇒ Object

Slice 4 hook. When the current class body contains ‘include M` and M is a Concern with deferred DSL calls, replay each deferred call against the including class. Acts as a re-targeting walker — no new manifest entries needed; downstream `collect_entries` / `collect_trait_entries` fire just as if the calls had been written directly in X’s body.



368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 368

def collect_concern_re_targeted_entries(entries, call_node, class_name, concern_index, # rubocop:disable Metrics/ParameterLists
                                        templates, registries, hierarchy, environment, path, fact_store = nil)
  return unless call_node.name == :include && call_node.receiver.nil?
  return if concern_index.empty?

  args = call_node.arguments&.arguments || []
  args.each do |arg|
    name = const_name_string(arg)
    deferred = name && concern_index[name]
    next unless deferred

    deferred.each do |inner_call|
      collect_entries(entries, templates, class_name, inner_call, hierarchy, environment, path, fact_store)
      collect_trait_entries(entries, registries, class_name, inner_call, hierarchy, environment, path)
    end
  end
end

.collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path, fact_store = nil) ⇒ Object

rubocop:disable Metrics/ParameterLists



478
479
480
481
482
483
484
485
486
487
488
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 478

def collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path, fact_store = nil) # rubocop:disable Metrics/ParameterLists
  templates.each do |(plugin_id, template)|
    next unless call_node.name == template.method_name
    next unless class_inherits_from?(class_name, template.receiver_constraint, hierarchy, environment)

    symbol_arg = literal_symbol_arg(call_node, template.symbol_arg_position)
    next if symbol_arg.nil?

    emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node, fact_store)
  end
end

.collect_included_do_calls(body) ⇒ Object



350
351
352
353
354
355
356
357
358
359
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 350

def collect_included_do_calls(body)
  body.body.flat_map do |stmt|
    next [] unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :included && stmt.block

    block_body = stmt.block.body
    next [] unless block_body.respond_to?(:body)

    block_body.body.select { |inner| inner.is_a?(Prism::CallNode) && inner.receiver.nil? }
  end
end

.collect_nested_class_entries(entries, class_names, nested_templates, ast, path) ⇒ Object

ADR-36 nested-class emission. For each class that ‘extend`s a template’s ‘receiver_constraint` and carries a `<block_method> do … end` block, mint one synthetic subclass per `<variant_method> <Const>, <Type>` row:

class Shape
  extend Mangrove::Enum
  variants do
    variant Circle, Float
  end
end

yields synthetic class ‘Shape::Circle` + instance method `Shape::Circle#inner -> Float`. The variant subclass name is recorded in `class_names` so `Environment#class_known?` resolves the constant (and `.new` dispatches through `meta_new`); `#inner`’s return type is the literal constant type argument (non-constant inner shapes degrade to ‘Dynamic` per the slice-A floor).



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 159

def collect_nested_class_entries(entries, class_names, nested_templates, ast, path)
  return if ast.nil?

  walk_classes(ast) do |class_name, class_node|
    body = class_body_statements(class_node)
    next if body.empty?

    nested_templates.each do |(plugin_id, template)|
      next unless body_extends?(body, template.receiver_constraint)

      each_variant_call(body, template) do |variant_const, inner_node|
        emit_variant(entries, class_names, class_name, variant_const, inner_node, template, plugin_id, path)
      end
    end
  end
end

.collect_nested_class_templates(plugin_registry) ⇒ Object

ADR-36 — aggregates ‘(plugin_id, template)` pairs across every plugin’s ‘manifest.nested_class_templates`. Empty when no plugin contributes the nested-class emission tier.



129
130
131
132
133
134
135
136
137
138
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 129

def collect_nested_class_templates(plugin_registry)
  return [] if plugin_registry.nil? || plugin_registry.empty?

  plugin_registry.plugins.flat_map do |plugin|
    # rigor:disable undefined-method
    plugin.manifest.nested_class_templates.map do |template|
      [plugin.manifest.id, template]
    end
  end
end

.collect_templates(plugin_registry) ⇒ Object

Aggregates ‘(plugin_id, template)` pairs across every plugin’s ‘manifest.heredoc_templates` in registration order. Empty when no plugin contributes Tier C entries.



100
101
102
103
104
105
106
107
108
109
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 100

def collect_templates(plugin_registry)
  return [] if plugin_registry.nil? || plugin_registry.empty?

  plugin_registry.plugins.flat_map do |plugin|
    # rigor:disable undefined-method
    plugin.manifest.heredoc_templates.map do |template|
      [plugin.manifest.id, template]
    end
  end
end

.collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path) ⇒ Object

ADR-16 Tier B (slice 3b). For each matching call like ‘<X>.<method_name>(:trait_a, :trait_b)` where X inherits from the registry’s receiver_constraint: collect every registered trait symbol’s module (silently skipping unknown traits per design decision (2)) plus the always_included modules, then per-method-explode each module’s RBS instance methods into the index.

Per slice 3 floor (per user agreement): the synthesised methods adopt ‘return_type: “untyped”` (Dynamic at dispatch). Precision promotion — looking up the module’s actual RBS return type — is reserved for the ceiling slice.



502
503
504
505
506
507
508
509
510
511
512
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 502

def collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path)
  registries.each do |(plugin_id, registry)|
    next unless call_node.name == registry.method_name
    next unless class_inherits_from?(class_name, registry.receiver_constraint, hierarchy, environment)

    modules = resolve_trait_modules(registry, call_node)
    next if modules.empty?

    emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment)
  end
end

.collect_trait_registries(plugin_registry) ⇒ Object

ADR-16 Tier B (slice 3b). Aggregates ‘(plugin_id, registry)` pairs across every plugin’s ‘manifest.trait_registries` in registration order. Empty when no plugin contributes Tier B entries.



115
116
117
118
119
120
121
122
123
124
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 115

def collect_trait_registries(plugin_registry)
  return [] if plugin_registry.nil? || plugin_registry.empty?

  plugin_registry.plugins.flat_map do |plugin|
    # rigor:disable undefined-method
    plugin.manifest.trait_registries.map do |registry|
      [plugin.manifest.id, registry]
    end
  end
end

.concern_module_body?(body) ⇒ Boolean

Recognises a module body that begins with (or contains at top level) an ‘extend ActiveSupport::Concern` statement.

Returns:

  • (Boolean)


339
340
341
342
343
344
345
346
347
348
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 339

def concern_module_body?(body)
  return false unless body.respond_to?(:body)

  body.body.any? do |stmt|
    next false unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :extend

    args = stmt.arguments&.arguments || []
    args.any? { |arg| const_name_string(arg) == CONCERN_NAME }
  end
end

.const_name_string(node) ⇒ Object



462
463
464
465
466
467
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 462

def const_name_string(node)
  case node
  when Prism::ConstantReadNode then node.name.to_s
  when Prism::ConstantPathNode then constant_path_string(node)
  end
end

.constant_path_string(node) ⇒ Object



469
470
471
472
473
474
475
476
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 469

def constant_path_string(node)
  parent = node.parent
  name = node.name.to_s
  return name if parent.nil?

  parent_str = const_name_string(parent)
  parent_str ? "#{parent_str}::#{name}" : name
end

.each_variant_call(body, template) ⇒ Object

Yields ‘(variant_const_name, inner_type_node)` for every `<variant_method> <Const>, <Type>` call inside the template’s ‘<block_method> do … end` block(s).



214
215
216
217
218
219
220
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 214

def each_variant_call(body, template, &)
  body.each do |stmt|
    next unless variants_block_call?(stmt, template)

    block_body_statements(stmt.block).each { |call| yield_variant(call, template, &) }
  end
end

.emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node, fact_store = nil) ⇒ Object

rubocop:disable Metrics/ParameterLists



597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 597

def emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node, fact_store = nil) # rubocop:disable Metrics/ParameterLists
  template.emit.each do |row|
    entries << build_synthetic_method(
      class_name: class_name, name_arg: symbol_arg, row: row,
      template: template, plugin_id: plugin_id, path: path, call_node: call_node,
      kind: SyntheticMethod::INSTANCE, fact_store: fact_store
    )
  end
  template.class_level_emit.each do |row|
    entries << build_synthetic_method(
      class_name: class_name, name_arg: symbol_arg, row: row,
      template: template, plugin_id: plugin_id, path: path, call_node: call_node,
      kind: SyntheticMethod::SINGLETON, fact_store: fact_store
    )
  end
end

.emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment) ⇒ Object

rubocop:disable Metrics/ParameterLists



549
550
551
552
553
554
555
556
557
558
559
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 549

def emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment) # rubocop:disable Metrics/ParameterLists
  modules.each do |module_name|
    method_names = module_instance_method_names(module_name, environment)
    method_names.each do |method_name|
      entries << build_trait_synthetic_method(
        class_name: class_name, method_name: method_name, module_name: module_name,
        registry: registry, plugin_id: plugin_id, path: path, call_node: call_node
      )
    end
  end
end

.emit_variant(entries, class_names, enclosing, variant_const, inner_node, template, plugin_id, path) ⇒ Object

rubocop:disable Metrics/ParameterLists



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 242

def emit_variant(entries, class_names, enclosing, variant_const, inner_node, template, plugin_id, path) # rubocop:disable Metrics/ParameterLists
  variant_class = "#{enclosing}::#{variant_const}"
  class_names << variant_class
  inner_type = const_name_string(inner_node) || "untyped"

  entries << SyntheticMethod.new(
    class_name: variant_class,
    method_name: template.inner_reader,
    return_type: inner_type,
    kind: SyntheticMethod::INSTANCE,
    provenance: {
      plugin_id: plugin_id,
      tier: "nested_class",
      enclosing: enclosing,
      variant: variant_const,
      source_path: path
    }
  )
end

.first_arg(call_node) ⇒ Object



262
263
264
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 262

def first_arg(call_node)
  call_node.arguments&.arguments&.first
end

.interpolate(template_name, name_arg) ⇒ Object



695
696
697
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 695

def interpolate(template_name, name_arg)
  template_name.gsub(Rigor::Plugin::Macro::HeredocTemplate::NAME_PLACEHOLDER, name_arg.to_s)
end

.literal_symbol_arg(call_node, index) ⇒ Object



728
729
730
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 728

def literal_symbol_arg(call_node, index)
  Source::Literals.symbol_arg(call_node, index)
end

.literal_symbol_value(node) ⇒ Object



545
546
547
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 545

def literal_symbol_value(node)
  Source::Literals.symbol_or_string(node)
end

.module_instance_method_names(module_name, environment) ⇒ Object

Returns the Symbol method-name list defined on ‘module_name`’s RBS instance definition. Empty Array when the module is not in the RBS env (silent skip — the synthetic emit produces nothing rather than fabricating method names).



565
566
567
568
569
570
571
572
573
574
575
576
577
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 565

def module_instance_method_names(module_name, environment)
  return [] if environment.nil?

  loader = environment.rbs_loader
  return [] if loader.nil?

  definition = loader.instance_definition(module_name)
  return [] if definition.nil?

  definition.methods.keys
rescue StandardError
  []
end

.parent_name_from(class_node, _scope_stack) ⇒ Object



456
457
458
459
460
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 456

def parent_name_from(class_node, _scope_stack)
  return nil if class_node.superclass.nil?

  const_name_string(class_node.superclass)
end

.parse_paths(paths, buffer: nil) ⇒ Object



266
267
268
269
270
271
272
273
274
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 266

def parse_paths(paths, buffer: nil)
  paths.to_h do |path|
    physical = buffer ? buffer.resolve(path) : path
    source = File.read(physical)
    [path, Prism.parse(source, filepath: path).value]
  rescue StandardError
    [path, nil]
  end
end

.positional_symbols(call_node, registry) ⇒ Object



533
534
535
536
537
538
539
540
541
542
543
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 533

def positional_symbols(call_node, registry)
  args_node = call_node.arguments
  return [] if args_node.nil?

  if registry.symbol_arg_position == Rigor::Plugin::Macro::TraitRegistry::REST_POSITION
    args_node.arguments.filter_map { |arg| literal_symbol_value(arg) }
  else
    symbol_arg = literal_symbol_arg(call_node, registry.symbol_arg_position)
    symbol_arg ? [symbol_arg] : []
  end
end

.qualified_constant_name(node) ⇒ Object



684
685
686
687
688
689
690
691
692
693
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 684

def qualified_constant_name(node)
  case node
  when Prism::ConstantReadNode then node.name.to_s
  when Prism::ConstantPathNode
    parent_name = node.parent.nil? ? nil : qualified_constant_name(node.parent)
    return nil if !node.parent.nil? && parent_name.nil?

    parent_name.nil? ? node.name.to_s : "#{parent_name}::#{node.name}"
  end
end

.rbs_subtype?(class_name, constraint, environment) ⇒ Boolean

Returns:

  • (Boolean)


721
722
723
724
725
726
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 721

def rbs_subtype?(class_name, constraint, environment)
  ordering = environment.class_ordering(class_name, constraint)
  %i[equal subclass].include?(ordering)
rescue StandardError
  false
end

.resolve_emit_return_type(row, call_node, fact_store) ⇒ Object

ADR-18 three-tier fallback for the synthetic method’s ‘return_type` string:

  1. When ‘row.returns_from_arg` is present AND the call-site argument at the declared position is a resolvable constant reference AND the fact_store has a matching value, use that as the return type.

  2. Else if ‘row.returns` is a non-empty String, use it (the slice-6b static path).

  3. Else use ‘“untyped”` so the dispatcher’s ‘promote_via_return_type` sentinel chain yields `Dynamic`.



645
646
647
648
649
650
651
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 645

def resolve_emit_return_type(row, call_node, fact_store)
  resolved = resolve_returns_from_arg(row.returns_from_arg, call_node, fact_store)
  return resolved if resolved
  return row.returns if row.returns

  "untyped"
end

.resolve_returns_from_arg(returns_from_arg, call_node, fact_store) ⇒ Object



653
654
655
656
657
658
659
660
661
662
663
664
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 653

def resolve_returns_from_arg(returns_from_arg, call_node, fact_store)
  return nil if returns_from_arg.nil?

  source_rep = argument_source_representation(call_node, returns_from_arg.position)
  return nil if source_rep.nil?
  return nil if fact_store.nil?

  fact = fact_store.read(plugin_id: returns_from_arg.plugin_id, name: returns_from_arg.fact)
  return nil unless fact.is_a?(Hash)

  fact[source_rep]
end

.resolve_trait_modules(registry, call_node) ⇒ Object

Resolves the set of modules to include from a Tier B call site:

  • ‘always_included` modules (unconditional);

  • one module per literal Symbol argument the call carries (resolved through ‘registry.modules_by_symbol`; unknown symbols silently skipped per design decision (2)).

Returns an Array<String> of module names in ‘always_included` order followed by argument order.



524
525
526
527
528
529
530
531
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 524

def resolve_trait_modules(registry, call_node)
  modules = registry.always_included.dup
  positional_symbols(call_node, registry).each do |symbol|
    module_name = registry.module_for(symbol)
    modules << module_name if module_name
  end
  modules
end

.scan(plugin_registry:, paths:, environment: nil, fact_store: nil, buffer: nil) ⇒ Rigor::Inference::SyntheticMethodIndex

Parameters:

  • plugin_registry (Rigor::Plugin::Registry)
  • paths (Array<String>)

    absolute paths to the project source files to scan.

  • environment (Rigor::Environment, nil) (defaults to: nil)

    used for inheritance resolution against RBS-known classes (ActiveRecord::Base, Dry::Struct, etc.) that aren’t declared in project source.

  • fact_store (Rigor::Plugin::FactStore, nil) (defaults to: nil)

    the per-run cross-plugin fact store. ADR-18 lookups (‘Plugin::Macro::HeredocTemplate::Emit#returns_from_arg`) consult this at scan time to resolve per-call-site return types from published facts; without it, those emit rows fall back to their static `returns:` (or `“untyped”` → `Dynamic`).

  • buffer (Rigor::Analysis::BufferBinding, nil) (defaults to: nil)

    editor-mode buffer binding. When set, reads for the logical path resolve to the buffer’s physical path so the pre-pass sees the in-flight bytes instead of the on-disk copy.

Returns:



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 70

def scan(plugin_registry:, paths:, environment: nil, fact_store: nil, buffer: nil)
  templates = collect_templates(plugin_registry)
  registries = collect_trait_registries(plugin_registry)
  nested_templates = collect_nested_class_templates(plugin_registry)
  return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?

  asts = parse_paths(paths, buffer: buffer)
  hierarchy = build_hierarchy(asts)
  concern_index = build_concern_index(asts)

  entries = []
  class_names = []
  asts.each do |path, ast|
    walk_class_bodies(ast) do |class_name, call_node|
      collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path, fact_store)
      collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path)
      collect_concern_re_targeted_entries(
        entries, call_node, class_name, concern_index,
        templates, registries, hierarchy, environment, path, fact_store
      )
    end
    collect_nested_class_entries(entries, class_names, nested_templates, ast, path) unless nested_templates.empty?
  end

  SyntheticMethodIndex.new(entries: entries, class_names: class_names)
end

.variants_block_call?(stmt, template) ⇒ Boolean

Returns:

  • (Boolean)


222
223
224
225
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 222

def variants_block_call?(stmt, template)
  stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? &&
    stmt.name == template.block_method && stmt.block.is_a?(Prism::BlockNode)
end

.walk_class_bodies(node, scope_stack = []) ⇒ Object

Yields ‘(class_name, call_node)` for every Prism::CallNode at class-body top level (singleton-context calls). Nested method bodies, blocks, and conditionals are skipped — the Tier C call shapes the substrate targets all live at the class body’s top level.



423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 423

def walk_class_bodies(node, scope_stack = [], &) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
  return unless node.respond_to?(:compact_child_nodes)

  if node.is_a?(Prism::ClassNode)
    name = class_name_from(node, scope_stack)
    new_stack = scope_stack + [node]
    if name && node.body.respond_to?(:body)
      node.body.body.each do |stmt|
        yield name, stmt if stmt.is_a?(Prism::CallNode) && stmt.receiver.nil?
      end
    end
    node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
  elsif node.is_a?(Prism::ModuleNode)
    new_stack = scope_stack + [node]
    node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
  else
    node.compact_child_nodes.each { |child| walk_class_bodies(child, scope_stack, &) }
  end
end

.walk_class_decls(node, scope_stack) ⇒ Object

rubocop:disable Metrics/PerceivedComplexity



401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 401

def walk_class_decls(node, scope_stack, &) # rubocop:disable Metrics/PerceivedComplexity
  return unless node.respond_to?(:compact_child_nodes)

  if node.is_a?(Prism::ClassNode)
    name = class_name_from(node, scope_stack)
    parent = parent_name_from(node, scope_stack)
    yield name, parent if name
    new_stack = scope_stack + [node]
    node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
  elsif node.is_a?(Prism::ModuleNode)
    new_stack = scope_stack + [node]
    node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
  else
    node.compact_child_nodes.each { |child| walk_class_decls(child, scope_stack, &) }
  end
end

.walk_classes(node, scope_stack = []) ⇒ Object

Walks every class declaration, yielding its fully-qualified name and the ‘Prism::ClassNode`. Mirrors `walk_class_bodies`’ scope-stack bookkeeping but hands back the class node itself.



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 179

def walk_classes(node, scope_stack = [], &)
  return unless node.respond_to?(:compact_child_nodes)

  case node
  when Prism::ClassNode
    name = class_name_from(node, scope_stack)
    yield name, node if name
    new_stack = scope_stack + [node]
    node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
  when Prism::ModuleNode
    new_stack = scope_stack + [node]
    node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
  else
    node.compact_child_nodes.each { |child| walk_classes(child, scope_stack, &) }
  end
end

.walk_module_decls(node, scope_stack) ⇒ Object



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 320

def walk_module_decls(node, scope_stack, &)
  return unless node.respond_to?(:compact_child_nodes)

  case node
  when Prism::ModuleNode
    name = class_name_from(node, scope_stack)
    yield name, node.body
    new_stack = scope_stack + [node]
    node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
  when Prism::ClassNode
    new_stack = scope_stack + [node]
    node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
  else
    node.compact_child_nodes.each { |child| walk_module_decls(child, scope_stack, &) }
  end
end

.yield_variant(call, template) {|variant_const, | ... } ⇒ Object

Yields:

  • (variant_const, )


227
228
229
230
231
232
233
234
235
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 227

def yield_variant(call, template)
  return unless call.is_a?(Prism::CallNode) && call.receiver.nil? && call.name == template.variant_method

  args = call.arguments&.arguments || []
  variant_const = const_name_string(args[template.name_arg_position])
  return if variant_const.nil?

  yield variant_const, args[template.inner_arg_position]
end