archsight 0.2.4 → 0.2.5

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.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/lib/archsight/cli.rb +67 -0
  3. data/lib/archsight/graph.rb +10 -0
  4. data/lib/archsight/import/executor.rb +11 -3
  5. data/lib/archsight/import/handlers/cpp_grapher.rb +193 -0
  6. data/lib/archsight/import/handlers/crystal_grapher.rb +186 -0
  7. data/lib/archsight/import/handlers/elixir_grapher.rb +202 -0
  8. data/lib/archsight/import/handlers/go_grapher.rb +127 -0
  9. data/lib/archsight/import/handlers/grapher.rb +552 -0
  10. data/lib/archsight/import/handlers/java_grapher.rb +286 -0
  11. data/lib/archsight/import/handlers/javascript_grapher.rb +340 -0
  12. data/lib/archsight/import/handlers/python_grapher.rb +270 -0
  13. data/lib/archsight/import/handlers/repository.rb +41 -17
  14. data/lib/archsight/import/handlers/ruby_grapher.rb +203 -0
  15. data/lib/archsight/import/handlers/rust_grapher.rb +227 -0
  16. data/lib/archsight/import/registry.rb +23 -0
  17. data/lib/archsight/resources/import.rb +1 -0
  18. data/lib/archsight/resources/technology_artifact.rb +17 -0
  19. data/lib/archsight/version.rb +1 -1
  20. data/lib/archsight/web/api/json_helpers.rb +1 -1
  21. data/lib/archsight/web/public/vue/ApiDocsPage-C0y953v0.css +1 -0
  22. data/lib/archsight/web/public/vue/ApiDocsPage-DHSCaHEn.js +1 -0
  23. data/lib/archsight/web/public/vue/DocPage-DszOPlFy.js +1 -0
  24. data/lib/archsight/web/public/vue/EditorPage-CPZ0Ei4l.css +1 -0
  25. data/lib/archsight/web/public/vue/EditorPage-DsiuZ7fg.js +35 -0
  26. data/lib/archsight/web/public/vue/ErrorPage-C4JutrYc.js +2 -0
  27. data/lib/archsight/web/public/vue/ErrorPage-uMDnfY5_.css +1 -0
  28. data/lib/archsight/web/public/vue/GraphView-Bqlbt6dK.js +1 -0
  29. data/lib/archsight/web/public/vue/GraphView-Cj2V2stN.css +1 -0
  30. data/lib/archsight/web/public/vue/InstanceRouter-D8SEY2eu.js +2 -0
  31. data/lib/archsight/web/public/vue/InstanceRouter-D9hclKFt.css +1 -0
  32. data/lib/archsight/web/public/vue/KindList-CPDaNron.js +1 -0
  33. data/lib/archsight/web/public/vue/ResourceList-B5w9yiyS.js +1 -0
  34. data/lib/archsight/web/public/vue/ResourceList-DxZfNbOg.css +1 -0
  35. data/lib/archsight/web/public/vue/SearchResults-DSHpVO-c.css +1 -0
  36. data/lib/archsight/web/public/vue/SearchResults-FpkhdBFu.js +1 -0
  37. data/lib/archsight/web/public/vue/architecture-7EHR7CIX-DpNNjAIc.js +1 -0
  38. data/lib/archsight/web/public/vue/eventmodeling-FCH6USID-CiThxoWl.js +1 -0
  39. data/lib/archsight/web/public/vue/gitGraph-WXDBUCRP-BODMGpAm.js +1 -0
  40. data/lib/archsight/web/public/vue/graphviz-09t3o0af.js +13 -0
  41. data/lib/archsight/web/public/vue/index-BW0IzY6X.css +1 -0
  42. data/lib/archsight/web/public/vue/index-T1YqCmM1.js +2 -0
  43. data/lib/archsight/web/public/vue/info-J43DQDTF-fLq04sri.js +1 -0
  44. data/lib/archsight/web/public/vue/katex-5qHlIbPR.js +261 -0
  45. data/lib/archsight/web/public/vue/mermaid-DYyHQk7x.js +3093 -0
  46. data/lib/archsight/web/public/vue/packet-YPE3B663-DoY1fbqu.js +1 -0
  47. data/lib/archsight/web/public/vue/pie-LRSECV5Y-C7ZQVwRe.js +1 -0
  48. data/lib/archsight/web/public/vue/radar-GUYGQ44K-CRtY5oqf.js +1 -0
  49. data/lib/archsight/web/public/vue/rolldown-runtime-QTnfLwEv.js +1 -0
  50. data/lib/archsight/web/public/vue/treeView-BLDUP644-Csx2WLLh.js +1 -0
  51. data/lib/archsight/web/public/vue/treemap-LRROVOQU-CfEnRbTx.js +1 -0
  52. data/lib/archsight/web/public/vue/{useGraphviz-C5lv_BWF.js → useGraphviz-EKSrE4q_.js} +5 -4
  53. data/lib/archsight/web/public/vue/useHighlight-BcVbGyrK.js +10 -0
  54. data/lib/archsight/web/public/vue/useMermaid-CIZxhy_r.js +2 -0
  55. data/lib/archsight/web/public/vue/usePanZoom-C2slpyY9.js +11 -0
  56. data/lib/archsight/web/public/vue/wardley-L42UT6IY-97oUvxhz.js +1 -0
  57. data/lib/archsight/web/public/vue.html +4 -3
  58. metadata +51 -72
  59. data/lib/archsight/web/public/vue/ApiDocsPage-DHOFUCYc.js +0 -1
  60. data/lib/archsight/web/public/vue/ApiDocsPage-DhNTOH4o.css +0 -1
  61. data/lib/archsight/web/public/vue/DocPage-CV66qgTr.js +0 -1
  62. data/lib/archsight/web/public/vue/EditorPage-Dq0MuTnp.css +0 -1
  63. data/lib/archsight/web/public/vue/EditorPage-KqBivY-B.js +0 -34
  64. data/lib/archsight/web/public/vue/ErrorPage-CwPT3JUr.css +0 -1
  65. data/lib/archsight/web/public/vue/ErrorPage-DcbC8Kf1.js +0 -2
  66. data/lib/archsight/web/public/vue/GraphView-Bg_l-F-Q.js +0 -1
  67. data/lib/archsight/web/public/vue/GraphView-DRcIqAiR.css +0 -1
  68. data/lib/archsight/web/public/vue/InstanceRouter-4VEtZM7n.css +0 -1
  69. data/lib/archsight/web/public/vue/InstanceRouter-D-twdyZY.js +0 -2
  70. data/lib/archsight/web/public/vue/KindList-CPImTKNb.js +0 -1
  71. data/lib/archsight/web/public/vue/ResourceList-DMxm0cGh.js +0 -1
  72. data/lib/archsight/web/public/vue/ResourceList-DP-z-j71.css +0 -1
  73. data/lib/archsight/web/public/vue/SearchResults-BGHbg48-.css +0 -1
  74. data/lib/archsight/web/public/vue/SearchResults-BqUHEWHE.js +0 -1
  75. data/lib/archsight/web/public/vue/_baseUniq-BjkdEi26.js +0 -1
  76. data/lib/archsight/web/public/vue/architectureDiagram-VXUJARFQ-JN7CxdtP.js +0 -36
  77. data/lib/archsight/web/public/vue/blockDiagram-VD42YOAC-teziPFHX.js +0 -122
  78. data/lib/archsight/web/public/vue/c4Diagram-YG6GDRKO-CVNrzCCc.js +0 -10
  79. data/lib/archsight/web/public/vue/chunk-4BX2VUAB-BQUARyyA.js +0 -1
  80. data/lib/archsight/web/public/vue/chunk-55IACEB6-BgJn0Waa.js +0 -1
  81. data/lib/archsight/web/public/vue/chunk-B4BG7PRW-0ghAfB1t.js +0 -165
  82. data/lib/archsight/web/public/vue/chunk-DI55MBZ5-HJo1DW2B.js +0 -220
  83. data/lib/archsight/web/public/vue/chunk-FMBD7UC4-C1GwGKgX.js +0 -15
  84. data/lib/archsight/web/public/vue/chunk-QN33PNHL-BOoA1KfJ.js +0 -1
  85. data/lib/archsight/web/public/vue/chunk-QZHKN3VN-BcTNH3IX.js +0 -1
  86. data/lib/archsight/web/public/vue/chunk-TZMSLE5B-58bQF3J5.js +0 -1
  87. data/lib/archsight/web/public/vue/classDiagram-2ON5EDUG-DAA8tpbN.js +0 -1
  88. data/lib/archsight/web/public/vue/classDiagram-v2-WZHVMYZB-DAA8tpbN.js +0 -1
  89. data/lib/archsight/web/public/vue/clone-BPcOyh7U.js +0 -1
  90. data/lib/archsight/web/public/vue/cose-bilkent-S5V4N54A-Bv8iR4rF.js +0 -1
  91. data/lib/archsight/web/public/vue/cytoscape.esm-5J0xJHOV.js +0 -321
  92. data/lib/archsight/web/public/vue/dagre-6UL2VRFP-B8ZPRhgU.js +0 -4
  93. data/lib/archsight/web/public/vue/diagram-PSM6KHXK-Dp1bZnNq.js +0 -24
  94. data/lib/archsight/web/public/vue/diagram-QEK2KX5R-DZBsDWP6.js +0 -43
  95. data/lib/archsight/web/public/vue/diagram-S2PKOQOG-BKOnLWNk.js +0 -24
  96. data/lib/archsight/web/public/vue/erDiagram-Q2GNP2WA-V6FqHPc9.js +0 -60
  97. data/lib/archsight/web/public/vue/flowDiagram-NV44I4VS-BipXjPVT.js +0 -162
  98. data/lib/archsight/web/public/vue/ganttDiagram-JELNMOA3-DUdVPVK1.js +0 -267
  99. data/lib/archsight/web/public/vue/gitGraphDiagram-V2S2FVAM-Bcf_apTG.js +0 -65
  100. data/lib/archsight/web/public/vue/graph-C4e9XILZ.js +0 -1
  101. data/lib/archsight/web/public/vue/graphviz-CJms5bxZ.js +0 -13
  102. data/lib/archsight/web/public/vue/index-BUI400cn.js +0 -2
  103. data/lib/archsight/web/public/vue/index-Tiu4C-Sb.css +0 -1
  104. data/lib/archsight/web/public/vue/infoDiagram-HS3SLOUP-DBwnExXO.js +0 -2
  105. data/lib/archsight/web/public/vue/journeyDiagram-XKPGCS4Q-D4b8PEzB.js +0 -139
  106. data/lib/archsight/web/public/vue/kanban-definition-3W4ZIXB7-R6r4NPxJ.js +0 -89
  107. data/lib/archsight/web/public/vue/katex-C-M49wc6.js +0 -261
  108. data/lib/archsight/web/public/vue/layout-CXL8NrCi.js +0 -1
  109. data/lib/archsight/web/public/vue/mermaid-MmHzOPPB.js +0 -250
  110. data/lib/archsight/web/public/vue/min-B4MPxNTL.js +0 -1
  111. data/lib/archsight/web/public/vue/mindmap-definition-VGOIOE7T-BypPT67y.js +0 -68
  112. data/lib/archsight/web/public/vue/pieDiagram-ADFJNKIX-BJ4nb15u.js +0 -30
  113. data/lib/archsight/web/public/vue/quadrantDiagram-AYHSOK5B-D4Ee5XRs.js +0 -7
  114. data/lib/archsight/web/public/vue/requirementDiagram-UZGBJVZJ-DuxpUmt2.js +0 -64
  115. data/lib/archsight/web/public/vue/sankeyDiagram-TZEHDZUN-CgWlWmza.js +0 -10
  116. data/lib/archsight/web/public/vue/sequenceDiagram-WL72ISMW-v2l2CbI0.js +0 -145
  117. data/lib/archsight/web/public/vue/stateDiagram-FKZM4ZOC-B5ROi2_1.js +0 -1
  118. data/lib/archsight/web/public/vue/stateDiagram-v2-4FDKWEC3-CQhha5H7.js +0 -1
  119. data/lib/archsight/web/public/vue/timeline-definition-IT6M3QCI-Bnx2HvxP.js +0 -61
  120. data/lib/archsight/web/public/vue/treemap-GDKQZRPO-BVho_qBy.js +0 -162
  121. data/lib/archsight/web/public/vue/useHighlight-yg_u-WUA.js +0 -10
  122. data/lib/archsight/web/public/vue/useMermaid-BPBwn39g.js +0 -1
  123. data/lib/archsight/web/public/vue/usePanZoom-CgSmFLId.js +0 -11
  124. data/lib/archsight/web/public/vue/xychartDiagram-PRI3JC2R-CDkeZXF1.js +0 -7
