Class: Rigor::ModuleGraph::Analyzer

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/module_graph/analyzer.rb

Overview

Per-node edge extractor. One instance per ‘node_rule` invocation; the plugin builds it with the current path, NodeContext, scope, and (optional) Zeitwerk resolver, then asks for `*_edges(node)`.

Confidence ladder per edge:

  • ‘zeitwerk` when the owner’s lexical name matches the path-inferred name (Phase 2).

  • ‘rigor_type` when a mixin arg is a non-constant whose `scope.type_of` is a Singleton — we read its `class_name` instead of dropping the edge (Phase 3).

  • ‘unresolved` when scope.type_of declines but we still want to record that something was referenced.

  • ‘syntax` otherwise.

Constant Summary collapse

MIXIN_METHODS =
%i[include prepend extend].freeze
ATTR_METHODS =
{
  attr_reader: "read",
  attr_writer: "write",
  attr_accessor: "accessor"
}.freeze
ASSOCIATION_METHODS =
{
  has_many: "has_many",
  belongs_to: "belongs_to",
  has_one: "has_one",
  has_and_belongs_to_many: "has_and_belongs_to_many"
}.freeze
VISIBILITY_MARKERS =
%i[public protected private].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path:, context:, scope: nil, zeitwerk: nil, visibility_map: nil) ⇒ Analyzer

Returns a new instance of Analyzer.



44
45
46
47
48
49
50
# File 'lib/rigor/module_graph/analyzer.rb', line 44

def initialize(path:, context:, scope: nil, zeitwerk: nil, visibility_map: nil)
  @path = path
  @context = context
  @scope = scope
  @zeitwerk = zeitwerk
  @visibility_map = visibility_map
end

Instance Attribute Details

#contextObject (readonly)

Returns the value of attribute context.



42
43
44
# File 'lib/rigor/module_graph/analyzer.rb', line 42

def context
  @context
end

#pathObject (readonly)

Returns the value of attribute path.



42
43
44
# File 'lib/rigor/module_graph/analyzer.rb', line 42

def path
  @path
end

#scopeObject (readonly)

Returns the value of attribute scope.



42
43
44
# File 'lib/rigor/module_graph/analyzer.rb', line 42

def scope
  @scope
end

#visibility_mapObject (readonly)

Returns the value of attribute visibility_map.



42
43
44
# File 'lib/rigor/module_graph/analyzer.rb', line 42

def visibility_map
  @visibility_map
end

#zeitwerkObject (readonly)

Returns the value of attribute zeitwerk.



42
43
44
# File 'lib/rigor/module_graph/analyzer.rb', line 42

def zeitwerk
  @zeitwerk
end

Instance Method Details

#arg_source(arg) ⇒ Object



292
293
294
295
296
297
298
299
# File 'lib/rigor/module_graph/analyzer.rb', line 292

def arg_source(arg)
  loc = arg.location
  return nil unless loc

  loc.slice
rescue StandardError
  nil
end

#arguments_of(node) ⇒ Object



340
341
342
# File 'lib/rigor/module_graph/analyzer.rb', line 340

def arguments_of(node)
  node.arguments ? node.arguments.arguments : []
end

#association_edges(node) ⇒ Object

Phase 5b — Rails ActiveRecord association edges. For has_many :invoices we infer Invoice via the bundled Inflector; class_name: “Foo” overrides win when present.



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/rigor/module_graph/analyzer.rb', line 153

def association_edges(node)
  kind = ASSOCIATION_METHODS[node.name]
  return [] unless kind
  return [] unless node.receiver.nil?

  owner = ConstantName.lexical_owner(context)
  return [] unless owner

  arguments_of(node).filter_map do |arg|
    next unless (sym = symbol_name(arg))

    target = class_name_from_options(node) ||
             infer_associated_class(owner, sym)
    build_edge(
      from: owner, to: target, kind: kind, node: node,
      confidence: :syntax,
      raw: sym
    )
  end
end

#attribute_nodes(node) ⇒ Object

Phase 5a — Node rows for attr_reader / attr_writer / attr_accessor calls. One Node per symbol argument.



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/rigor/module_graph/analyzer.rb', line 120

def attribute_nodes(node)
  access = ATTR_METHODS[node.name]
  return [] unless access
  return [] unless node.receiver.nil?

  owner = ConstantName.lexical_owner(context)
  return [] unless owner

  # Inside a class body the running visibility is what the
  # bare keyword markers set. We approximate by reading the
  # nearest enclosing def-or-attr-marker's visibility — but
  # attr_* calls are sibling statements, not nested defs, so
  # we fall back to public unless the class body's visibility
  # tracker covers them. For MVP we record public; the
  # filter side still excludes private nodes when callers
  # add visibility tracking later.
  attr_visibility = visibility_for(node) || "public"

  arguments_of(node).filter_map do |arg|
    name = symbol_name(arg)
    next unless name

    Node.build(
      kind: "attribute", name: name, owner: owner,
      visibility: attr_visibility, access: access,
      path: path, line: line_of(node), column: column_of(node)
    )
  end
end

#build_edge(from:, to:, kind:, node:, confidence: :syntax, raw: nil) ⇒ Object



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/rigor/module_graph/analyzer.rb', line 361

def build_edge(from:, to:, kind:, node:, confidence: :syntax, raw: nil)
  # Caller's confidence is the floor — we may bump it up
  # when Zeitwerk agrees with the owner's lexical name. We
  # never demote.
  effective = confidence == :syntax ? zeitwerk_confidence(from) : confidence
  Edge.build(
    from: from,
    to: to,
    kind: kind,
    path: path,
    line: line_of(node),
    column: column_of(node),
    confidence: effective.to_s,
    raw: raw
  )
end

#build_mixin_edges(owner:, kind:, arg:, node:) ⇒ Object



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/rigor/module_graph/analyzer.rb', line 251

def build_mixin_edges(owner:, kind:, arg:, node:)
  if (target = ConstantName.render(arg))
    [build_edge(
      from: owner, to: target, kind: kind, node: node
    )]
  else
    resolved = resolve_via_scope(arg)
    if resolved
      [build_edge(
        from: owner, to: resolved, kind: kind, node: node,
        confidence: :rigor_type, raw: arg_source(arg)
      )]
    else
      unresolved_label = arg_source(arg)
      return [] unless unresolved_label

      [build_edge(
        from: owner,
        to: unresolved_label,
        kind: kind,
        node: node,
        confidence: :unresolved,
        raw: unresolved_label
      )]
    end
  end
end

#call_edges(node) ⇒ Object

Emits ‘include` / `prepend` / `extend` edges for a call whose method name is one of `MIXIN_METHODS`. Skips the call when no class/module encloses it (top-level `include` on Object is rare and adds noise to the graph).



