rigor-module-graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module ModuleGraph
5
+ # Renders edges as a Mermaid flowchart.
6
+ #
7
+ # Mermaid does not have per-edge style classes the way DOT does;
8
+ # we use distinct arrow heads per kind (`==>`, `-->`, `-.->`)
9
+ # plus an `:::kind` classDef on the target node so the legend is
10
+ # readable in any Mermaid renderer.
11
+ #
12
+ # When `collapse:` is given, every node whose name sits under one
13
+ # of the listed prefixes is wrapped in a `subgraph <prefix>`
14
+ # block, with the prefix stripped from the visible label.
15
+ module Mermaid
16
+ module_function
17
+
18
+ ARROW_FOR_KIND = {
19
+ "inherits" => "==>",
20
+ "include" => "-->",
21
+ "prepend" => "-->",
22
+ "extend" => "-.->",
23
+ "const_ref" => "-.->"
24
+ }.freeze
25
+
26
+ CLASS_DEFS = <<~MERMAID
27
+ classDef inherits fill:#0f172a,color:#fff,stroke:#0f172a;
28
+ classDef include fill:#1d4ed8,color:#fff,stroke:#1d4ed8;
29
+ classDef prepend fill:#9333ea,color:#fff,stroke:#9333ea;
30
+ classDef extend fill:#0f766e,color:#fff,stroke:#0f766e;
31
+ classDef const_ref fill:#cbd5e1,color:#0f172a,stroke:#94a3b8;
32
+ classDef unresolved fill:#fef3c7,color:#0f172a,stroke:#d97706,stroke-dasharray: 4 4;
33
+ MERMAID
34
+
35
+ # @param edges [Array<Edge>]
36
+ # @param collapse [Array<String>] namespace prefixes to fold
37
+ # into subgraphs (mutually exclusive with +groups+)
38
+ # @param groups [Hash{String=>String}, nil] explicit
39
+ # +{node_name => cluster_label}+ mapping. Takes precedence
40
+ # over +collapse+ when given.
41
+ def render(edges, collapse: [], groups: nil)
42
+ edges = dedup(edges)
43
+ node_ids = assign_node_ids(edges)
44
+ clusters, ungrouped = build_groups(node_ids.keys.sort, collapse, groups)
45
+
46
+ out = +"flowchart LR\n"
47
+ clusters.each do |label, members|
48
+ out << render_cluster(label, members, node_ids, use_namespace_prefix: groups.nil?)
49
+ end
50
+ ungrouped.each do |name|
51
+ out << " #{node_ids[name]}[\"#{escape_label(name)}\"]\n"
52
+ end
53
+ out << "\n" unless node_ids.empty?
54
+ edges.each do |edge|
55
+ arrow = ARROW_FOR_KIND.fetch(edge.kind, "-->")
56
+ out << " #{node_ids[edge.from]} #{arrow}|#{edge.kind}| #{node_ids[edge.to]}\n"
57
+ end
58
+ out << "\n"
59
+ out << CLASS_DEFS
60
+ # One class assignment per node id. Mermaid silently keeps
61
+ # the last assignment but starts to error out when the same
62
+ # `class N kind;` line repeats many hundreds of times in a
63
+ # large graph, so we dedupe and pick the most structural
64
+ # kind per node (inherits > include > prepend > extend >
65
+ # const_ref) so the resulting colour conveys intent.
66
+ out << render_class_assignments(edges, node_ids)
67
+ out
68
+ end
69
+
70
+ KIND_PRIORITY = {
71
+ "inherits" => 0,
72
+ "include" => 1,
73
+ "prepend" => 2,
74
+ "extend" => 3,
75
+ "const_ref" => 4
76
+ }.freeze
77
+
78
+ def render_class_assignments(edges, node_ids)
79
+ per_node = {}
80
+ edges.each do |edge|
81
+ id = node_ids[edge.to]
82
+ tag = edge.confidence == "unresolved" ? "unresolved" : edge.kind
83
+ current = per_node[id]
84
+ if current.nil? || better_tag?(tag, current)
85
+ per_node[id] = tag
86
+ end
87
+ end
88
+ per_node.map { |id, tag| " class #{id} #{tag};\n" }.join
89
+ end
90
+
91
+ def better_tag?(candidate, current)
92
+ return false if current == "unresolved"
93
+ return true if candidate == "unresolved"
94
+
95
+ (KIND_PRIORITY[candidate] || 99) < (KIND_PRIORITY[current] || 99)
96
+ end
97
+
98
+ def dedup(edges)
99
+ seen = {}
100
+ edges.each_with_object([]) do |edge, acc|
101
+ key = edge.dedup_key
102
+ next if seen[key]
103
+
104
+ seen[key] = true
105
+ acc << edge
106
+ end
107
+ end
108
+
109
+ def assign_node_ids(edges)
110
+ names = edges.flat_map { |edge| [edge.from, edge.to] }.uniq.sort
111
+ names.each_with_index.to_h { |name, idx| [name, "n#{idx}"] }
112
+ end
113
+
114
+ def build_groups(names, collapse, groups)
115
+ if groups && !groups.empty?
116
+ clusters = Hash.new { |h, k| h[k] = [] }
117
+ ungrouped = []
118
+ names.each do |name|
119
+ if (label = groups[name])
120
+ clusters[label] << name
121
+ else
122
+ ungrouped << name
123
+ end
124
+ end
125
+ [clusters, ungrouped]
126
+ else
127
+ group_by_prefix(names, collapse)
128
+ end
129
+ end
130
+
131
+ def group_by_prefix(names, collapse)
132
+ prefixes = Array(collapse).map(&:to_s).reject(&:empty?)
133
+ return [{}, names] if prefixes.empty?
134
+
135
+ sorted = prefixes.sort_by { |p| -p.length }
136
+ clusters = Hash.new { |h, k| h[k] = [] }
137
+ ungrouped = []
138
+ names.each do |name|
139
+ match = sorted.find { |p| name.start_with?(p + "::") }
140
+ if match
141
+ clusters[match] << name
142
+ else
143
+ ungrouped << name
144
+ end
145
+ end
146
+ [clusters, ungrouped]
147
+ end
148
+
149
+ def render_cluster(label, members, node_ids, use_namespace_prefix: true)
150
+ out = +" subgraph #{cluster_id(label)} [\"#{escape_label(label)}\"]\n"
151
+ members.each do |name|
152
+ short = use_namespace_prefix ? name.sub(/\A#{Regexp.escape(label)}::/, "") : name
153
+ out << " #{node_ids[name]}[\"#{escape_label(short)}\"]\n"
154
+ end
155
+ out << " end\n"
156
+ end
157
+
158
+ # Mermaid subgraph ids must be plain identifiers; anything
159
+ # else breaks the parser silently. Coerce non-alnum
160
+ # characters to `_` so `packages/billing` ends up as
161
+ # `sg_packages_billing` and stays unambiguous.
162
+ def cluster_id(prefix)
163
+ "sg_#{prefix.gsub(/[^A-Za-z0-9_]+/, "_")}"
164
+ end
165
+
166
+ def escape_label(name)
167
+ name.gsub('"', "#quot;")
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ module ModuleGraph
7
+ # Kinds of node metadata the plugin emits. Class and module
8
+ # declarations carry the class itself; the remaining kinds are
9
+ # children of one — methods and attributes.
10
+ NODE_KINDS = %w[
11
+ class
12
+ module
13
+ instance_method
14
+ class_method
15
+ attribute
16
+ ].freeze
17
+
18
+ # Visibility values for a method / attribute. Matches the Ruby
19
+ # access modifiers.
20
+ NODE_VISIBILITIES = %w[public protected private].freeze
21
+
22
+ # Access flavours for an +attr_*+ macro. Used to label
23
+ # attribute glyphs in a class diagram and nothing else.
24
+ NODE_ACCESSES = %w[read write accessor].freeze
25
+
26
+ # A piece of node metadata extracted from a source file.
27
+ #
28
+ # Three flavours, distinguished by +kind+:
29
+ #
30
+ # * +class+ / +module+ — a constant declaration. Carries
31
+ # +name+, +path+, +line+, +column+. +owner+/+visibility+/
32
+ # +access+ are nil.
33
+ # * +instance_method+ / +class_method+ — a method definition.
34
+ # Carries +name+, +owner+ (the enclosing class/module),
35
+ # +visibility+, +path+, +line+, +column+.
36
+ # * +attribute+ — an +attr_reader+ / +attr_writer+ /
37
+ # +attr_accessor+ symbol. Carries +name+, +owner+,
38
+ # +visibility+, +access+, +path+, +line+, +column+.
39
+ class Node < Data.define(:kind, :name, :owner, :path, :line, :column, :visibility, :access)
40
+ KINDS = NODE_KINDS
41
+ VISIBILITIES = NODE_VISIBILITIES
42
+ ACCESSES = NODE_ACCESSES
43
+
44
+ def self.build(kind:, name:, owner: nil, path: nil, line: nil, column: nil,
45
+ visibility: nil, access: nil)
46
+ new(
47
+ kind: validate_kind!(kind),
48
+ name: name.to_s.freeze,
49
+ owner: owner && owner.to_s.freeze,
50
+ path: path,
51
+ line: line,
52
+ column: column,
53
+ visibility: visibility && validate_visibility!(visibility),
54
+ access: access && validate_access!(access)
55
+ )
56
+ end
57
+
58
+ def self.validate_kind!(kind) # :nodoc:
59
+ kind = kind.to_s
60
+ return kind if KINDS.include?(kind)
61
+
62
+ raise ArgumentError, "unknown node kind #{kind.inspect}; expected one of #{KINDS.inspect}"
63
+ end
64
+
65
+ def self.validate_visibility!(visibility) # :nodoc:
66
+ visibility = visibility.to_s
67
+ return visibility if VISIBILITIES.include?(visibility)
68
+
69
+ raise ArgumentError, "unknown visibility #{visibility.inspect}; expected one of #{VISIBILITIES.inspect}"
70
+ end
71
+
72
+ def self.validate_access!(access) # :nodoc:
73
+ access = access.to_s
74
+ return access if ACCESSES.include?(access)
75
+
76
+ raise ArgumentError, "unknown access #{access.inspect}; expected one of #{ACCESSES.inspect}"
77
+ end
78
+
79
+ def to_h
80
+ h = { "kind" => kind, "name" => name }
81
+ h["owner"] = owner if owner
82
+ h["path"] = path if path
83
+ h["line"] = line if line
84
+ h["column"] = column if column
85
+ h["visibility"] = visibility if visibility
86
+ h["access"] = access if access
87
+ h
88
+ end
89
+
90
+ # The payload embedded in the plugin's +:info+ diagnostic
91
+ # message. Position is intentionally absent — the diagnostic
92
+ # row carries +path+/+line+/+column+ on its own.
93
+ def to_message_payload
94
+ h = { "kind" => kind, "name" => name }
95
+ h["owner"] = owner if owner
96
+ h["visibility"] = visibility if visibility
97
+ h["access"] = access if access
98
+ h
99
+ end
100
+
101
+ # Key used to dedupe node rows. Two declarations of the same
102
+ # method on the same owner collapse to one row; class re-opens
103
+ # collapse to one class node.
104
+ def dedup_key
105
+ [kind, owner, name]
106
+ end
107
+ end
108
+
109
+ # JSONL reader / writer for Node rows.
110
+ module NodeIO
111
+ module_function
112
+
113
+ def write(nodes, io)
114
+ seen = {}
115
+ nodes.each do |node|
116
+ key = node.dedup_key
117
+ next if seen[key]
118
+
119
+ seen[key] = true
120
+ io.puts(JSON.generate(node.to_h))
121
+ end
122
+ end
123
+
124
+ def read(io)
125
+ nodes = []
126
+ io.each_line do |line|
127
+ line = line.strip
128
+ next if line.empty?
129
+
130
+ row = JSON.parse(line)
131
+ nodes << Node.build(
132
+ kind: row.fetch("kind"),
133
+ name: row.fetch("name"),
134
+ owner: row["owner"],
135
+ path: row["path"],
136
+ line: row["line"],
137
+ column: row["column"],
138
+ visibility: row["visibility"],
139
+ access: row["access"]
140
+ )
141
+ end
142
+ nodes
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+
5
+ module Rigor
6
+ module ModuleGraph
7
+ # Discovers Packwerk-style packages (`package.yml`) inside a
8
+ # project tree and maps source file paths to their owning
9
+ # package.
10
+ #
11
+ # Treats every directory that contains a +package.yml+ as a
12
+ # package root. The package's name is its path relative to the
13
+ # project root with a leading +./+ stripped — that's how
14
+ # +packwerk+ itself reports them, and it's stable across
15
+ # Packwerk versions which gives the renderer something to use
16
+ # as the cluster label.
17
+ #
18
+ # Files map to the +deepest+ ancestor package — if a nested
19
+ # `packages/billing/invoices/package.yml` lives under
20
+ # `packages/billing/package.yml`, a file under the inner one
21
+ # belongs to +packages/billing/invoices+, not +packages/billing+.
22
+ class PackwerkOverlay
23
+ Package = Data.define(:name, :root) do
24
+ # Stable rendering for snapshot / debug output.
25
+ def to_s
26
+ "Package(#{name})"
27
+ end
28
+ end
29
+
30
+ EXCLUDED_DIRS = %w[.git node_modules tmp log vendor].freeze
31
+ private_constant :EXCLUDED_DIRS
32
+
33
+ attr_reader :project_root, :packages
34
+
35
+ # @param project_root [String] the project root the packages
36
+ # are reported relative to
37
+ # @param packages [Array<Package>] frozen
38
+ def initialize(project_root:, packages:)
39
+ @project_root = realpath_or_expand(project_root)
40
+ @packages = packages
41
+ .map { |pkg| Package.new(name: pkg.name, root: realpath_or_expand(pkg.root)) }
42
+ .sort_by { |p| -p.root.length }
43
+ .freeze
44
+ end
45
+
46
+ # @param project_root [String]
47
+ # @return [PackwerkOverlay]
48
+ def self.discover(project_root)
49
+ root = File.expand_path(project_root)
50
+ packages = []
51
+ Find.find(root) do |path|
52
+ base = File.basename(path)
53
+ if File.directory?(path) && EXCLUDED_DIRS.include?(base) && path != root
54
+ Find.prune
55
+ next
56
+ end
57
+ next unless File.file?(path) && base == "package.yml"
58
+
59
+ pkg_root = File.dirname(path)
60
+ packages << Package.new(name: package_name(pkg_root, root), root: pkg_root)
61
+ end
62
+ new(project_root: root, packages: packages)
63
+ end
64
+
65
+ def self.package_name(pkg_root, project_root)
66
+ # The root package (a `package.yml` at the project root) is
67
+ # canonically called `.` in Packwerk output. Match that so
68
+ # users see the familiar label.
69
+ return "." if pkg_root == project_root
70
+
71
+ rel = pkg_root.sub(%r{\A#{Regexp.escape(project_root)}/?}, "")
72
+ rel.empty? ? "." : rel
73
+ end
74
+
75
+ # @return [Boolean] true when at least one package.yml was
76
+ # found.
77
+ def any?
78
+ !@packages.empty?
79
+ end
80
+
81
+ # Find the deepest package whose root is an ancestor of
82
+ # +path+. Returns nil when the path is outside every
83
+ # package's root.
84
+ #
85
+ # Both sides are normalised through +realpath+ when
86
+ # possible so a macOS +/tmp+ ↔ +/private/tmp+ symlink (or
87
+ # any other symlink in the project root path) doesn't make
88
+ # the comparison spuriously miss.
89
+ def package_for(path)
90
+ return nil if path.nil? || path.empty?
91
+
92
+ absolute = realpath_of(File.expand_path(path, @project_root))
93
+ @packages.find do |pkg|
94
+ absolute == pkg.root || absolute.start_with?(pkg.root + "/")
95
+ end
96
+ end
97
+
98
+ def realpath_or_expand(path)
99
+ File.realpath(File.expand_path(path))
100
+ rescue Errno::ENOENT
101
+ File.expand_path(path)
102
+ end
103
+
104
+ def realpath_of(path)
105
+ File.realpath(path)
106
+ rescue Errno::ENOENT
107
+ # Tests and synthetic edges may carry paths whose tail
108
+ # doesn't exist on disk; walk up to the deepest existing
109
+ # ancestor, realpath that, then reattach the missing tail.
110
+ # That makes a macOS +/tmp+ ↔ +/private/tmp+ symlink
111
+ # transparent even for synthetic paths.
112
+ parent = path
113
+ until parent == File.dirname(parent)
114
+ parent = File.dirname(parent)
115
+ if File.exist?(parent)
116
+ return File.realpath(parent) + path[parent.length..]
117
+ end
118
+ end
119
+ path
120
+ end
121
+
122
+ # Build a +{node_name => package_name}+ mapping from a list
123
+ # of edges. We only attribute a node to a package when we
124
+ # have evidence the node is *declared* under that package's
125
+ # root — that is, the node appears as +edge.from+ for at
126
+ # least one edge, and that edge's path lives under the
127
+ # package. The +to+ side is just a reference; using it would
128
+ # mis-attribute base classes (+ApplicationRecord+) and any
129
+ # other external constant to whichever package happens to
130
+ # reference them first.
131
+ def groups_for(edges)
132
+ node_paths = {}
133
+ edges.each do |edge|
134
+ node_paths[edge.from] ||= edge.path
135
+ end
136
+ node_paths.each_with_object({}) do |(name, path), acc|
137
+ pkg = package_for(path)
138
+ acc[name] = pkg.name if pkg
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "prism"
5
+
6
+ module Rigor
7
+ module ModuleGraph
8
+ # Rigor plugin: declares the node rules that emit class/module/
9
+ # constant dependency edges as `:info` diagnostics. Loaded only
10
+ # when `rigortype` is available (see `plugin.rb`).
11
+ class Plugin < ::Rigor::Plugin::Base
12
+ EDGE_RULE = "edge"
13
+ NODE_RULE = "node"
14
+ private_constant :EDGE_RULE, :NODE_RULE
15
+
16
+ manifest(
17
+ id: "module-graph",
18
+ version: Rigor::ModuleGraph::VERSION,
19
+ description: "Extract Ruby class/module/constant dependency graph as :info diagnostics.",
20
+ config_schema: {
21
+ "autoload_paths" => {
22
+ kind: :array,
23
+ default: ZeitwerkResolver::DEFAULT_AUTOLOAD_PATHS
24
+ },
25
+ "concern_dirs" => {
26
+ kind: :array,
27
+ default: ZeitwerkResolver::DEFAULT_CONCERN_DIRS
28
+ },
29
+ "rails_zeitwerk" => { kind: :boolean, default: true },
30
+ "include_constant_refs" => { kind: :boolean, default: false },
31
+ "emit_node_metadata" => { kind: :boolean, default: true },
32
+ "emit_associations" => { kind: :boolean, default: true }
33
+ }
34
+ )
35
+
36
+ # Pre-walk each file once to record per-DefNode visibility.
37
+ # Shared across all node_rule invocations for the same file.
38
+ node_file_context do |root, _scope|
39
+ VisibilityMap.build(root)
40
+ end
41
+
42
+ node_rule Prism::ClassNode do |node, scope, path, file_context, context|
43
+ analyzer = analyzer_for(scope, path, context, file_context)
44
+ diagnostics = analyzer.class_edges(node).map { |edge| edge_diagnostic(edge, node) }
45
+ if config["emit_node_metadata"]
46
+ if (meta = analyzer.class_node_metadata(node))
47
+ diagnostics << node_diagnostic(meta, node)
48
+ end
49
+ end
50
+ diagnostics
51
+ end
52
+
53
+ node_rule Prism::ModuleNode do |node, scope, path, file_context, context|
54
+ analyzer = analyzer_for(scope, path, context, file_context)
55
+ diagnostics = []
56
+ if config["emit_node_metadata"]
57
+ if (meta = analyzer.module_node_metadata(node))
58
+ diagnostics << node_diagnostic(meta, node)
59
+ end
60
+ end
61
+ diagnostics
62
+ end
63
+
64
+ node_rule Prism::CallNode do |node, scope, path, file_context, context|
65
+ analyzer = analyzer_for(scope, path, context, file_context)
66
+ diagnostics = analyzer.call_edges(node).map { |edge| edge_diagnostic(edge, node) }
67
+ if config["emit_associations"]
68
+ analyzer.association_edges(node).each do |edge|
69
+ diagnostics << edge_diagnostic(edge, node)
70
+ end
71
+ end
72
+ if config["emit_node_metadata"]
73
+ analyzer.attribute_nodes(node).each do |meta|
74
+ diagnostics << node_diagnostic(meta, node)
75
+ end
76
+ end
77
+ diagnostics
78
+ end
79
+
80
+ node_rule Prism::DefNode do |node, scope, path, file_context, context|
81
+ next [] unless config["emit_node_metadata"]
82
+
83
+ analyzer = analyzer_for(scope, path, context, file_context)
84
+ if (meta = analyzer.method_node_metadata(node))
85
+ [node_diagnostic(meta, node)]
86
+ else
87
+ []
88
+ end
89
+ end
90
+
91
+ node_rule Prism::ConstantReadNode do |node, scope, path, file_context, context|
92
+ next [] unless config["include_constant_refs"]
93
+
94
+ analyzer = analyzer_for(scope, path, context, file_context)
95
+ analyzer.constant_read_edges(node).map { |edge| edge_diagnostic(edge, node) }
96
+ end
97
+
98
+ node_rule Prism::ConstantPathNode do |node, scope, path, file_context, context|
99
+ next [] unless config["include_constant_refs"]
100
+
101
+ analyzer = analyzer_for(scope, path, context, file_context)
102
+ analyzer.constant_path_edges(node).map { |edge| edge_diagnostic(edge, node) }
103
+ end
104
+
105
+ def analyzer_for(scope, path, context, visibility_map)
106
+ Analyzer.new(
107
+ path: path,
108
+ context: context,
109
+ scope: scope,
110
+ zeitwerk: zeitwerk_resolver,
111
+ visibility_map: visibility_map
112
+ )
113
+ end
114
+
115
+ def zeitwerk_resolver
116
+ return @zeitwerk_resolver if defined?(@zeitwerk_resolver)
117
+
118
+ @zeitwerk_resolver =
119
+ if config["rails_zeitwerk"]
120
+ ZeitwerkResolver.new(
121
+ autoload_paths: config["autoload_paths"],
122
+ concern_dirs: config["concern_dirs"]
123
+ )
124
+ end
125
+ end
126
+
127
+ def edge_diagnostic(edge, node)
128
+ diagnostic(
129
+ node,
130
+ path: edge.path,
131
+ message: JSON.generate(edge.to_message_payload),
132
+ severity: :info,
133
+ rule: EDGE_RULE
134
+ )
135
+ end
136
+
137
+ def node_diagnostic(meta, ast_node)
138
+ diagnostic(
139
+ ast_node,
140
+ path: meta.path,
141
+ message: JSON.generate(meta.to_message_payload),
142
+ severity: :info,
143
+ rule: NODE_RULE
144
+ )
145
+ end
146
+ end
147
+
148
+ ::Rigor::Plugin.register(Plugin)
149
+ end
150
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lazy plugin entry point.
4
+ #
5
+ # This file is `require`d by `lib/rigor-module-graph.rb`, which itself
6
+ # is `require`d two distinct ways:
7
+ #
8
+ # 1. From `Rigor::Plugin::Loader` when a host project's `.rigor.yml`
9
+ # lists `rigor-module-graph` under `plugins:` — the host gem has
10
+ # `rigortype` already loaded, so we want to subclass
11
+ # `Rigor::Plugin::Base` and register at require-time.
12
+ # 2. From the `rigor-module-graph` CLI when the user only wants the
13
+ # converter subcommands (`dot`, `mermaid`, `cycles`) — `rigortype`
14
+ # may not be available and we must NOT crash.
15
+ #
16
+ # We detect which mode we're in and defer the Rigor wiring to the
17
+ # concrete subclass file when appropriate.
18
+
19
+ require_relative "edge"
20
+ require_relative "analyzer"
21
+ require_relative "constant_name"
22
+
23
+ if defined?(Rigor::Plugin::Base)
24
+ require_relative "plugin/rigor_plugin"
25
+ end