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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "grapher"
|
|
4
|
+
require_relative "../registry"
|
|
5
|
+
|
|
6
|
+
# ElixirGrapher — analyses an Elixir repository and generates a GraphViz DOT
|
|
7
|
+
# graph of its application/package structure, stored as
|
|
8
|
+
# architecture/elixir/modules on the TechnologyArtifact.
|
|
9
|
+
#
|
|
10
|
+
# Supports single-app projects (mix.exs at root) and umbrella projects
|
|
11
|
+
# (apps/*/mix.exs). Uses pure static regex analysis of alias/import/use
|
|
12
|
+
# statements — no Elixir toolchain required.
|
|
13
|
+
#
|
|
14
|
+
# Configuration:
|
|
15
|
+
# import/config/path - Path to the Elixir repository root
|
|
16
|
+
# import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
|
|
17
|
+
# import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
|
|
18
|
+
class Archsight::Import::Handlers::ElixirGrapher < Archsight::Import::Handlers::Grapher
|
|
19
|
+
def self.language_name = "elixir"
|
|
20
|
+
|
|
21
|
+
def self.applicable?(path)
|
|
22
|
+
File.exist?(File.join(path, "mix.exs")) ||
|
|
23
|
+
Dir.glob(File.join(path, "apps/*/mix.exs")).any?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def wrap_single_module?
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
SKIP_DIRS = %w[_build deps test tests .git cover priv node_modules _checkouts config].freeze
|
|
31
|
+
|
|
32
|
+
# MAX_PKG_DEPTH = 3 allows my_app/web/controllers depth, which is common in Phoenix apps.
|
|
33
|
+
MAX_PKG_DEPTH = 3
|
|
34
|
+
|
|
35
|
+
ALIAS_RE = /^\s*alias\s+(\w+(?:\.\w+)*(?:\.\{[^}]*\})?)/
|
|
36
|
+
IMPORT_RE = /^\s*import\s+([\w.]+)/
|
|
37
|
+
USE_RE = /^\s*use\s+([\w.]+)/
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# ── Module discovery ─────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def discover_modules(repo_root)
|
|
44
|
+
return umbrella_modules(repo_root) if umbrella?(repo_root)
|
|
45
|
+
|
|
46
|
+
lib = File.join(repo_root, "lib")
|
|
47
|
+
dirs = lib_all_top_dirs(lib)
|
|
48
|
+
|
|
49
|
+
if dirs.empty?
|
|
50
|
+
# No lib sub-dirs: single flat app
|
|
51
|
+
app_name = parse_app_name(File.join(repo_root, "mix.exs")) || File.basename(repo_root)
|
|
52
|
+
return [[".", app_name]]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One namespace dir (common case): use it as the single module.
|
|
56
|
+
# Multiple dirs (e.g. Phoenix: ic_daily + ic_daily_web + mix): each becomes its own cluster.
|
|
57
|
+
# rel_dir "lib/<ns>" lets collect_packages scope scans and pkg_module_dir assign correctly.
|
|
58
|
+
dirs.map { |d| ["lib/#{d}", d] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def umbrella?(repo_root)
|
|
62
|
+
content = begin
|
|
63
|
+
File.read(File.join(repo_root, "mix.exs"), encoding: "utf-8")
|
|
64
|
+
rescue StandardError
|
|
65
|
+
""
|
|
66
|
+
end
|
|
67
|
+
content.match?(/\bapps_path\s*:/) || Dir.glob(File.join(repo_root, "apps/*/mix.exs")).any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def umbrella_modules(repo_root)
|
|
71
|
+
Dir.glob(File.join(repo_root, "apps/*/mix.exs")).filter_map do |mixexs|
|
|
72
|
+
app_dir = File.dirname(mixexs)
|
|
73
|
+
rel_dir = app_dir.delete_prefix("#{repo_root}/")
|
|
74
|
+
mod_name = parse_app_name(mixexs) || File.basename(app_dir)
|
|
75
|
+
[rel_dir, mod_name]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_app_name(mixexs_path)
|
|
80
|
+
content = File.read(mixexs_path, encoding: "utf-8")
|
|
81
|
+
content.match(/\bapp:\s*:(\w+)/)[1]
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def lib_all_top_dirs(lib_dir)
|
|
87
|
+
return [] unless Dir.exist?(lib_dir)
|
|
88
|
+
|
|
89
|
+
Dir.children(lib_dir).select { |e| File.directory?(File.join(lib_dir, e)) }.sort
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ── Package collection ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def collect_packages(repo_root, modules, _prefix)
|
|
95
|
+
known_prefixes = modules.map { |_, mod_name| mod_name }
|
|
96
|
+
all_pkgs = {}
|
|
97
|
+
|
|
98
|
+
modules.each do |rel_dir, mod_name|
|
|
99
|
+
mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
|
|
100
|
+
lib_dir = Dir.exist?(File.join(mod_dir, "lib")) ? File.join(mod_dir, "lib") : mod_dir
|
|
101
|
+
|
|
102
|
+
scan_lib_dir(lib_dir, mod_name, known_prefixes, all_pkgs)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Drop deps that don't correspond to any scanned package: this removes references to
|
|
106
|
+
# framework modules whose namespace collides with an application namespace (e.g.
|
|
107
|
+
# Mix.Task from the stdlib when the app also has a lib/mix/ directory).
|
|
108
|
+
pkg_set = all_pkgs.keys.to_set
|
|
109
|
+
all_pkgs.each_value { |deps| deps.select! { |d| pkg_set.include?(d) } }
|
|
110
|
+
|
|
111
|
+
all_pkgs
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def scan_lib_dir(lib_dir, mod_name, known_prefixes, all_pkgs)
|
|
115
|
+
safe_glob(File.join(lib_dir, "**", "*.ex")).each do |ex_file|
|
|
116
|
+
rel_parts = ex_file.delete_prefix("#{lib_dir}/").split("/")
|
|
117
|
+
next if rel_parts.any? { |p| SKIP_DIRS.include?(p) }
|
|
118
|
+
|
|
119
|
+
pkg = cap_depth(file_to_pkg(ex_file, lib_dir, mod_name), mod_name)
|
|
120
|
+
all_pkgs[pkg] ||= []
|
|
121
|
+
|
|
122
|
+
extract_deps(ex_file, known_prefixes).each do |dep|
|
|
123
|
+
dep = cap_depth(dep, mod_name)
|
|
124
|
+
next if dep == pkg || all_pkgs[pkg].include?(dep)
|
|
125
|
+
|
|
126
|
+
all_pkgs[pkg] << dep
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ── Import extraction ─────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def extract_deps(ex_file, known_prefixes)
|
|
134
|
+
content = File.read(ex_file, encoding: "utf-8")
|
|
135
|
+
raw_modules = []
|
|
136
|
+
|
|
137
|
+
content.each_line do |line|
|
|
138
|
+
line.scan(ALIAS_RE) { |(m)| raw_modules.concat(expand_alias(m)) }
|
|
139
|
+
line.scan(IMPORT_RE) { |(m)| raw_modules << m }
|
|
140
|
+
line.scan(USE_RE) { |(m)| raw_modules << m }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
raw_modules.filter_map { |m| resolve_dep(m, known_prefixes) }.uniq
|
|
144
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Expand multi-alias: MyApp.Web.{Router, Controller} → [MyApp.Web.Router, MyApp.Web.Controller]
|
|
149
|
+
def expand_alias(raw)
|
|
150
|
+
m = raw.match(/\A([\w.]+)\.\{([^}]+)\}/)
|
|
151
|
+
unless m
|
|
152
|
+
# Strip ", as: Alias" suffix if present
|
|
153
|
+
return [raw.split(",").first.strip]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
prefix = m[1]
|
|
157
|
+
m[2].split(",").map { |n| "#{prefix}.#{n.strip}" }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def resolve_dep(mod_str, known_prefixes)
|
|
161
|
+
path = module_to_path(mod_str)
|
|
162
|
+
return nil unless known_prefixes.any? { |pfx| path == pfx || path.start_with?("#{pfx}/") }
|
|
163
|
+
|
|
164
|
+
path
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# CamelCase Elixir module name → snake_case/slash path
|
|
168
|
+
# MyApp.Web.Controller → my_app/web/controller
|
|
169
|
+
def module_to_path(mod_name)
|
|
170
|
+
mod_name.split(".").map do |part|
|
|
171
|
+
part.gsub(/(?<=[a-z0-9])([A-Z])/, '_\1').downcase
|
|
172
|
+
end.join("/")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ── Package path helpers ──────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
# Maps a file to a package path. Prepends mod_name when lib_dir is the namespace dir
|
|
178
|
+
# itself (rel_dir = "lib/<ns>") so that accounts.ex → my_app/accounts, not just accounts.
|
|
179
|
+
# When lib_dir is the parent lib/ directory (rel_dir = "."), the rel path already carries
|
|
180
|
+
# the namespace prefix and is returned as-is.
|
|
181
|
+
def file_to_pkg(abs_path, lib_dir, mod_name)
|
|
182
|
+
rel = abs_path.delete_prefix("#{lib_dir}/").delete_suffix(File.extname(abs_path))
|
|
183
|
+
return mod_name if rel == mod_name
|
|
184
|
+
return rel if rel.start_with?("#{mod_name}/")
|
|
185
|
+
|
|
186
|
+
"#{mod_name}/#{rel}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def cap_depth(pkg, mod_name)
|
|
190
|
+
return pkg if pkg != mod_name && !pkg.start_with?("#{mod_name}/")
|
|
191
|
+
|
|
192
|
+
suffix = pkg.delete_prefix("#{mod_name}/")
|
|
193
|
+
return mod_name if suffix == pkg
|
|
194
|
+
|
|
195
|
+
parts = suffix.split("/")
|
|
196
|
+
return pkg if parts.length <= MAX_PKG_DEPTH - 1
|
|
197
|
+
|
|
198
|
+
"#{mod_name}/#{parts.first(MAX_PKG_DEPTH - 1).join("/")}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
Archsight::Import::Registry.register("elixir-grapher", Archsight::Import::Handlers::ElixirGrapher)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "find"
|
|
5
|
+
require_relative "grapher"
|
|
6
|
+
require_relative "../registry"
|
|
7
|
+
|
|
8
|
+
# GoGrapher handler - analyses a Go repository and generates a GraphViz DOT
|
|
9
|
+
# graph of its module/package structure, stored as architecture/modules on
|
|
10
|
+
# the TechnologyArtifact so it can be rendered in the frontend.
|
|
11
|
+
#
|
|
12
|
+
# Configuration:
|
|
13
|
+
# import/config/path - Path to the Go repository root (go.mod or go.work)
|
|
14
|
+
# import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
|
|
15
|
+
# import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
|
|
16
|
+
class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grapher
|
|
17
|
+
def self.language_name = "go"
|
|
18
|
+
|
|
19
|
+
def self.applicable?(path)
|
|
20
|
+
File.exist?(File.join(path, "go.work")) ||
|
|
21
|
+
File.exist?(File.join(path, "go.mod")) ||
|
|
22
|
+
Dir.glob(File.join(path, "**/go.mod")).any?
|
|
23
|
+
rescue Errno::ELOOP, Errno::ENOTDIR
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def show_root_package_node?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def suppress_edge_to?(dep, pkg_set, has_children)
|
|
34
|
+
has_children.include?(dep) && !pkg_set.include?(dep)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# ── Module discovery ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def discover_modules(repo_root)
|
|
40
|
+
work_file = File.join(repo_root, "go.work")
|
|
41
|
+
modules = []
|
|
42
|
+
|
|
43
|
+
if File.exist?(work_file)
|
|
44
|
+
content = File.read(work_file)
|
|
45
|
+
dirs = if (block_match = content.match(/\buse\s*\((.*?)\)/m))
|
|
46
|
+
block_match[1].split.map(&:strip).reject(&:empty?)
|
|
47
|
+
else
|
|
48
|
+
content.scan(/\buse\s+(\S+)/).flatten
|
|
49
|
+
end
|
|
50
|
+
dirs.each do |d|
|
|
51
|
+
rel = d == "." ? "" : d.delete_prefix("./")
|
|
52
|
+
abs_dir = rel.empty? ? repo_root : File.join(repo_root, rel)
|
|
53
|
+
mod_name = read_module_name(abs_dir)
|
|
54
|
+
modules << [rel.empty? ? "." : rel, mod_name] if mod_name
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
mod_name = read_module_name(repo_root)
|
|
58
|
+
if mod_name
|
|
59
|
+
modules << [".", mod_name]
|
|
60
|
+
else
|
|
61
|
+
# No root go.mod: scan subdirectories for go.mod files (monorepo without go.work)
|
|
62
|
+
Find.find(repo_root) do |path|
|
|
63
|
+
bn = File.basename(path)
|
|
64
|
+
Find.prune if File.directory?(path) && %w[vendor testdata .git node_modules].include?(bn)
|
|
65
|
+
next unless bn == "go.mod"
|
|
66
|
+
|
|
67
|
+
mod_dir = File.dirname(path)
|
|
68
|
+
rel = mod_dir.delete_prefix("#{repo_root}/")
|
|
69
|
+
name = read_module_name(mod_dir)
|
|
70
|
+
modules << [rel, name] if name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
modules
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_module_name(mod_dir)
|
|
79
|
+
gomod = File.join(mod_dir, "go.mod")
|
|
80
|
+
return nil unless File.exist?(gomod)
|
|
81
|
+
|
|
82
|
+
File.foreach(gomod) do |line|
|
|
83
|
+
m = line.match(/^\s*module\s+(\S+)/)
|
|
84
|
+
return m[1] if m
|
|
85
|
+
end
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Package collection ────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def collect_packages(repo_root, modules, _prefix)
|
|
92
|
+
workspace_mode = File.exist?(File.join(repo_root, "go.work"))
|
|
93
|
+
mod_names = modules.map { |_, mod_name| mod_name }
|
|
94
|
+
all_pkgs = {}
|
|
95
|
+
|
|
96
|
+
modules.each_key do |rel_dir|
|
|
97
|
+
mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
|
|
98
|
+
cmd = ["go", "list", "-e", "-f", "{{.ImportPath}}|||{{join .Imports \" \"}}", "./..."]
|
|
99
|
+
cmd.insert(2, "-mod=vendor") if File.directory?(File.join(mod_dir, "vendor"))
|
|
100
|
+
cmd.insert(2, "-mod=readonly") unless workspace_mode || cmd.include?("-mod=vendor")
|
|
101
|
+
out, err, status = Open3.capture3(*cmd, chdir: mod_dir)
|
|
102
|
+
|
|
103
|
+
unless status.success?
|
|
104
|
+
progress.warn("Skipping #{rel_dir}: #{err.lines.first.to_s.strip}")
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
out.each_line do |line|
|
|
109
|
+
next unless line.include?("|||")
|
|
110
|
+
|
|
111
|
+
pkg, _, imports_str = line.partition("|||")
|
|
112
|
+
pkg = pkg.strip
|
|
113
|
+
next if pkg.include?("testdata") || pkg.start_with?("_")
|
|
114
|
+
next unless mod_names.any? { |m| pkg == m || pkg.start_with?("#{m}/") }
|
|
115
|
+
|
|
116
|
+
internal_imports = imports_str.split.reject { |i| i.include?("testdata") }.select do |i|
|
|
117
|
+
mod_names.any? { |m| i == m || i.start_with?("#{m}/") }
|
|
118
|
+
end
|
|
119
|
+
all_pkgs[pkg] = internal_imports
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
all_pkgs
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Archsight::Import::Registry.register("go-grapher", Archsight::Import::Handlers::GoGrapher)
|