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.
- checksums.yaml +4 -4
- data/lib/archsight/cli.rb +67 -0
- data/lib/archsight/graph.rb +10 -0
- data/lib/archsight/import/executor.rb +11 -3
- data/lib/archsight/import/handlers/cpp_grapher.rb +193 -0
- data/lib/archsight/import/handlers/crystal_grapher.rb +186 -0
- data/lib/archsight/import/handlers/elixir_grapher.rb +202 -0
- data/lib/archsight/import/handlers/go_grapher.rb +127 -0
- data/lib/archsight/import/handlers/grapher.rb +552 -0
- data/lib/archsight/import/handlers/java_grapher.rb +286 -0
- data/lib/archsight/import/handlers/javascript_grapher.rb +340 -0
- data/lib/archsight/import/handlers/python_grapher.rb +270 -0
- data/lib/archsight/import/handlers/repository.rb +41 -17
- data/lib/archsight/import/handlers/ruby_grapher.rb +203 -0
- data/lib/archsight/import/handlers/rust_grapher.rb +227 -0
- data/lib/archsight/import/registry.rb +23 -0
- data/lib/archsight/resources/import.rb +1 -0
- data/lib/archsight/resources/technology_artifact.rb +17 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/json_helpers.rb +1 -1
- data/lib/archsight/web/public/vue/ApiDocsPage-C0y953v0.css +1 -0
- data/lib/archsight/web/public/vue/ApiDocsPage-DHSCaHEn.js +1 -0
- data/lib/archsight/web/public/vue/DocPage-DszOPlFy.js +1 -0
- data/lib/archsight/web/public/vue/EditorPage-CPZ0Ei4l.css +1 -0
- data/lib/archsight/web/public/vue/EditorPage-DsiuZ7fg.js +35 -0
- data/lib/archsight/web/public/vue/ErrorPage-C4JutrYc.js +2 -0
- data/lib/archsight/web/public/vue/ErrorPage-uMDnfY5_.css +1 -0
- data/lib/archsight/web/public/vue/GraphView-Bqlbt6dK.js +1 -0
- data/lib/archsight/web/public/vue/GraphView-Cj2V2stN.css +1 -0
- data/lib/archsight/web/public/vue/InstanceRouter-D8SEY2eu.js +2 -0
- data/lib/archsight/web/public/vue/InstanceRouter-D9hclKFt.css +1 -0
- data/lib/archsight/web/public/vue/KindList-CPDaNron.js +1 -0
- data/lib/archsight/web/public/vue/ResourceList-B5w9yiyS.js +1 -0
- data/lib/archsight/web/public/vue/ResourceList-DxZfNbOg.css +1 -0
- data/lib/archsight/web/public/vue/SearchResults-DSHpVO-c.css +1 -0
- data/lib/archsight/web/public/vue/SearchResults-FpkhdBFu.js +1 -0
- data/lib/archsight/web/public/vue/architecture-7EHR7CIX-DpNNjAIc.js +1 -0
- data/lib/archsight/web/public/vue/eventmodeling-FCH6USID-CiThxoWl.js +1 -0
- data/lib/archsight/web/public/vue/gitGraph-WXDBUCRP-BODMGpAm.js +1 -0
- data/lib/archsight/web/public/vue/graphviz-09t3o0af.js +13 -0
- data/lib/archsight/web/public/vue/index-BW0IzY6X.css +1 -0
- data/lib/archsight/web/public/vue/index-T1YqCmM1.js +2 -0
- data/lib/archsight/web/public/vue/info-J43DQDTF-fLq04sri.js +1 -0
- data/lib/archsight/web/public/vue/katex-5qHlIbPR.js +261 -0
- data/lib/archsight/web/public/vue/mermaid-DYyHQk7x.js +3093 -0
- data/lib/archsight/web/public/vue/packet-YPE3B663-DoY1fbqu.js +1 -0
- data/lib/archsight/web/public/vue/pie-LRSECV5Y-C7ZQVwRe.js +1 -0
- data/lib/archsight/web/public/vue/radar-GUYGQ44K-CRtY5oqf.js +1 -0
- data/lib/archsight/web/public/vue/rolldown-runtime-QTnfLwEv.js +1 -0
- data/lib/archsight/web/public/vue/treeView-BLDUP644-Csx2WLLh.js +1 -0
- data/lib/archsight/web/public/vue/treemap-LRROVOQU-CfEnRbTx.js +1 -0
- data/lib/archsight/web/public/vue/{useGraphviz-C5lv_BWF.js → useGraphviz-EKSrE4q_.js} +5 -4
- data/lib/archsight/web/public/vue/useHighlight-BcVbGyrK.js +10 -0
- data/lib/archsight/web/public/vue/useMermaid-CIZxhy_r.js +2 -0
- data/lib/archsight/web/public/vue/usePanZoom-C2slpyY9.js +11 -0
- data/lib/archsight/web/public/vue/wardley-L42UT6IY-97oUvxhz.js +1 -0
- data/lib/archsight/web/public/vue.html +4 -3
- metadata +51 -72
- data/lib/archsight/web/public/vue/ApiDocsPage-DHOFUCYc.js +0 -1
- data/lib/archsight/web/public/vue/ApiDocsPage-DhNTOH4o.css +0 -1
- data/lib/archsight/web/public/vue/DocPage-CV66qgTr.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-Dq0MuTnp.css +0 -1
- data/lib/archsight/web/public/vue/EditorPage-KqBivY-B.js +0 -34
- data/lib/archsight/web/public/vue/ErrorPage-CwPT3JUr.css +0 -1
- data/lib/archsight/web/public/vue/ErrorPage-DcbC8Kf1.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-Bg_l-F-Q.js +0 -1
- data/lib/archsight/web/public/vue/GraphView-DRcIqAiR.css +0 -1
- data/lib/archsight/web/public/vue/InstanceRouter-4VEtZM7n.css +0 -1
- data/lib/archsight/web/public/vue/InstanceRouter-D-twdyZY.js +0 -2
- data/lib/archsight/web/public/vue/KindList-CPImTKNb.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DMxm0cGh.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DP-z-j71.css +0 -1
- data/lib/archsight/web/public/vue/SearchResults-BGHbg48-.css +0 -1
- data/lib/archsight/web/public/vue/SearchResults-BqUHEWHE.js +0 -1
- data/lib/archsight/web/public/vue/_baseUniq-BjkdEi26.js +0 -1
- data/lib/archsight/web/public/vue/architectureDiagram-VXUJARFQ-JN7CxdtP.js +0 -36
- data/lib/archsight/web/public/vue/blockDiagram-VD42YOAC-teziPFHX.js +0 -122
- data/lib/archsight/web/public/vue/c4Diagram-YG6GDRKO-CVNrzCCc.js +0 -10
- data/lib/archsight/web/public/vue/chunk-4BX2VUAB-BQUARyyA.js +0 -1
- data/lib/archsight/web/public/vue/chunk-55IACEB6-BgJn0Waa.js +0 -1
- data/lib/archsight/web/public/vue/chunk-B4BG7PRW-0ghAfB1t.js +0 -165
- data/lib/archsight/web/public/vue/chunk-DI55MBZ5-HJo1DW2B.js +0 -220
- data/lib/archsight/web/public/vue/chunk-FMBD7UC4-C1GwGKgX.js +0 -15
- data/lib/archsight/web/public/vue/chunk-QN33PNHL-BOoA1KfJ.js +0 -1
- data/lib/archsight/web/public/vue/chunk-QZHKN3VN-BcTNH3IX.js +0 -1
- data/lib/archsight/web/public/vue/chunk-TZMSLE5B-58bQF3J5.js +0 -1
- data/lib/archsight/web/public/vue/classDiagram-2ON5EDUG-DAA8tpbN.js +0 -1
- data/lib/archsight/web/public/vue/classDiagram-v2-WZHVMYZB-DAA8tpbN.js +0 -1
- data/lib/archsight/web/public/vue/clone-BPcOyh7U.js +0 -1
- data/lib/archsight/web/public/vue/cose-bilkent-S5V4N54A-Bv8iR4rF.js +0 -1
- data/lib/archsight/web/public/vue/cytoscape.esm-5J0xJHOV.js +0 -321
- data/lib/archsight/web/public/vue/dagre-6UL2VRFP-B8ZPRhgU.js +0 -4
- data/lib/archsight/web/public/vue/diagram-PSM6KHXK-Dp1bZnNq.js +0 -24
- data/lib/archsight/web/public/vue/diagram-QEK2KX5R-DZBsDWP6.js +0 -43
- data/lib/archsight/web/public/vue/diagram-S2PKOQOG-BKOnLWNk.js +0 -24
- data/lib/archsight/web/public/vue/erDiagram-Q2GNP2WA-V6FqHPc9.js +0 -60
- data/lib/archsight/web/public/vue/flowDiagram-NV44I4VS-BipXjPVT.js +0 -162
- data/lib/archsight/web/public/vue/ganttDiagram-JELNMOA3-DUdVPVK1.js +0 -267
- data/lib/archsight/web/public/vue/gitGraphDiagram-V2S2FVAM-Bcf_apTG.js +0 -65
- data/lib/archsight/web/public/vue/graph-C4e9XILZ.js +0 -1
- data/lib/archsight/web/public/vue/graphviz-CJms5bxZ.js +0 -13
- data/lib/archsight/web/public/vue/index-BUI400cn.js +0 -2
- data/lib/archsight/web/public/vue/index-Tiu4C-Sb.css +0 -1
- data/lib/archsight/web/public/vue/infoDiagram-HS3SLOUP-DBwnExXO.js +0 -2
- data/lib/archsight/web/public/vue/journeyDiagram-XKPGCS4Q-D4b8PEzB.js +0 -139
- data/lib/archsight/web/public/vue/kanban-definition-3W4ZIXB7-R6r4NPxJ.js +0 -89
- data/lib/archsight/web/public/vue/katex-C-M49wc6.js +0 -261
- data/lib/archsight/web/public/vue/layout-CXL8NrCi.js +0 -1
- data/lib/archsight/web/public/vue/mermaid-MmHzOPPB.js +0 -250
- data/lib/archsight/web/public/vue/min-B4MPxNTL.js +0 -1
- data/lib/archsight/web/public/vue/mindmap-definition-VGOIOE7T-BypPT67y.js +0 -68
- data/lib/archsight/web/public/vue/pieDiagram-ADFJNKIX-BJ4nb15u.js +0 -30
- data/lib/archsight/web/public/vue/quadrantDiagram-AYHSOK5B-D4Ee5XRs.js +0 -7
- data/lib/archsight/web/public/vue/requirementDiagram-UZGBJVZJ-DuxpUmt2.js +0 -64
- data/lib/archsight/web/public/vue/sankeyDiagram-TZEHDZUN-CgWlWmza.js +0 -10
- data/lib/archsight/web/public/vue/sequenceDiagram-WL72ISMW-v2l2CbI0.js +0 -145
- data/lib/archsight/web/public/vue/stateDiagram-FKZM4ZOC-B5ROi2_1.js +0 -1
- data/lib/archsight/web/public/vue/stateDiagram-v2-4FDKWEC3-CQhha5H7.js +0 -1
- data/lib/archsight/web/public/vue/timeline-definition-IT6M3QCI-Bnx2HvxP.js +0 -61
- data/lib/archsight/web/public/vue/treemap-GDKQZRPO-BVho_qBy.js +0 -162
- data/lib/archsight/web/public/vue/useHighlight-yg_u-WUA.js +0 -10
- data/lib/archsight/web/public/vue/useMermaid-BPBwn39g.js +0 -1
- data/lib/archsight/web/public/vue/usePanZoom-CgSmFLId.js +0 -11
- 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
|