197
198
199
200
201
202
203
204
205
206
207
# File 'lib/rigor/module_graph/analyzer.rb', line 197

def call_edges(node)
  return [] unless mixin_call?(node)

  owner = ConstantName.lexical_owner(context)
  return [] unless owner

  kind = node.name.to_s
  arguments_of(node).flat_map do |arg|
    build_mixin_edges(owner: owner, kind: kind, arg: arg, node: node)
  end
end

#class_edges(node) ⇒ Object

Emits an ‘inherits` edge when the class declares a superclass. The owner combines the lexical ancestor chain with the class’s own constant path (so ‘module A; class B::C` resolves to `A::B::C`). Confidence is elevated to `zeitwerk` when the path-inferred name matches.



57
58
59
60
61
62
63
64
65
66
67
# File 'lib/rigor/module_graph/analyzer.rb', line 57

def class_edges(node)
  owner = owner_for_decl(node)
  return [] unless owner

  superclass_name = ConstantName.render(node.superclass)
  return [] unless superclass_name

  [build_edge(
    from: owner, to: superclass_name, kind: "inherits", node: node
  )]
end

#class_name_from_options(node) ⇒ Object

Pulls class_name: “Foo” (or :Foo) out of the keyword arguments on an association call. Returns nil when absent.



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/rigor/module_graph/analyzer.rb', line 316

def class_name_from_options(node)
  args = arguments_of(node)
  keyword_hash = args.find { |a| a.is_a?(Prism::KeywordHashNode) || a.is_a?(Prism::HashNode) }
  return nil unless keyword_hash

  keyword_hash.elements.each do |elem|
    next unless elem.is_a?(Prism::AssocNode)

    key = elem.key
    next unless key.is_a?(Prism::SymbolNode) && key.value == "class_name"

    value = elem.value
    return value.unescaped if value.is_a?(Prism::StringNode)
    return value.value.to_s if value.is_a?(Prism::SymbolNode)
  end
  nil
end

#class_node_metadata(node) ⇒ Object

Phase 5a — a Node row for the class declaration itself. Used by the plugin’s rule: “node” diagnostic emitter so downstream tooling can list classes by file / line.



81
82
83
84
85
86
87
88
89
# File 'lib/rigor/module_graph/analyzer.rb', line 81

def (node)
  owner = owner_for_decl(node)
  return nil unless owner

  Node.build(
    kind: "class", name: owner,
    path: path, line: line_of(node), column: column_of(node)
  )
end

#column_of(node) ⇒ Object



