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[T]. 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.



635
636
637
638
639
640
641
642
643
644
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 635

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



218
219
220
221
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 218

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)


186
187
188
189
190
191
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 186

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



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 283

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.



364
365
366
367
368
369
370
371
372
373
374
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 364

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



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

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



545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 545

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



179
180
181
182
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 179

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)


661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 661

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



416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 416

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.



344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 344

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



451
452
453
454
455
456
457
458
459
460
461
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 451

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



329
330
331
332
333
334
335
336
337
338
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 329

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 extends 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[Top] per the slice-A floor).



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 143

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.



118
119
120
121
122
123
124
125
126
127
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 118

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.



92
93
94
95
96
97
98
99
100
101
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 92

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.



471
472
473
474
475
476
477
478
479
480
481
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 471

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.



105
106
107
108
109
110
111
112
113
114
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 105

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)


318
319
320
321
322
323
324
325
326
327
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 318

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



435
436
437
438
439
440
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 435

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



442
443
444
445
446
447
448
449
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 442

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).



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

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



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

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



516
517
518
519
520
521
522
523
524
525
526
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 516

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



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 223

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



243
244
245
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 243

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

.interpolate(template_name, name_arg) ⇒ Object



657
658
659
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 657

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



689
690
691
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 689

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

.literal_symbol_value(node) ⇒ Object



512
513
514
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 512

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).



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

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



429
430
431
432
433
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 429

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



247
248
249
250
251
252
253
254
255
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 247

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



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

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



646
647
648
649
650
651
652
653
654
655
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 646

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)


682
683
684
685
686
687
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 682

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[Top].


610
611
612
613
614
615
616
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 610

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



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

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 of module names in always_included order followed by argument order.



491
492
493
494
495
496
497
498
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 491

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[Top]).

  • 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:



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 63

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)


203
204
205
206
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 203

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.



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 396

def walk_class_bodies(node, scope_stack = [], &)
  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&.rigor_each_child { |child| walk_class_bodies(child, new_stack, &) }
  elsif node.is_a?(Prism::ModuleNode)
    new_stack = scope_stack + [node]
    node.body&.rigor_each_child { |child| walk_class_bodies(child, new_stack, &) }
  else
    node.rigor_each_child { |child| walk_class_bodies(child, scope_stack, &) }
  end
end

.walk_class_decls(node, scope_stack) ⇒ Object



376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 376

def walk_class_decls(node, scope_stack, &)
  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&.rigor_each_child { |child| walk_class_decls(child, new_stack, &) }
  elsif node.is_a?(Prism::ModuleNode)
    new_stack = scope_stack + [node]
    node.body&.rigor_each_child { |child| walk_class_decls(child, new_stack, &) }
  else
    node.rigor_each_child { |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.



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

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&.rigor_each_child { |child| walk_classes(child, new_stack, &) }
  when Prism::ModuleNode
    new_stack = scope_stack + [node]
    node.body&.rigor_each_child { |child| walk_classes(child, new_stack, &) }
  else
    node.rigor_each_child { |child| walk_classes(child, scope_stack, &) }
  end
end

.walk_module_decls(node, scope_stack) ⇒ Object



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 299

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&.rigor_each_child { |child| walk_module_decls(child, new_stack, &) }
  when Prism::ClassNode
    new_stack = scope_stack + [node]
    node.body&.rigor_each_child { |child| walk_module_decls(child, new_stack, &) }
  else
    node.rigor_each_child { |child| walk_module_decls(child, scope_stack, &) }
  end
end

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

Yields:

  • (variant_const, )


208
209
210
211
212
213
214
215
216
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 208

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.symbol_arg_position])
  return if variant_const.nil?

  yield variant_const, args[template.inner_arg_position]
end