archsight 0.2.4 → 0.2.6

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