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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module ModuleGraph
7
+ # Resolves a fully-qualified constant name from Prism AST nodes
8
+ # and lexical ancestor chains.
9
+ #
10
+ # Three things this module handles that the bare Prism API does
11
+ # not give you in one place:
12
+ #
13
+ # - Owner from lexical nesting. `node.constant_path.full_name`
14
+ # on a `class Billing::Invoice` only returns `"Invoice"`; the
15
+ # outer `module Billing` does not enter unless we walk
16
+ # ancestors ourselves.
17
+ # - Absolute paths (`::Foo::Bar`). Prism encodes the leading
18
+ # `::` as an empty-symbol `:""` in `full_name_parts`; we
19
+ # render it as `"::Foo::Bar"`.
20
+ # - Mixed AST shapes. `ClassNode#constant_path` is either a
21
+ # `ConstantReadNode` (single name) or a `ConstantPathNode`
22
+ # (dotted path); same for `superclass` and `include` args.
23
+ module ConstantName
24
+ module_function
25
+
26
+ # Render a single Prism constant node into a string like
27
+ # `"Foo"`, `"Foo::Bar"`, or `"::Foo::Bar"`. Returns nil when
28
+ # the node is not a constant carrier (e.g. `include SOME_VAR`
29
+ # where the arg is a `CallNode`).
30
+ def render(node)
31
+ case node
32
+ when Prism::ConstantReadNode
33
+ node.name.to_s
34
+ when Prism::ConstantPathNode
35
+ full_name_for_path(node)
36
+ end
37
+ end
38
+
39
+ # Build the lexical-nesting owner string for a node, by
40
+ # walking `context.ancestors` from outer to inner and joining
41
+ # every enclosing `ClassNode`/`ModuleNode` constant path with
42
+ # `::`. Returns nil when no class/module encloses the node.
43
+ def lexical_owner(context)
44
+ parts = lexical_parts(context.ancestors)
45
+ return nil if parts.empty?
46
+
47
+ parts.join("::")
48
+ end
49
+
50
+ # Same as `#lexical_owner`, but with `extra` appended as the
51
+ # innermost element. Used to build the owner of a class or
52
+ # module decl itself: pass `context.ancestors` (which does NOT
53
+ # include the node itself) plus the node's own constant path.
54
+ def lexical_owner_with(context, extra)
55
+ parts = lexical_parts(context.ancestors)
56
+ parts << extra unless extra.nil? || extra.empty?
57
+ return nil if parts.empty?
58
+
59
+ parts.join("::")
60
+ end
61
+
62
+ def lexical_parts(ancestors)
63
+ ancestors.flat_map do |ancestor|
64
+ case ancestor
65
+ when Prism::ClassNode, Prism::ModuleNode
66
+ name = render(ancestor.constant_path)
67
+ name ? [name] : []
68
+ else
69
+ []
70
+ end
71
+ end
72
+ end
73
+
74
+ def full_name_for_path(node)
75
+ parts = node.full_name_parts
76
+ # Prism encodes a leading `::` as an empty-symbol first
77
+ # part. Render it as a literal `"::"` prefix.
78
+ if parts.first == :""
79
+ "::" + parts.drop(1).join("::")
80
+ else
81
+ parts.join("::")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Rigor
6
+ module ModuleGraph
7
+ # Finds dependency cycles in an Edge list.
8
+ #
9
+ # Tarjan's strongly-connected-components, sized ≥ 2 (a SCC of
10
+ # 1 is a single node with no self-loop and represents no cycle).
11
+ # We also surface single-node self-loops if any edge points a
12
+ # constant at itself.
13
+ #
14
+ # Returns an array of Cycle, where each Cycle carries the list
15
+ # of node names in the cycle in a canonical rotation: smallest
16
+ # name first. The actual edge instances making up each cycle
17
+ # are not surfaced — for visualisation we only need the node
18
+ # set — but a kind filter is offered so callers can ask "do
19
+ # these edges form a cycle using only `include` / `inherits`?"
20
+ module CycleDetector
21
+ module_function
22
+
23
+ # One detected cycle, expressed as the list of node names
24
+ # along it. Always rotated so the lexicographically smallest
25
+ # name comes first, so the rendered output is stable across
26
+ # runs.
27
+ class Cycle < Data.define(:nodes)
28
+ # Render the cycle as +A -> B -> C -> A+ (note the closing
29
+ # repeat of the first node so the round-trip is obvious).
30
+ def to_s
31
+ nodes.join(" -> ") + " -> " + nodes.first
32
+ end
33
+ end
34
+
35
+ # @param edges [Array<Edge>]
36
+ # @param kinds [Array<String>, nil] when given, only edges
37
+ # whose `kind` is in the list participate in cycle detection.
38
+ def detect(edges, kinds: nil)
39
+ graph = build_adjacency(edges, kinds)
40
+ sccs = tarjan(graph)
41
+ cycles = sccs.select { |scc| scc.size >= 2 }.map { |scc| canonicalize(scc, graph) }
42
+ self_loops = collect_self_loops(graph)
43
+ (cycles + self_loops).sort_by { |c| c.nodes.first }
44
+ end
45
+
46
+ def build_adjacency(edges, kinds)
47
+ graph = Hash.new { |h, k| h[k] = [] }
48
+ edges.each do |edge|
49
+ next if kinds && !kinds.include?(edge.kind)
50
+
51
+ graph[edge.from] << edge.to
52
+ graph[edge.to] # ensure target appears even with no outgoing edges
53
+ end
54
+ graph.each_value(&:uniq!)
55
+ graph
56
+ end
57
+
58
+ # Iterative Tarjan to avoid blowing the Ruby stack on a wide
59
+ # graph. Returns SCCs as arrays of node names.
60
+ def tarjan(graph)
61
+ index = 0
62
+ indices = {}
63
+ lowlink = {}
64
+ on_stack = {}
65
+ stack = []
66
+ sccs = []
67
+
68
+ graph.each_key do |start|
69
+ next if indices.key?(start)
70
+
71
+ work = [[start, 0]]
72
+ indices[start] = index
73
+ lowlink[start] = index
74
+ index += 1
75
+ stack.push(start)
76
+ on_stack[start] = true
77
+
78
+ until work.empty?
79
+ node, i = work.last
80
+ neighbours = graph[node]
81
+ if i < neighbours.size
82
+ work[-1] = [node, i + 1]
83
+ succ = neighbours[i]
84
+ if !indices.key?(succ)
85
+ indices[succ] = index
86
+ lowlink[succ] = index
87
+ index += 1
88
+ stack.push(succ)
89
+ on_stack[succ] = true
90
+ work.push([succ, 0])
91
+ elsif on_stack[succ]
92
+ lowlink[node] = [lowlink[node], indices[succ]].min
93
+ end
94
+ else
95
+ if lowlink[node] == indices[node]
96
+ scc = []
97
+ loop do
98
+ w = stack.pop
99
+ on_stack.delete(w)
100
+ scc << w
101
+ break if w == node
102
+ end
103
+ sccs << scc
104
+ end
105
+ work.pop
106
+ parent = work.last&.first
107
+ lowlink[parent] = [lowlink[parent], lowlink[node]].min if parent
108
+ end
109
+ end
110
+ end
111
+ sccs
112
+ end
113
+
114
+ def canonicalize(scc, graph)
115
+ nodes = walk_cycle(scc, graph)
116
+ nodes = rotate_to_smallest(nodes)
117
+ Cycle.new(nodes: nodes)
118
+ end
119
+
120
+ # Walk a single trip around the SCC starting from its
121
+ # smallest-named node, following outgoing edges that stay
122
+ # in the SCC. Falls back to sorted membership if the cycle
123
+ # is degenerate (should not happen for a real SCC ≥ 2).
124
+ def walk_cycle(scc, graph)
125
+ set = scc.to_set
126
+ start = scc.min
127
+ path = [start]
128
+ current = start
129
+ loop do
130
+ next_node = graph[current].find { |n| set.include?(n) && !path.include?(n) }
131
+ break unless next_node
132
+
133
+ path << next_node
134
+ current = next_node
135
+ end
136
+ # If we couldn't visit everyone (rare: SCC with parallel
137
+ # branches), fall back to sorted order so output stays
138
+ # deterministic.
139
+ path.size == scc.size ? path : scc.sort
140
+ end
141
+
142
+ def rotate_to_smallest(nodes)
143
+ i = nodes.each_with_index.min_by { |name, _| name }[1]
144
+ nodes.rotate(i)
145
+ end
146
+
147
+ def collect_self_loops(graph)
148
+ graph.each_with_object([]) do |(node, targets), acc|
149
+ acc << Cycle.new(nodes: [node]) if targets.include?(node)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module ModuleGraph
5
+ # Renders an array of Edges as a Graphviz DOT document.
6
+ #
7
+ # Style decisions (per docs/plan.md "グラフモデル"):
8
+ # - rankdir=LR for readability of inheritance towers
9
+ # - inherits: thick solid
10
+ # - include: solid
11
+ # - prepend: solid, distinct color
12
+ # - extend: dashed
13
+ # - const_ref: faded dotted
14
+ #
15
+ # When `collapse:` is given, every node whose fully-qualified
16
+ # name sits under one of the listed prefixes is wrapped in a
17
+ # `subgraph cluster_<prefix>` block, and the prefix is stripped
18
+ # from the visible label. Edges across clusters render normally;
19
+ # Graphviz routes them between the cluster boundaries.
20
+ module Dot
21
+ module_function
22
+
23
+ KIND_STYLE = {
24
+ "inherits" => 'color="#0f172a", penwidth=2.0',
25
+ "include" => 'color="#1d4ed8"',
26
+ "prepend" => 'color="#9333ea"',
27
+ "extend" => 'color="#0f766e", style="dashed"',
28
+ "const_ref" => 'color="#94a3b8", style="dotted"'
29
+ }.freeze
30
+
31
+ CONFIDENCE_STYLE = {
32
+ "unresolved" => 'style="dashed", color="#94a3b8"'
33
+ }.freeze
34
+
35
+ HEADER = <<~DOT
36
+ digraph ruby_modules {
37
+ rankdir=LR;
38
+ graph [compound=true, overlap=false, splines=true];
39
+ node [shape=box, style="rounded,filled", fillcolor="#f8fafc", color="#94a3b8", fontname="Helvetica"];
40
+ edge [color="#64748b", arrowsize=0.7, fontname="Helvetica"];
41
+ DOT
42
+
43
+ # @param edges [Array<Edge>]
44
+ # @param collapse [Array<String>] namespace prefixes to
45
+ # fold into clusters (mutually exclusive with +groups+)
46
+ # @param groups [Hash{String=>String}, nil] explicit
47
+ # +{node_name => cluster_label}+ mapping. Takes precedence
48
+ # over +collapse+ when given. Used by the +--package+
49
+ # overlay where the cluster boundary is something other
50
+ # than a +::+ namespace prefix.
51
+ def render(edges, collapse: [], groups: nil)
52
+ edges = dedup(edges)
53
+ nodes = collect_nodes(edges)
54
+ clusters, ungrouped = build_groups(nodes, collapse, groups)
55
+
56
+ out = +HEADER
57
+ clusters.each do |label, members|
58
+ out << render_cluster(label, members, use_namespace_prefix: groups.nil?)
59
+ end
60
+ ungrouped.each do |name|
61
+ out << " #{quote(name)};\n"
62
+ end
63
+ out << "\n" unless nodes.empty?
64
+ edges.each do |edge|
65
+ out << render_edge(edge)
66
+ end
67
+ out << "}\n"
68
+ end
69
+
70
+ def dedup(edges)
71
+ seen = {}
72
+ edges.each_with_object([]) do |edge, acc|
73
+ key = edge.dedup_key
74
+ next if seen[key]
75
+
76
+ seen[key] = true
77
+ acc << edge
78
+ end
79
+ end
80
+
81
+ def collect_nodes(edges)
82
+ names = edges.flat_map { |edge| [edge.from, edge.to] }
83
+ names.uniq.sort
84
+ end
85
+
86
+ # Build the cluster partition. When +groups+ is given we
87
+ # use it verbatim; otherwise fall back to prefix-matching
88
+ # against +collapse+ (the legacy namespace-collapse path).
89
+ def build_groups(nodes, collapse, groups)
90
+ if groups && !groups.empty?
91
+ clusters = Hash.new { |h, k| h[k] = [] }
92
+ ungrouped = []
93
+ nodes.each do |name|
94
+ if (label = groups[name])
95
+ clusters[label] << name
96
+ else
97
+ ungrouped << name
98
+ end
99
+ end
100
+ [clusters, ungrouped]
101
+ else
102
+ group_by_prefix(nodes, collapse)
103
+ end
104
+ end
105
+
106
+ def group_by_prefix(nodes, collapse)
107
+ prefixes = Array(collapse).map(&:to_s).reject(&:empty?)
108
+ return [{}, nodes] if prefixes.empty?
109
+
110
+ sorted = prefixes.sort_by { |p| -p.length }
111
+ clusters = Hash.new { |h, k| h[k] = [] }
112
+ ungrouped = []
113
+ nodes.each do |name|
114
+ match = sorted.find { |p| name.start_with?(p + "::") }
115
+ if match
116
+ clusters[match] << name
117
+ else
118
+ ungrouped << name
119
+ end
120
+ end
121
+ [clusters, ungrouped]
122
+ end
123
+
124
+ def render_cluster(label, members, use_namespace_prefix: true)
125
+ out = +" subgraph #{quote("cluster_" + cluster_id(label))} {\n"
126
+ out << " label=#{quote(label)};\n"
127
+ out << " style=\"rounded,filled\";\n"
128
+ out << " color=\"#cbd5e1\";\n"
129
+ out << " fillcolor=\"#f1f5f9\";\n"
130
+ members.each do |name|
131
+ short = use_namespace_prefix ? name.sub(/\A#{Regexp.escape(label)}::/, "") : name
132
+ out << " #{quote(name)} [label=#{quote(short)}];\n"
133
+ end
134
+ out << " }\n"
135
+ end
136
+
137
+ # Cluster identifiers in DOT must match `[A-Za-z_][A-Za-z0-9_]*`
138
+ # — package names like `packages/billing` would otherwise
139
+ # break Graphviz's parser even inside quotes. Squash every
140
+ # non-id character to `_` so the prefix `cluster_` still
141
+ # triggers Graphviz's cluster handling.
142
+ def cluster_id(prefix)
143
+ prefix.gsub(/[^A-Za-z0-9_]+/, "_")
144
+ end
145
+
146
+ def render_edge(edge)
147
+ attrs = +"label=\"#{edge.kind}\""
148
+ if (style = KIND_STYLE[edge.kind])
149
+ attrs << ", " << style
150
+ end
151
+ if (style = CONFIDENCE_STYLE[edge.confidence])
152
+ attrs << ", " << style
153
+ end
154
+ " #{quote(edge.from)} -> #{quote(edge.to)} [#{attrs}];\n"
155
+ end
156
+
157
+ def quote(name)
158
+ # DOT identifiers that contain `::` or quotes must be
159
+ # double-quoted; escape embedded double quotes.
160
+ '"' + name.gsub('"', '\"') + '"'
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ module ModuleGraph
7
+ # The list of valid +kind+ values for an Edge.
8
+ EDGE_KINDS = %w[
9
+ inherits
10
+ include
11
+ prepend
12
+ extend
13
+ const_ref
14
+ has_many
15
+ belongs_to
16
+ has_one
17
+ has_and_belongs_to_many
18
+ ].freeze
19
+
20
+ # Subset of EDGE_KINDS that represent Rails ActiveRecord
21
+ # associations. Used by the class diagram renderer to attach
22
+ # cardinality labels, and by callers that want to filter the
23
+ # set as a group.
24
+ ASSOCIATION_KINDS = %w[has_many belongs_to has_one has_and_belongs_to_many].freeze
25
+
26
+ # The structural / mixin kinds — the "first class" relations
27
+ # of a typical dependency graph. const_ref and associations
28
+ # sit outside this set.
29
+ STRUCTURAL_KINDS = %w[inherits include prepend extend].freeze
30
+
31
+ # The list of valid +confidence+ values for an Edge.
32
+ EDGE_CONFIDENCES = %w[syntax zeitwerk rigor_type unresolved].freeze
33
+
34
+ # A single dependency edge between two constants.
35
+ #
36
+ # Carries the dependency itself (+from+, +to+, +kind+,
37
+ # +confidence+), the source position it was extracted from
38
+ # (+path+, +line+, +column+), and the raw source slice (+raw+)
39
+ # when the resolution went through a fallback path. Edge is a
40
+ # +Data+ subclass — every instance is immutable.
41
+ #
42
+ # == Two serialisation shapes
43
+ #
44
+ # +to_message_payload+::
45
+ # What the plugin embeds in a diagnostic's +message+ field.
46
+ # The collector reconstructs an Edge from this payload plus
47
+ # the diagnostic's own +path+/+line+/+column+, so the payload
48
+ # omits position to keep the message compact.
49
+ # +to_h+::
50
+ # What the JSONL writer dumps to disk. Full row, with
51
+ # +path+/+line+/+column+ included.
52
+ #
53
+ # == Dedup key
54
+ #
55
+ # +dedup_key+ ignores +path+ and +line+ so the same logical
56
+ # edge declared in two files (or surfaced by two re-runs of
57
+ # +rigor check+) collapses to one row.
58
+ # The +dedup_key+ member at the end is internal: it's the
59
+ # cached +"\\x00"+-joined key string the renderers' dedup
60
+ # loops use for Hash lookups. Storing it on the value pays
61
+ # for itself once a few hundred edges flow through any
62
+ # rendering / IO path.
63
+ class Edge < Data.define(:from, :to, :kind, :path, :line, :column, :confidence, :raw, :dedup_key)
64
+ # Same as Rigor::ModuleGraph::EDGE_KINDS; exposed on the
65
+ # class so callers can write +Edge::KINDS+.
66
+ KINDS = EDGE_KINDS
67
+
68
+ # Same as Rigor::ModuleGraph::EDGE_CONFIDENCES; exposed on
69
+ # the class so callers can write +Edge::CONFIDENCES+.
70
+ CONFIDENCES = EDGE_CONFIDENCES
71
+
72
+ # Build an Edge, validating +kind+ and +confidence+ against
73
+ # the canonical lists and frozen-stringifying +from+ / +to+.
74
+ # Raises +ArgumentError+ on unknown values.
75
+ def self.build(from:, to:, kind:, path: nil, line: nil, column: nil, confidence: "syntax", raw: nil)
76
+ from = from.to_s.freeze
77
+ to = to.to_s.freeze
78
+ kind = validate_kind!(kind)
79
+ confidence = validate_confidence!(confidence)
80
+ new(
81
+ from: from,
82
+ to: to,
83
+ kind: kind,
84
+ path: path,
85
+ line: line,
86
+ column: column,
87
+ confidence: confidence,
88
+ raw: raw,
89
+ dedup_key: -"#{from}\x00#{to}\x00#{kind}\x00#{confidence}"
90
+ )
91
+ end
92
+
93
+ def self.validate_kind!(kind) # :nodoc:
94
+ kind = kind.to_s
95
+ return kind if KINDS.include?(kind)
96
+
97
+ raise ArgumentError, "unknown edge kind #{kind.inspect}; expected one of #{KINDS.inspect}"
98
+ end
99
+
100
+ def self.validate_confidence!(confidence) # :nodoc:
101
+ confidence = confidence.to_s
102
+ return confidence if CONFIDENCES.include?(confidence)
103
+
104
+ raise ArgumentError, "unknown confidence #{confidence.inspect}; expected one of #{CONFIDENCES.inspect}"
105
+ end
106
+
107
+ # The on-disk JSONL row. Nil-valued positional fields are
108
+ # omitted so a stand-alone edge (e.g. constructed in a test
109
+ # without a path) does not leak +"path":null+ noise.
110
+ def to_h
111
+ h = { "from" => from, "to" => to, "kind" => kind }
112
+ h["path"] = path if path
113
+ h["line"] = line if line
114
+ h["column"] = column if column
115
+ h["confidence"] = confidence
116
+ h["raw"] = raw if raw
117
+ h
118
+ end
119
+
120
+ # The payload embedded in a +:info+ diagnostic's message.
121
+ # Position is intentionally absent — the diagnostic carries
122
+ # its own +path+/+line+/+column+, so duplicating them here
123
+ # would just bloat output.
124
+ def to_message_payload
125
+ h = { "from" => from, "to" => to, "kind" => kind, "confidence" => confidence }
126
+ h["raw"] = raw if raw
127
+ h
128
+ end
129
+
130
+ def to_json(*args)
131
+ JSON.generate(to_h, *args)
132
+ end
133
+
134
+ # +dedup_key+ is a generated Data accessor; no override
135
+ # needed. See the class header for the rationale.
136
+ end
137
+
138
+ # JSONL reader / writer for Edge rows. Used by the plugin
139
+ # collector and the +rigor-module-graph+ renderer subcommands.
140
+ module EdgeIO
141
+ module_function
142
+
143
+ # Stream +edges+ to +io+ as JSONL, deduping by Edge#dedup_key
144
+ # so re-runs don't accumulate duplicate rows.
145
+ def write(edges, io)
146
+ seen = {}
147
+ edges.each do |edge|
148
+ key = edge.dedup_key
149
+ next if seen[key]
150
+
151
+ seen[key] = true
152
+ io.puts(JSON.generate(edge.to_h))
153
+ end
154
+ end
155
+
156
+ # Parse JSONL from +io+ into Edge instances. Blank lines are
157
+ # skipped. Missing +confidence+ defaults to +"syntax"+ so the
158
+ # format stays backwards-compatible with earlier outputs.
159
+ def read(io)
160
+ edges = []
161
+ io.each_line do |line|
162
+ line = line.strip
163
+ next if line.empty?
164
+
165
+ row = JSON.parse(line)
166
+ edges << Edge.build(
167
+ from: row.fetch("from"),
168
+ to: row.fetch("to"),
169
+ kind: row.fetch("kind"),
170
+ path: row["path"],
171
+ line: row["line"],
172
+ column: row["column"],
173
+ confidence: row.fetch("confidence", "syntax"),
174
+ raw: row["raw"]
175
+ )
176
+ end
177
+ edges
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Rigor
6
+ module ModuleGraph
7
+ # Self-contained HTML document that embeds Mermaid output
8
+ # inline so it renders without any local server (works over
9
+ # +file://+, no fetch).
10
+ #
11
+ # The view loads +mermaid@10+ from a CDN at render time. The
12
+ # only network access is that one CDN URL; if a project needs
13
+ # to ship a fully offline page, render the SVG via Graphviz
14
+ # and embed that instead.
15
+ #
16
+ # The HTML body lives in
17
+ # +lib/rigor/module_graph/templates/view.html.erb+; bumping
18
+ # styling or Mermaid init options is an edit of that file
19
+ # alone.
20
+ module HtmlView
21
+ module_function
22
+
23
+ TEMPLATE_PATH = File.expand_path("templates/view.html.erb", __dir__)
24
+
25
+ # @param title [String] page <title> and <h1> text
26
+ # @param mermaid_source [String] the mermaid flowchart body
27
+ # @param subtitle [String, nil] one-line caption under the H1
28
+ # @return [String] the rendered HTML document
29
+ def render(title:, mermaid_source:, subtitle: nil)
30
+ indented = mermaid_source.strip.gsub("\n", "\n ")
31
+ template.result_with_hash(
32
+ title: title,
33
+ subtitle: subtitle,
34
+ indented_mermaid: indented
35
+ )
36
+ end
37
+
38
+ def template
39
+ @template ||= ERB.new(File.read(TEMPLATE_PATH), trim_mode: "-")
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module ModuleGraph
5
+ # Minimal Rails-style inflection helpers. Used to infer a class
6
+ # name from a Rails association argument (+has_many :invoices+
7
+ # → +Invoice+).
8
+ #
9
+ # Deliberately tiny — we don't ship +ActiveSupport::Inflector+
10
+ # and its irregular-noun table; the plugin records its guess
11
+ # at +confidence: "syntax"+ so a downstream reviewer can spot
12
+ # mis-singularised plurals in the graph. Apps that need exact
13
+ # association class names should rely on +class_name:+ overrides
14
+ # in the source, which the Analyzer reads verbatim.
15
+ module Inflector
16
+ module_function
17
+
18
+ IRREGULAR_PLURALS = {
19
+ "people" => "person",
20
+ "men" => "man",
21
+ "women" => "woman",
22
+ "children" => "child",
23
+ "feet" => "foot",
24
+ "teeth" => "tooth",
25
+ "geese" => "goose",
26
+ "mice" => "mouse",
27
+ "lice" => "louse"
28
+ }.freeze
29
+
30
+ # @param word [String]
31
+ # @return [String] best-effort singular form
32
+ def singularize(word)
33
+ downcased = word.downcase
34
+ return word.dup if word.empty?
35
+ return preserve_case(IRREGULAR_PLURALS[downcased], word) if IRREGULAR_PLURALS.key?(downcased)
36
+ return word[0..-4] + "y" if word =~ /ies\z/i && word.size > 3
37
+ return word[0..-3] if word =~ /ses\z/i # buses → bus, classes → clas... we accept loss
38
+ return word[0..-2] if word.end_with?("s") && !word.end_with?("ss")
39
+
40
+ word.dup
41
+ end
42
+
43
+ # +"foo_bar_baz" → "FooBarBaz"+. Plain Rails camelize without
44
+ # acronym handling.
45
+ def camelize(word)
46
+ word.to_s.split("_").map { |seg| seg.empty? ? seg : seg[0].upcase + seg[1..] }.join
47
+ end
48
+
49
+ # +"invoices" → "Invoice"+ — the common Rails association
50
+ # argument to class-name path.
51
+ def class_name_for(symbol_or_string)
52
+ camelize(singularize(symbol_or_string.to_s))
53
+ end
54
+
55
+ def preserve_case(replacement, original)
56
+ # The irregular table is lower-case; preserve a leading
57
+ # capital from the source so +People+ → +Person+.
58
+ return replacement.capitalize if original[0] =~ /[A-Z]/
59
+
60
+ replacement.dup
61
+ end
62
+ end
63
+ end
64
+ end