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.



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

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



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

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)


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

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



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

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.



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

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



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

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



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

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



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

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)


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

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



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

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.



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

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



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

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



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

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



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

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.



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

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.



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

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.



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

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.



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

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)


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

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



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

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



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

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



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

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



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

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



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

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



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

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



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

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

.interpolate(template_name, name_arg) ⇒ Object



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

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



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

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

.literal_symbol_value(node) ⇒ Object



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

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



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

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



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

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



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

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



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

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



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

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)


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

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


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

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



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

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.



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

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:



62
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
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 62

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)


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

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.



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

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



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

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.



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

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



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

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


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

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