353
354
355
356
357
358
359
# File 'lib/rigor/module_graph/analyzer.rb', line 353

def column_of(node)
  # Prism returns 0-based start_column; downstream tooling
  # and diagnostic JSON expect 1-based columns to match how
  # editors render positions.
  col = node.location&.start_column
  col.nil? ? nil : col + 1
end

#constant_path_edges(node) ⇒ Object

Phase 2c: a ‘const_ref` edge for a `Foo::Bar` reference inside a method body. We only fire on the outermost path — Prism nests a `ConstantPathNode(:Bar)` inside `Foo`’s own ‘ConstantPathNode`, and we’d double-count if we emitted from both.



236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/rigor/module_graph/analyzer.rb', line 236

def constant_path_edges(node)
  return [] unless emit_const_ref?(node)
  return [] if parent_is_constant_path?(node)

  owner = ConstantName.lexical_owner(context)
  return [] unless owner

  target = ConstantName.render(node)
  return [] unless target

  [build_edge(
    from: owner, to: target, kind: "const_ref", node: node
  )]
end

#constant_read_edges(node) ⇒ Object

Phase 2c: a ‘const_ref` edge for a bare constant read inside a method body. The plugin gates on `include_constant_refs`, so this method assumes the caller already decided to look at constant nodes.



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/rigor/module_graph/analyzer.rb', line 213

def constant_read_edges(node)
  return [] unless emit_const_ref?(node)
  # The leftmost name of `Foo::Bar::Baz` is a
  # ConstantReadNode wrapped by the outer ConstantPathNode.
  # The path's own rule covers it, so we skip here.
  return [] if parent_is_constant_path?(node)

  owner = ConstantName.lexical_owner(context)
  return [] unless owner

  [build_edge(
    from: owner,
    to: node.name.to_s,
    kind: "const_ref",
    node: node
  )]
end

#contains_node?(haystack, needle) ⇒ Boolean

Returns:

  • (Boolean)


432
433
434
435
436
437
# File 'lib/rigor/module_graph/analyzer.rb', line 432

def contains_node?(haystack, needle)
  return true if haystack.equal?(needle)
  return false unless haystack.is_a?(Prism::Node)

  haystack.compact_child_nodes.any? { |child| contains_node?(child, needle) }
end

#emit_const_ref?(node) ⇒ Boolean

Returns:

  • (Boolean)


390
391
392
393
394
395
396
397
# File 'lib/rigor/module_graph/analyzer.rb', line 390

def emit_const_ref?(node)
  return false unless context.respond_to?(:enclosing_def)
  return false if context.enclosing_def.nil?
  return false if inside_class_header?(node)
  return false if inside_mixin_args?(node)

  true
end

#infer_associated_class(owner, sym) ⇒ Object

Rails resolves ‘has_many :invoices` inside `Billing::Customer` to `Billing::Invoice`, not the top-level `Invoice`, because `compute_type` walks the owner’s namespace upwards before falling back to the top level. We don’t reproduce that walk (we’d need every constant in scope), but defaulting to the owner’s namespace is the right approximation:

  • ‘class_name: “Foo”` always wins (the explicit override)

  • top-level owners (no enclosing namespace) keep the bare name, matching the previous behaviour

  • namespaced owners get the sibling resolution Rails does by default



185
186
187
188
189
190
191
# File 'lib/rigor/module_graph/analyzer.rb', line 185

def infer_associated_class(owner, sym)
  bare = Inflector.class_name_for(sym)
  namespace = owner.rpartition("::").first
  return bare if namespace.empty?

  "#{namespace}::#{bare}"
end

#inside_class_header?(node) ⇒ Boolean

Inside ‘class Foo < Bar; …`, Bar’s ConstantReadNode is a child of the ClassNode itself (constant_path / superclass slots). We are walked AFTER ‘context.ancestors` has been pushed, so the immediate parent here is the ClassNode.

Returns:

  • (Boolean)


403
404
405
406
407
408
409
410
# File 'lib/rigor/module_graph/analyzer.rb', line 403

def inside_class_header?(node)
  parent = context.ancestors.last
  return false unless parent.is_a?(Prism::ClassNode) ||
                      parent.is_a?(Prism::ModuleNode)

  parent.constant_path.equal?(node) ||
    (parent.respond_to?(:superclass) && parent.superclass.equal?(node))
end

#inside_mixin_args?(node) ⇒ Boolean

‘include Foo` / `prepend Foo` / `extend Foo` — Foo’s ConstantReadNode is reached after the include CallNode is on the ancestor stack. Walk up looking for a recent mixin CallNode where this node sits inside its arguments.

Returns:

  • (Boolean)


