Class: Rigor::ModuleGraph::Analyzer
- Inherits:
-
Object
- Object
- Rigor::ModuleGraph::Analyzer
- 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
-
#context ⇒ Object
readonly
Returns the value of attribute context.
-
#path ⇒ Object
readonly
Returns the value of attribute path.
-
#scope ⇒ Object
readonly
Returns the value of attribute scope.
-
#visibility_map ⇒ Object
readonly
Returns the value of attribute visibility_map.
-
#zeitwerk ⇒ Object
readonly
Returns the value of attribute zeitwerk.
Instance Method Summary collapse
- #arg_source(arg) ⇒ Object
- #arguments_of(node) ⇒ Object
-
#association_edges(node) ⇒ Object
Phase 5b — Rails ActiveRecord association edges.
-
#attribute_nodes(node) ⇒ Object
Phase 5a — Node rows for
attr_reader/attr_writer/attr_accessorcalls. - #build_edge(from:, to:, kind:, node:, confidence: :syntax, raw: nil) ⇒ Object
- #build_mixin_edges(owner:, kind:, arg:, node:) ⇒ Object
-
#call_edges(node) ⇒ Object
Emits ‘include` / `prepend` / `extend` edges for a call whose method name is one of `MIXIN_METHODS`.
-
#class_edges(node) ⇒ Object
Emits an ‘inherits` edge when the class declares a superclass.
-
#class_name_from_options(node) ⇒ Object
Pulls class_name: “Foo” (or
:Foo) out of the keyword arguments on an association call. -
#class_node_metadata(node) ⇒ Object
Phase 5a — a Node row for the class declaration itself.
- #column_of(node) ⇒ Object
-
#constant_path_edges(node) ⇒ Object
Phase 2c: a ‘const_ref` edge for a `Foo::Bar` reference inside a method body.
-
#constant_read_edges(node) ⇒ Object
Phase 2c: a ‘const_ref` edge for a bare constant read inside a method body.
- #contains_node?(haystack, needle) ⇒ Boolean
- #emit_const_ref?(node) ⇒ Boolean
-
#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.
-
#initialize(path:, context:, scope: nil, zeitwerk: nil, visibility_map: nil) ⇒ Analyzer
constructor
A new instance of Analyzer.
-
#inside_class_header?(node) ⇒ Boolean
Inside ‘class Foo < Bar; …`, Bar’s ConstantReadNode is a child of the ClassNode itself (constant_path / superclass slots).
-
#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.
- #line_of(node) ⇒ Object
-
#method_node_metadata(node) ⇒ Object
Phase 5a — a Node row for a
def/ def self.. - #mixin_call?(node) ⇒ Boolean
-
#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`.
-
#module_node_metadata(node) ⇒ Object
Phase 5a — a Node row for the module declaration.
- #owner_for_decl(node) ⇒ Object
- #parent_is_constant_path?(node) ⇒ Boolean
- #resolve_via_scope(arg) ⇒ Object
- #symbol_name(arg) ⇒ Object
- #visibility_for(node) ⇒ Object
-
#zeitwerk_confidence(owner) ⇒ Object
Returns :zeitwerk when the path-inferred constant for the current file matches the lexical owner, :syntax otherwise.
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
#context ⇒ Object (readonly)
Returns the value of attribute context.
42 43 44 |
# File 'lib/rigor/module_graph/analyzer.rb', line 42 def context @context end |
#path ⇒ Object (readonly)
Returns the value of attribute path.
42 43 44 |
# File 'lib/rigor/module_graph/analyzer.rb', line 42 def path @path end |
#scope ⇒ Object (readonly)
Returns the value of attribute scope.
42 43 44 |
# File 'lib/rigor/module_graph/analyzer.rb', line 42 def scope @scope end |
#visibility_map ⇒ Object (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 |
#zeitwerk ⇒ Object (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 = (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 (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
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
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) = Inflector.class_name_for(sym) namespace = owner.rpartition("::").first return if namespace.empty? "#{namespace}::#{}" 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.
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.
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
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
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 |