@@ -0,0 +1,552 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../handler"
5
+
6
+ # Abstract base class for language-specific code-graph import handlers.
7
+ #
8
+ # Subclasses implement two discovery methods and inherit all graph layout,
9
+ # clustering, coloring, and DOT generation logic from here.
10
+ #
11
+ # Required overrides:
12
+ # discover_modules(repo_root) → [[rel_dir, mod_name], ...]
13
+ # collect_packages(repo_root, modules, prefix) → {pkg_path => [dep_path, ...]}
14
+ #
15
+ # Package paths must use "/" as the separator regardless of language so that
16
+ # all generic helpers (node_id, short_label, rel_parts, etc.) work correctly.
17
+ # Subclasses with "." separators (e.g. Python dotted names) should convert to
18
+ # "/" in collect_packages before returning.
19
+ #
20
+ # Configuration (inherited by all subclasses):
21
+ # import/config/path - Path to the repository root
22
+ # import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
23
+ # import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
24
+ class Archsight::Import::Handlers::Grapher < Archsight::Import::Handler
25
+ # Subclasses declare their language name for explicit --language lookup.
26
+ def self.language_name = nil
27
+
28
+ # Subclasses return true when they can handle the given path.
29
+ # Used by Registry.handlers_for to collect all applicable graphers.
30
+ def self.applicable?(_path) = false
31
+
32
+ def safe_glob(pattern)
33
+ Dir.glob(pattern)
34
+ rescue Errno::ELOOP, Errno::ENOTDIR
35
+ []
36
+ end
37
+
38
+ PALETTE = [
39
+ { fill: "#ddeeff", edge: "#2266cc" },
40
+ { fill: "#ddffd8", edge: "#2a8a1e" },
41
+ { fill: "#fff3cc", edge: "#cc8800" },
42
+ { fill: "#fde8e8", edge: "#cc2222" },
43
+ { fill: "#f5e8fd", edge: "#8822cc" },
44
+ { fill: "#fdf5e8", edge: "#cc6600" },
45
+ { fill: "#e8fdfd", edge: "#228888" },
46
+ { fill: "#ffeedd", edge: "#995500" },
47
+ { fill: "#eeeeff", edge: "#4444aa" },
48
+ { fill: "#ffeeff", edge: "#993399" }
49
+ ].freeze
50
+
51
+ def execute
52
+ @path = config("path")
53
+ raise "Missing required config: path" unless @path
54
+ raise "Directory not found: #{@path}" unless File.directory?(@path)
55
+
56
+ @ranksep = config("ranksep", default: "0.6").to_f
57
+ @nodesep = config("nodesep", default: "0.15").to_f
58
+
59
+ progress.update("Discovering modules")
60
+ modules = discover_modules(@path)
61
+ return write_yaml(YAML.dump(self_marker)) if modules.empty?
62
+
63
+ module_colors, prefix = build_module_colors(modules)
64
+
65
+ progress.update("Collecting packages")
66
+ pkgs = collect_packages(@path, modules, prefix)
67
+ return write_yaml(YAML.dump(self_marker)) if pkgs.empty?
68
+
69
+ progress.update("Generating DOT graph")
70
+ dot_content = emit_dot(pkgs, modules, module_colors, prefix,
71
+ ranksep: @ranksep, nodesep: @nodesep)
72
+
73
+ progress.update("Generating resource")
74
+ resource = resource_yaml(
75
+ kind: "TechnologyArtifact",
76
+ name: artifact_name(@path),
77
+ annotations: { annotation_key => dot_content },
78
+ spec: {}
79
+ )
80
+
81
+ write_yaml(YAML.dump(resource) + YAML.dump(self_marker))
82
+ write_generates_meta
83
+ end
84
+
85
+ def dot_graph(path:, ranksep: 0.6, nodesep: 0.15)
86
+ modules = discover_modules(path)
87
+ return nil if modules.empty?
88
+
89
+ module_colors, prefix = build_module_colors(modules)
90
+ pkgs = collect_packages(path, modules, prefix)
91
+ return nil if pkgs.empty?
92
+
93
+ emit_dot(pkgs, modules, module_colors, prefix, ranksep: ranksep, nodesep: nodesep)
94
+ end
95
+
96
+ private
97
+
98
+ def annotation_key
99
+ lang = self.class.language_name
100
+ lang ? "architecture/#{lang}/modules" : "architecture/modules"
101
+ end
102
+
103
+ # @abstract Discover modules/packages in the repository.
104
+ # @param repo_root [String] Absolute path to the repository root
105
+ # @return [Array<Array>] List of [rel_dir, mod_name] pairs
106
+ def discover_modules(_repo_root)
107
+ raise NotImplementedError, "#{self.class}#discover_modules must be implemented"
108
+ end
109
+
110
+ # @abstract Collect packages and their intra-repo dependencies.
111
+ # @param repo_root [String] Absolute path to the repository root
112
+ # @param modules [Array<Array>] List of [rel_dir, mod_name] pairs from discover_modules
113
+ # @param prefix [String] Common module name prefix (may be empty)
114
+ # @return [Hash] Map of pkg_path => [dep_pkg_path, ...] using "/" separators
115
+ def collect_packages(_repo_root, _modules, _prefix)
116
+ raise NotImplementedError, "#{self.class}#collect_packages must be implemented"
117
+ end
118
+
119
+ # ── Module coloring ───────────────────────────────────────────────────────
120
+
121
+ def build_module_colors(modules)
122
+ prefix = common_module_prefix(modules.map { |_, m| m })
123
+ colors = {}
124
+ modules.each_with_index do |(rel_dir, mod_name), i|
125
+ label = mod_name.delete_prefix(prefix)
126
+ label = mod_name.split("/").last if label.empty?
127
+ c = PALETTE[i % PALETTE.length]
128
+ colors[rel_dir] = { fill: c[:fill], edge: c[:edge], label: label }
129
+ end
130
+ [colors, prefix]
131
+ end
132
+
133
+ def common_module_prefix(module_names)
134
+ return "" if module_names.empty?
135
+
136
+ parts = module_names.map { |n| n.split("/") }
137
+ min_len = parts.map(&:length).min
138
+ common = []
139
+ min_len.times do |i|
140
+ column = parts.map { |p| p[i] }
141
+ break unless column.uniq.length == 1
142
+
143
+ common << column.first
144
+ end
145
+ common.empty? ? "" : "#{common.join("/")}/"
146
+ end
147
+
148
+ def init_node(graph, pkg, mod_name, prefix, cluster_label)
149
+ lbl = short_label(pkg, mod_name)
150
+ if lbl == cluster_label
151
+ if show_root_package_node?
152
+ graph.node(node_id(pkg, prefix), label: "◆")
153
+ else
154
+ graph.node(node_id(pkg, prefix), label: "", style: :invis, width: 0, height: 0)
155
+ end
156
+ else
157
+ graph.node(node_id(pkg, prefix), label: lbl)
158
+ end
159
+ end
160
+
161
+ # Hook: return true to render the package node even when its label matches the cluster label.
162
+ # Python/Go leave it false (hide redundant package-root node); Java overrides to true.
163
+ def show_root_package_node?
164
+ false
165
+ end
166
+
167
+ def darken(hex_color, factor = 0.88)
168
+ hex = hex_color.delete_prefix("#")
169
+ r = (hex[0, 2].to_i(16) * factor).to_i
170
+ g = (hex[2, 2].to_i(16) * factor).to_i
171
+ b = (hex[4, 2].to_i(16) * factor).to_i
172
+ format("#%02x%02x%02x", r, g, b)
173
+ end
174
+
175
+ # ── Node / label helpers ──────────────────────────────────────────────────
176
+
177
+ def node_id(pkg_path, prefix)
178
+ pkg_path.delete_prefix(prefix).gsub(/[^a-zA-Z0-9]/, "_")
179
+ end
180
+
181
+ def short_label(pkg_path, mod_name)
182
+ rel = pkg_path.delete_prefix("#{mod_name}/")
183
+ rel == pkg_path ? pkg_path.split("/").last : rel
184
+ end
185
+
186
+ def rel_parts(pkg_path, mod_name)
187
+ suffix = pkg_path.delete_prefix(mod_name)
188
+ return [] if suffix.empty?
189
+
190
+ suffix.delete_prefix("/").split("/")
191
+ end
192
+
193
+ def pkg_module_dir(pkg_path, modules, _prefix)
194
+ modules.sort_by { |_, mod_name| -mod_name.length }.each do |rel_dir, mod_name|
195
+ return rel_dir if pkg_path == mod_name || pkg_path.start_with?("#{mod_name}/")
196
+ end
197
+ nil
198
+ end
199
+
200
+ # ── Hierarchy ─────────────────────────────────────────────────────────────
201
+
202
+ def build_hierarchy_edges(pkg_set)
203
+ edges = []
204
+ pkg_set.each do |pkg|
205
+ parts = pkg.split("/")
206
+ (parts.length - 1).downto(1) do |i|
207
+ parent = parts[0, i].join("/")
208
+ if pkg_set.include?(parent)
209
+ edges << [parent, pkg]
210
+ break
211
+ end
212
+ end
213
+ end
214
+ edges
215
+ end
216
+
217
+ # ── Topological depths ────────────────────────────────────────────────────
218
+
219
+ def l1_topo_depths(pkgs, mod_name)
220
+ l1_of = {}
221
+ pkgs.each_key do |pkg|
222
+ parts = rel_parts(pkg, mod_name)
223
+ l1_of[pkg] = parts[0] if parts.any? && parts != [""]
224
+ end
225
+
226
+ all_l1 = l1_of.values.to_set
227
+ l1_imports = all_l1.to_h { |l1| [l1, Set.new] }
228
+
229
+ pkgs.each do |pkg, deps|
230
+ src = l1_of[pkg]
231
+ next unless src
232
+
233
+ deps.each do |dep|
234
+ dst = l1_of[dep]
235
+ l1_imports[src] << dst if dst && dst != src
236
+ end
237
+ end
238
+
239
+ cache = {}
240
+ depth_fn = lambda { |l1, visiting|
241
+ return cache[l1] if cache.key?(l1)
242
+ return 0 if visiting.include?(l1)
243
+
244
+ children = l1_imports[l1] - visiting
245
+ d = children.any? ? 1 + children.map { |c| depth_fn.call(c, visiting | Set[l1]) }.max : 0
246
+ cache[l1] = d
247
+ }
248
+ all_l1.each { |l1| depth_fn.call(l1, Set.new) }
249
+
250
+ [cache, l1_imports]
251
+ end
252
+
253
+ def module_topo_depths(pkgs, modules)
254
+ pkg_to_dir = {}
255
+ modules.each do |rel_dir, mod_name|
256
+ pkgs.each_key do |pkg|
257
+ pkg_to_dir[pkg] = rel_dir if pkg == mod_name || pkg.start_with?("#{mod_name}/")
258
+ end
259
+ end
260
+
261
+ all_dirs = modules.to_set { |rel_dir, _| rel_dir }
262
+ mod_imports = all_dirs.to_h { |d| [d, Set.new] }
263
+
264
+ pkgs.each do |pkg, deps|
265
+ src_dir = pkg_to_dir[pkg]
266
+ next unless src_dir
267
+
268
+ deps.each do |dep|
269
+ dst_dir = pkg_to_dir[dep]
270
+ mod_imports[src_dir] << dst_dir if dst_dir && dst_dir != src_dir
271
+ end
272
+ end
273
+
274
+ cache = {}
275
+ depth_fn = lambda { |d, visiting|
276
+ return cache[d] if cache.key?(d)
277
+ return 0 if visiting.include?(d)
278
+
279
+ children = mod_imports[d] - visiting
280
+ v = children.any? ? 1 + children.map { |c| depth_fn.call(c, visiting | Set[d]) }.max : 0
281
+ cache[d] = v
282
+ }
283
+ all_dirs.each { |d| depth_fn.call(d, Set.new) }
284
+
285
+ [cache, mod_imports]
286
+ end
287
+
288
+ # ── Cluster emitters ──────────────────────────────────────────────────────
289
+
290
+ def group_by_l1_l2(pkgs_in_mod, mod_name)
291
+ l1_groups = {}
292
+ ungrouped = []
293
+
294
+ pkgs_in_mod.each do |pkg|
295
+ parts = rel_parts(pkg, mod_name)
296
+ if parts.empty? || parts == [""]
297
+ ungrouped << pkg
298
+ next
299
+ end
300
+ l1 = parts[0]
301
+ l2 = parts.length >= 2 ? parts[0, 2].join("/") : nil
302
+ l1_groups[l1] ||= {}
303
+ l1_groups[l1][l2] ||= []
304
+ l1_groups[l1][l2] << pkg
305
+ end
306
+
307
+ [l1_groups, ungrouped]
308
+ end
309
+
310
+ def emit_module_cluster(graph, mod_name, mod_colors, pkgs_in_mod, prefix)
311
+ cluster_id = mod_name.delete_prefix(prefix).gsub(/[^a-zA-Z0-9]/, "_")
312
+ fill = mod_colors[:fill]
313
+
314
+ graph.subgraph("cluster_#{cluster_id}",
315
+ label: mod_colors[:label], style: "rounded,filled", fillcolor: fill,
316
+ fontname: "Helvetica Bold", fontsize: 13) do |sg|
317
+ l1_groups, ungrouped = group_by_l1_l2(pkgs_in_mod, mod_name)
318
+ ungrouped.each { |pkg| init_node(sg, pkg, mod_name, prefix, mod_colors[:label]) }
319
+ emit_l1_subclusters(sg, l1_groups, cluster_id, darken(fill, 0.93), darken(fill, 0.86),
320
+ mod_name, prefix, fontsize: 11)
321
+ end
322
+ end
323
+
324
+ def emit_l1_subclusters(graph, l1_groups, cluster_id, l1_fill, l2_fill, mod_name, prefix,
325
+ fontsize: 12)
326
+ l1_groups.keys.sort.each do |l1|
327
+ nil_pkgs = l1_groups[l1][nil] || []
328
+ l2_keys = l1_groups[l1].keys.compact
329
+
330
+ if l2_keys.empty?
331
+ nil_pkgs.sort.each { |pkg| graph.node(node_id(pkg, prefix), label: short_label(pkg, mod_name)) }
332
+ next
333
+ end
334
+
335
+ l1_cid = "#{cluster_id}_#{l1}".gsub(/[^a-zA-Z0-9]/, "_")
336
+ graph.subgraph("cluster_#{l1_cid}",
337
+ label: l1, style: "rounded,filled", fillcolor: l1_fill,
338
+ fontname: "Helvetica", fontsize: fontsize) do |l1g|
339
+ nil_pkgs.sort.each { |pkg| init_node(l1g, pkg, mod_name, prefix, l1) }
340
+ l2_keys.sort.each do |l2|
341
+ emit_l2_subcluster(l1g, l2, "#{cluster_id}_#{l2.gsub("/", "_")}", l2_fill,
342
+ l1_groups[l1][l2], mod_name, prefix)
343
+ end
344
+ end
345
+ end
346
+ end
347
+
348
+ def emit_l2_subcluster(graph, l2_path, l2_cid_raw, l2_fill, pkgs_in_l2, mod_name, prefix)
349
+ if pkgs_in_l2.length == 1
350
+ pkg = pkgs_in_l2.first
351
+ parts = rel_parts(pkg, mod_name)
352
+ lbl = parts.length > 2 ? parts[2..].join("/") : parts.last
353
+ graph.node(node_id(pkg, prefix), label: lbl)
354
+ return
355
+ end
356
+
357
+ l2_cid = l2_cid_raw.gsub(/[^a-zA-Z0-9]/, "_")
358
+ l2_label = l2_path.include?("/") ? l2_path.split("/")[1] : l2_path
359
+ graph.subgraph("cluster_#{l2_cid}",
360
+ label: l2_label, style: "rounded,filled", fillcolor: l2_fill,
361
+ fontname: "Helvetica", fontsize: 10) do |l2g|
362
+ pkgs_in_l2.sort.each do |pkg|
363
+ parts = rel_parts(pkg, mod_name)
364
+ lbl = parts.length > 2 ? parts[2..].join("/") : parts.last
365
+ l2g.node(node_id(pkg, prefix), label: lbl)
366
+ end
367
+ end
368
+ end
369
+
370
+ def emit_clusters_single_module(graph, mod_name, mod_colors, pkgs_in_mod, prefix)
371
+ l1_groups, ungrouped = group_by_l1_l2(pkgs_in_mod, mod_name)
372
+ l1_colors = l1_groups.keys.sort.each_with_index.with_object({}) do |(l1, idx), h|
373
+ h[l1] = PALETTE[idx % PALETTE.length]
374
+ end
375
+
376
+ if wrap_single_module?
377
+ cluster_id = mod_name.gsub(/[^a-zA-Z0-9]/, "_")
378
+ graph.subgraph("cluster_#{cluster_id}",
379
+ label: mod_colors[:label], style: "rounded,filled", fillcolor: mod_colors[:fill],
380
+ fontname: "Helvetica Bold", fontsize: 13) do |sg|
381
+ ungrouped.each { |pkg| init_node(sg, pkg, mod_name, prefix, mod_colors[:label]) }
382
+ l1_groups.keys.sort.each do |l1|
383
+ c = l1_colors[l1]
384
+ l1_cid = l1.gsub(/[^a-zA-Z0-9]/, "_")
385
+ emit_l1_subclusters(sg, { l1 => l1_groups[l1] }, l1_cid, c[:fill],
386
+ darken(c[:fill], 0.86), mod_name, prefix)
387
+ end
388
+ end
389
+ else
390
+ ungrouped.each { |pkg| graph.node(node_id(pkg, prefix), label: short_label(pkg, mod_name)) }
391
+ l1_groups.keys.sort.each do |l1|
392
+ c = l1_colors[l1]
393
+ l1_cid = l1.gsub(/[^a-zA-Z0-9]/, "_")
394
+ emit_l1_subclusters(graph, { l1 => l1_groups[l1] }, l1_cid, c[:fill],
395
+ darken(c[:fill], 0.86), mod_name, prefix)
396
+ end
397
+ end
398
+
399
+ l1_colors
400
+ end
401
+
402
+ def wrap_single_module?
403
+ false
404
+ end
405
+
406
+ # ── DOT generation ────────────────────────────────────────────────────────
407
+
408
+ # Choose GraphViz spline style based on graph complexity.
409
+ # curved: best quality, expensive routing — fine for small graphs.
410
+ # ortho: right-angle edges, much faster for medium graphs.
411
+ # line: straight lines, fastest — used for large graphs.
412
+ def splines_for(pkgs)
413
+ edge_count = pkgs.values.sum(&:length)
414
+ if edge_count < 80
415
+ :curved
416
+ elsif edge_count < 400
417
+ :ortho
418
+ else
419
+ :line
420
+ end
421
+ end
422
+
423
+ def emit_dot(pkgs, modules, module_colors, prefix, ranksep: 0.6, nodesep: 0.15)
424
+ by_dir = modules.each_with_object({}) { |(rel_dir, _), h| h[rel_dir] = [] }
425
+ pkgs.keys.sort.each { |pkg| assign_pkg_to_dir(pkg, modules, prefix, by_dir) }
426
+
427
+ Archsight::Graphvis.new("packages",
428
+ rankdir: :LR, compound: true, splines: splines_for(pkgs),
429
+ concentrate: true,
430
+ ranksep: ranksep, nodesep: nodesep,
431
+ fontname: "Helvetica", fontsize: 10).draw_dot do |graph|
432
+ graph.defaults(:node, fontname: "Helvetica", fontsize: 8, shape: :box,
433
+ style: "rounded,filled", fillcolor: :white, height: 0.2, width: 0.4)
434
+ graph.defaults(:edge, fontname: "Helvetica", fontsize: 8)
435
+ pkg_edge_color = {}
436
+ if modules.length == 1
437
+ emit_dot_single_module(graph, modules, by_dir, pkgs, prefix, pkg_edge_color, module_colors)
438
+ else
439
+ emit_dot_multi_module(graph, modules, by_dir, pkgs, module_colors, prefix, pkg_edge_color)
440
+ end
441
+ assigned = by_dir.values.flatten.to_set
442
+ pkgs.keys.sort.each do |pkg|
443
+ next if assigned.include?(pkg)
444
+
445
+ graph.node(node_id(pkg, prefix), label: pkg.split("/").last)
446
+ pkg_edge_color[pkg] = "#555555"
447
+ end
448
+ emit_dependency_edges(graph, pkgs, pkg_edge_color, prefix)
449
+ end
450
+ end
451
+
452
+ def assign_pkg_to_dir(pkg, modules, prefix, by_dir)
453
+ rel_dir = pkg_module_dir(pkg, modules, prefix)
454
+ by_dir[rel_dir] << pkg if rel_dir && by_dir.key?(rel_dir)
455
+ end
456
+
457
+ def emit_dot_single_module(graph, modules, by_dir, pkgs, prefix, pkg_edge_color, module_colors)
458
+ rel_dir, mod_name = modules[0]
459
+ mod_colors = module_colors[rel_dir]
460
+ pkgs_in_mod = by_dir[rel_dir] || []
461
+ l1_colors = emit_clusters_single_module(graph, mod_name, mod_colors, pkgs_in_mod, prefix)
462
+
463
+ pkgs_in_mod.each do |pkg|
464
+ parts = rel_parts(pkg, mod_name)
465
+ l1 = parts.any? && parts != [""] ? parts[0] : nil
466
+ pkg_edge_color[pkg] = l1_colors[l1][:edge] if l1 && l1_colors[l1]
467
+ end
468
+ emit_l1_constraint_edges(graph, pkgs, mod_name, pkgs_in_mod, prefix)
469
+ end
470
+
471
+ def emit_l1_constraint_edges(graph, pkgs, mod_name, pkgs_in_mod, prefix)
472
+ depths, l1_imports = l1_topo_depths(pkgs, mod_name)
473
+ l1_rep = depths.each_with_object({}) do |l1, h|
474
+ pkgs_in_l1 = pkgs_in_mod.select { |p| rel_parts(p, mod_name)[0, 1] == [l1] }.sort
475
+ h[l1] = node_id(pkgs_in_l1.first, prefix) if pkgs_in_l1.any?
476
+ end
477
+ emit_constraint_edges(graph, l1_imports, l1_rep)
478
+ end
479
+
480
+ def emit_dot_multi_module(graph, modules, by_dir, pkgs, module_colors, prefix, pkg_edge_color)
481
+ modules.each do |rel_dir, mod_name|
482
+ pkgs_in_mod = by_dir[rel_dir] || []
483
+ next if pkgs_in_mod.empty?
484
+
485
+ emit_module_cluster(graph, mod_name, module_colors[rel_dir], pkgs_in_mod, prefix)
486
+ color = module_colors[rel_dir][:edge]
487
+ pkgs_in_mod.each { |pkg| pkg_edge_color[pkg] = color }
488
+ end
489
+ emit_module_constraint_edges(graph, modules, by_dir, pkgs, prefix)
490
+ end
491
+
492
+ def emit_module_constraint_edges(graph, modules, by_dir, pkgs, prefix)
493
+ _depths, mod_imports = module_topo_depths(pkgs, modules)
494
+ mod_rep = modules.each_with_object({}) do |(rel_dir, _), h|
495
+ pkgs_in_mod = (by_dir[rel_dir] || []).sort
496
+ h[rel_dir] = node_id(pkgs_in_mod.first, prefix) if pkgs_in_mod.any?
497
+ end
498
+ emit_constraint_edges(graph, mod_imports, mod_rep)
499
+ end
500
+
501
+ def emit_constraint_edges(graph, imports, rep)
502
+ imports.each do |src, dsts|
503
+ src_rep = rep[src]
504
+ dsts.each do |dst|
505
+ dst_rep = rep[dst]
506
+ graph.edge(src_rep, dst_rep, style: :invis, constraint: true) if src_rep && dst_rep
507
+ end
508
+ end
509
+ end
510
+
511
+ def emit_dependency_edges(graph, pkgs, pkg_edge_color, prefix)
512
+ pkg_set = pkgs.keys.to_set
513
+ has_children = pkg_set.each_with_object(Set.new) do |p, s|
514
+ parts = p.split("/")
515
+ (1...parts.length).each { |i| s << parts[0, i].join("/") }
516
+ end
517
+
518
+ pkgs.keys.sort.each do |pkg|
519
+ edge_color = pkg_edge_color[pkg]
520
+ next unless edge_color
521
+
522
+ pkgs[pkg].uniq.sort.each do |dep|
523
+ next if dep == pkg
524
+ next if suppress_edge_to?(dep, pkg_set, has_children)
525
+
526
+ graph.edge(node_id(pkg, prefix), node_id(dep, prefix), color: edge_color, style: :solid)
527
+ end
528
+ end
529
+ end
530
+
531
+ # Hook: return true to suppress the dependency edge to +dep+.
532
+ # Default: suppress edges to any ancestor path (avoids edges to invisible cluster-root nodes).
533
+ # Java overrides to only suppress structural ancestors (not actual packages in pkg_set).
534
+ def suppress_edge_to?(dep, _pkg_set, has_children)
535
+ has_children.include?(dep)
536
+ end
537
+
538
+ # ── Artifact name ─────────────────────────────────────────────────────────
539
+
540
+ def artifact_name(path)
541
+ git_config = File.join(path, ".git", "config")
542
+ if File.exist?(git_config)
543
+ url_line = File.read(git_config).lines.find { |l| l.include?("url") }
544
+ if url_line
545
+ url = url_line.split("=").last.strip
546
+ name = url.split(":").last.gsub(/\.git$/, "").tr("/", ":")
547
+ return "Repo:#{name}"
548
+ end
549
+ end
550
+ "Repo:#{File.basename(path)}"
551
+ end
552
+ end