416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'lib/rigor/module_graph/analyzer.rb', line 416

def inside_mixin_args?(node)
  target = node
  context.ancestors.reverse_each do |ancestor|
    if ancestor.is_a?(Prism::CallNode) && mixin_call?(ancestor)
      args = arguments_of(ancestor)
      return true if args.any? { |a| contains_node?(a, target) }
    end
    # Stop at the first class / module / def boundary so we
    # don't accidentally bleed into a containing decl.
    break if ancestor.is_a?(Prism::ClassNode) ||
             ancestor.is_a?(Prism::ModuleNode) ||
             ancestor.is_a?(Prism::DefNode)
  end
  false
end

#line_of(node) ⇒ Object



349
350
351
# File 'lib/rigor/module_graph/analyzer.rb', line 349

def line_of(node)
  node.location&.start_line
end

#method_node_metadata(node) ⇒ Object

Phase 5a — a Node row for a def / def self.. Reads visibility from the VisibilityMap when one is wired in; defaults to public otherwise.



105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/rigor/module_graph/analyzer.rb', line 105

def (node)
  owner = ConstantName.lexical_owner(context)
  return nil unless owner

  Node.build(
    kind: node.receiver.nil? ? "instance_method" : "class_method",
    name: node.name.to_s,
    owner: owner,
    visibility: visibility_for(node),
    path: path, line: line_of(node), column: column_of(node)
  )
end

#mixin_call?(node) ⇒ Boolean

Returns:

  • (Boolean)


301
302
303
# File 'lib/rigor/module_graph/analyzer.rb', line 301

def mixin_call?(node)
  MIXIN_METHODS.include?(node.name) && node.receiver.nil?
end

#module_edges(_node) ⇒ Object

Modules don’t introduce dependency edges by themselves —the include/prepend/extend calls inside them do, and those are caught by ‘Prism::CallNode`. Returns an empty array so the plugin’s ‘Prism::ModuleNode` rule can stay symmetric with the class rule.



74
75
76
# File 'lib/rigor/module_graph/analyzer.rb', line 74

def module_edges(_node)
  []
end

#module_node_metadata(node) ⇒ Object

Phase 5a — a Node row for the module declaration.



92
93
94
95
96
97
98
99
100
# File 'lib/rigor/module_graph/analyzer.rb', line 92

def (node)
  owner = owner_for_decl(node)
  return nil unless owner

  Node.build(
    kind: "module", name: owner,
    path: path, line: line_of(node), column: column_of(node)
  )
end

#owner_for_decl(node) ⇒ Object



344
345
346
347
# File 'lib/rigor/module_graph/analyzer.rb', line 344

def owner_for_decl(node)
  own = ConstantName.render(node.constant_path)
  ConstantName.lexical_owner_with(context, own)
end

#parent_is_constant_path?(node) ⇒ Boolean

Returns:

  • (Boolean)


439
440
441
442
# File 'lib/rigor/module_graph/analyzer.rb', line 439

def parent_is_constant_path?(node)
  parent = context.ancestors.last
  parent.is_a?(Prism::ConstantPathNode) && parent.parent.equal?(node)
end

#resolve_via_scope(arg) ⇒ Object



279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/rigor/module_graph/analyzer.rb', line 279

def resolve_via_scope(arg)
  return nil unless scope.respond_to?(:type_of)

  type = scope.type_of(arg)
  return nil if type.nil?

  if defined?(::Rigor::Type::Singleton) && type.is_a?(::Rigor::Type::Singleton)
    type.class_name
  end
rescue StandardError
  nil
end

#symbol_name(arg) ⇒ Object



305
306
307
308
309
310
311
312
# File 'lib/rigor/module_graph/analyzer.rb', line 305

def symbol_name(arg)
  case arg
  when Prism::SymbolNode
    arg.value
  when Prism::StringNode
    arg.unescaped
  end
end

#visibility_for(node) ⇒ Object



334
335
336
337
338
# File 'lib/rigor/module_graph/analyzer.rb', line 334

def visibility_for(node)
  return nil unless visibility_map

  visibility_map.visibility_for(node)
end

#zeitwerk_confidence(owner) ⇒ Object

Returns :zeitwerk when the path-inferred constant for the current file matches the lexical owner, :syntax otherwise. The resolver is optional — when no Zeitwerk config is in play we just stay at :syntax.



382
383
384
385
386
387
388
# File 'lib/rigor/module_graph/analyzer.rb', line 382

def zeitwerk_confidence(owner)
  return :syntax unless zeitwerk
  return :syntax unless path

  inferred = zeitwerk.resolve(path)
  zeitwerk.matches?(owner, inferred) ? :zeitwerk : :syntax
end