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

.build_concern_index(asts) ⇒ Object



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 147

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.



232
233
234
235
236
237
238
239
240
241
242
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 232

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:) ⇒ Object

rubocop:disable Metrics/ParameterLists



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 459

def build_synthetic_method(class_name:, name_arg:, row:, template:, plugin_id:, path:, call_node:, kind:) # rubocop:disable Metrics/ParameterLists
  SyntheticMethod.new(
    class_name: class_name,
    method_name: interpolate(row.name, name_arg).to_sym,
    return_type: row.returns,
    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



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

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_inherits_from?(class_name, constraint, hierarchy, environment) ⇒ Boolean

Returns:

  • (Boolean)


479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 479

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



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

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



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 211

def collect_concern_re_targeted_entries(entries, call_node, class_name, concern_index, # rubocop:disable Metrics/ParameterLists
                                        templates, registries, hierarchy, environment, path)
  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)
      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) ⇒ Object



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

def collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path)
  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)
  end
end

.collect_included_do_calls(body) ⇒ Object



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

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



84
85
86
87
88
89
90
91
92
93
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 84

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.



345
346
347
348
349
350
351
352
353
354
355
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 345

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.



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

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)


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

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



305
306
307
308
309
310
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 305

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



312
313
314
315
316
317
318
319
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 312

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

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



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

def emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node)
  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
    )
  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
    )
  end
end

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

rubocop:disable Metrics/ParameterLists



394
395
396
397
398
399
400
401
402
403
404
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 394

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

.interpolate(template_name, name_arg) ⇒ Object



475
476
477
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 475

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



508
509
510
511
512
513
514
515
516
517
518
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 508

def literal_symbol_arg(call_node, index)
  args_node = call_node.arguments
  return nil if args_node.nil?

  arg = args_node.arguments[index]
  return nil unless arg

  case arg
  when Prism::SymbolNode, Prism::StringNode then arg.unescaped.to_sym
  end
end

.literal_symbol_value(node) ⇒ Object



388
389
390
391
392
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 388

def literal_symbol_value(node)
  case node
  when Prism::SymbolNode, Prism::StringNode then node.unescaped.to_sym
  end
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).



410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 410

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



299
300
301
302
303
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 299

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) ⇒ Object



110
111
112
113
114
115
116
117
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 110

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

.positional_symbols(call_node, registry) ⇒ Object



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

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

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

Returns:

  • (Boolean)


501
502
503
504
505
506
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 501

def rbs_subtype?(class_name, constraint, environment)
  ordering = environment.class_ordering(class_name, constraint)
  %i[equal subclass].include?(ordering)
rescue StandardError
  false
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.



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

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

Returns:



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 57

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

  asts = parse_paths(paths)
  hierarchy = build_hierarchy(asts)
  concern_index = build_concern_index(asts)

  entries = []
  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)
      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
      )
    end
  end

  SyntheticMethodIndex.new(entries: entries)
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.



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/rigor/inference/synthetic_method_scanner.rb', line 266

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



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

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_module_decls(node, scope_stack) ⇒ Object



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

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