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.
- checksums.yaml +4 -4
- data/lib/archsight/cli.rb +75 -2
- 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_dep_resolver.rb +84 -0
- data/lib/archsight/import/handlers/go_grapher.rb +177 -0
- data/lib/archsight/import/handlers/go_module_parser.rb +111 -0
- data/lib/archsight/import/handlers/grapher.rb +565 -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 +51 -19
- data/lib/archsight/import/handlers/rest_api.rb +8 -1
- 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/application_component.rb +1 -0
- data/lib/archsight/resources/import.rb +4 -0
- data/lib/archsight/resources/technology_artifact.rb +18 -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 +53 -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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "../handler"
|
|
5
|
+
require_relative "go_module_parser"
|
|
6
|
+
require_relative "../registry"
|
|
7
|
+
|
|
8
|
+
# GoDepResolver handler - second-pass resolver that patches dependsOn relations
|
|
9
|
+
# onto ApplicationComponents generated by GoGrapher.
|
|
10
|
+
#
|
|
11
|
+
# Runs after all GoGraphers complete (iteration 2 of the executor loop) so the
|
|
12
|
+
# database contains the full set of freshly-generated ApplicationComponents.
|
|
13
|
+
# Output goes to a single shared generated/relationships.yaml file (all repos
|
|
14
|
+
# combined), loaded after modules.yaml alphabetically so its version of each
|
|
15
|
+
# component — with dependsOn — takes precedence.
|
|
16
|
+
#
|
|
17
|
+
# One Import:GoDepResolver:* resource is emitted by each GoGrapher run; this
|
|
18
|
+
# handler is registered as a child of GoGrapher via the generates relation so
|
|
19
|
+
# the executor knows to wait until GoGrapher has completed.
|
|
20
|
+
#
|
|
21
|
+
# Configuration:
|
|
22
|
+
# import/config/path - Path to the Go repository root (same as GoGrapher)
|
|
23
|
+
class Archsight::Import::Handlers::GoDepResolver < Archsight::Import::Handler
|
|
24
|
+
include Archsight::Import::Handlers::GoModuleParser
|
|
25
|
+
|
|
26
|
+
def execute
|
|
27
|
+
@path = config("path")
|
|
28
|
+
raise "Missing required config: path" unless @path
|
|
29
|
+
raise "Directory not found: #{@path}" unless File.directory?(@path)
|
|
30
|
+
|
|
31
|
+
import_resource.annotations["import/outputPath"] ||= "generated/relationships.yaml"
|
|
32
|
+
|
|
33
|
+
modules = discover_modules(@path)
|
|
34
|
+
return write_yaml(YAML.dump(self_marker)) if modules.empty?
|
|
35
|
+
|
|
36
|
+
existing_components = database&.instances_by_kind("ApplicationComponent") || {}
|
|
37
|
+
output = +""
|
|
38
|
+
|
|
39
|
+
modules.each do |rel_dir, mod_name|
|
|
40
|
+
mod_dir = rel_dir == "." ? @path : File.join(@path, rel_dir)
|
|
41
|
+
dep_names = compute_deps(mod_dir, mod_name, existing_components)
|
|
42
|
+
next if dep_names.empty?
|
|
43
|
+
|
|
44
|
+
comp_name = component_name(mod_name)
|
|
45
|
+
existing = existing_components[comp_name]
|
|
46
|
+
next unless existing
|
|
47
|
+
|
|
48
|
+
# Rebuild the full ApplicationComponent merging dependsOn into the existing
|
|
49
|
+
# spec (which carries realizedThrough, exposes, etc. from GoGrapher).
|
|
50
|
+
spec = existing.raw.fetch("spec", {}).dup
|
|
51
|
+
spec["dependsOn"] = { "applicationComponents" => dep_names }
|
|
52
|
+
|
|
53
|
+
component = {
|
|
54
|
+
"apiVersion" => "architecture/v1alpha1",
|
|
55
|
+
"kind" => "ApplicationComponent",
|
|
56
|
+
"metadata" => {
|
|
57
|
+
"name" => comp_name,
|
|
58
|
+
"annotations" => existing.annotations.merge(
|
|
59
|
+
"generated/script" => import_resource.name,
|
|
60
|
+
"generated/at" => Time.now.utc.iso8601
|
|
61
|
+
)
|
|
62
|
+
},
|
|
63
|
+
"spec" => spec
|
|
64
|
+
}
|
|
65
|
+
output << YAML.dump(component)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
write_yaml(output + YAML.dump(self_marker))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def compute_deps(mod_dir, mod_name, existing_components)
|
|
74
|
+
prefix = same_origin_prefix(mod_name)
|
|
75
|
+
return [] unless prefix
|
|
76
|
+
|
|
77
|
+
go_mod_requires(mod_dir)
|
|
78
|
+
.select { |r| r.start_with?(prefix) && r != mod_name }
|
|
79
|
+
.filter_map { |r| component_name(r) if existing_components.key?(component_name(r)) }
|
|
80
|
+
.sort.uniq
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Archsight::Import::Registry.register("go-dep-resolver", Archsight::Import::Handlers::GoDepResolver)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require_relative "grapher"
|
|
6
|
+
require_relative "go_module_parser"
|
|
7
|
+
require_relative "../registry"
|
|
8
|
+
|
|
9
|
+
# GoGrapher handler - analyses a Go repository and generates a GraphViz DOT
|
|
10
|
+
# graph of its module/package structure, stored as architecture/go/modules on
|
|
11
|
+
# the TechnologyArtifact so it can be rendered in the frontend.
|
|
12
|
+
#
|
|
13
|
+
# Also emits one ApplicationComponent per go.mod module, linked to the repo
|
|
14
|
+
# artifact via realizedThrough. If OpenAPI spec files are found in a module
|
|
15
|
+
# directory, the component gains an exposes relation to the matching interface.
|
|
16
|
+
#
|
|
17
|
+
# dependsOn relations between components are resolved in a second pass by the
|
|
18
|
+
# GoDepResolver handler, which runs after all graphers have seeded the database.
|
|
19
|
+
#
|
|
20
|
+
# Configuration:
|
|
21
|
+
# import/config/path - Path to the Go repository root (go.mod or go.work)
|
|
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
|
+
# import/config/interface_visibility - Visibility prefix for detected interfaces (default: Private)
|
|
25
|
+
class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grapher
|
|
26
|
+
include Archsight::Import::Handlers::GoModuleParser
|
|
27
|
+
|
|
28
|
+
OPENAPI_FILENAMES = %w[openapi.yaml openapi.yml openapi.json swagger.yaml swagger.yml swagger.json].freeze
|
|
29
|
+
OPENAPI_SUBDIRS = %w[api docs spec].freeze
|
|
30
|
+
|
|
31
|
+
def self.language_name = "go"
|
|
32
|
+
|
|
33
|
+
def self.applicable?(path)
|
|
34
|
+
File.exist?(File.join(path, "go.work")) ||
|
|
35
|
+
File.exist?(File.join(path, "go.mod")) ||
|
|
36
|
+
Dir.glob(File.join(path, "**/go.mod")).any?
|
|
37
|
+
rescue Errno::ELOOP, Errno::ENOTDIR
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def show_root_package_node?
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def suppress_edge_to?(dep, pkg_set, has_children)
|
|
48
|
+
has_children.include?(dep) && !pkg_set.include?(dep)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ── Application resources ─────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def additional_resources(path, modules, artifact_name)
|
|
54
|
+
visibility = config("interface_visibility", default: "Private")
|
|
55
|
+
output = +""
|
|
56
|
+
|
|
57
|
+
modules.each do |rel_dir, mod_name|
|
|
58
|
+
mod_dir = rel_dir == "." ? path : File.join(path, rel_dir)
|
|
59
|
+
specs = detect_openapi_specs(mod_dir)
|
|
60
|
+
existing_interfaces = database&.instances_by_kind("ApplicationInterface") || {}
|
|
61
|
+
interface_names = specs.filter_map { |s| interface_name_from_spec(s, visibility: visibility) }
|
|
62
|
+
.select { |n| existing_interfaces.key?(n) }
|
|
63
|
+
|
|
64
|
+
comp_spec = { "realizedThrough" => { "technologyArtifacts" => [artifact_name] } }
|
|
65
|
+
comp_spec["exposes"] = { "applicationInterfaces" => interface_names } if interface_names.any?
|
|
66
|
+
|
|
67
|
+
# Build directly without resource_yaml so the component is not tracked in
|
|
68
|
+
# the generates spec — ApplicationComponents are seed resources the user
|
|
69
|
+
# is expected to keep and refine, not auto-regenerated on each run.
|
|
70
|
+
component = untracked_resource_yaml(
|
|
71
|
+
kind: "ApplicationComponent",
|
|
72
|
+
name: component_name(mod_name),
|
|
73
|
+
spec: comp_spec
|
|
74
|
+
)
|
|
75
|
+
output << YAML.dump(component)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Emit a GoDepResolver import so dependsOn relations can be resolved in a
|
|
79
|
+
# second pass, after all graphers have run and the database has been reloaded
|
|
80
|
+
# with the full set of ApplicationComponents.
|
|
81
|
+
resolver_name = "Import:GoDepResolver:#{artifact_name.delete_prefix("Repo:")}"
|
|
82
|
+
output << YAML.dump(import_yaml(
|
|
83
|
+
name: resolver_name,
|
|
84
|
+
handler: "go-dep-resolver",
|
|
85
|
+
config: { "path" => path }
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
output
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── Package collection ────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def collect_packages(repo_root, modules, _prefix)
|
|
94
|
+
workspace_mode = File.exist?(File.join(repo_root, "go.work"))
|
|
95
|
+
mod_names = modules.map { |_, mod_name| mod_name }
|
|
96
|
+
all_pkgs = {}
|
|
97
|
+
|
|
98
|
+
modules.map(&:first).each do |rel_dir|
|
|
99
|
+
mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
|
|
100
|
+
cmd = ["go", "list", "-e", "-f", "{{.ImportPath}}|||{{join .Imports \" \"}}", "./..."]
|
|
101
|
+
cmd.insert(2, "-mod=vendor") if File.directory?(File.join(mod_dir, "vendor"))
|
|
102
|
+
cmd.insert(2, "-mod=readonly") unless workspace_mode || cmd.include?("-mod=vendor")
|
|
103
|
+
out, err, status = Open3.capture3(*cmd, chdir: mod_dir)
|
|
104
|
+
|
|
105
|
+
unless status.success?
|
|
106
|
+
progress.warn("Skipping #{rel_dir}: #{err.lines.first.to_s.strip}")
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
out.each_line do |line|
|
|
111
|
+
next unless line.include?("|||")
|
|
112
|
+
|
|
113
|
+
pkg, _, imports_str = line.partition("|||")
|
|
114
|
+
pkg = pkg.strip
|
|
115
|
+
next if pkg.include?("testdata") || pkg.start_with?("_")
|
|
116
|
+
next unless mod_names.any? { |m| pkg == m || pkg.start_with?("#{m}/") }
|
|
117
|
+
|
|
118
|
+
internal_imports = imports_str.split.reject { |i| i.include?("testdata") }.select do |i|
|
|
119
|
+
mod_names.any? { |m| i == m || i.start_with?("#{m}/") }
|
|
120
|
+
end
|
|
121
|
+
all_pkgs[pkg] = internal_imports
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
all_pkgs
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── OpenAPI detection ─────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def untracked_resource_yaml(kind:, name:, spec: {})
|
|
131
|
+
{
|
|
132
|
+
"apiVersion" => "architecture/v1alpha1",
|
|
133
|
+
"kind" => kind,
|
|
134
|
+
"metadata" => {
|
|
135
|
+
"name" => name,
|
|
136
|
+
"annotations" => {
|
|
137
|
+
"generated/script" => import_resource.name,
|
|
138
|
+
"generated/at" => Time.now.utc.iso8601
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"spec" => spec
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def detect_openapi_specs(mod_dir)
|
|
146
|
+
candidates = OPENAPI_FILENAMES.map { |f| File.join(mod_dir, f) }
|
|
147
|
+
OPENAPI_SUBDIRS.each do |sub|
|
|
148
|
+
OPENAPI_FILENAMES.each { |f| candidates << File.join(mod_dir, sub, f) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
candidates.filter_map do |path|
|
|
152
|
+
next unless File.file?(path)
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
content = File.read(path)
|
|
156
|
+
doc = path.end_with?(".json") ? JSON.parse(content) : YAML.safe_load(content)
|
|
157
|
+
doc.is_a?(Hash) ? doc : nil
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def interface_name_from_spec(spec, visibility: "Private")
|
|
165
|
+
info = spec["info"] || {}
|
|
166
|
+
title = (info["title"] || "").strip
|
|
167
|
+
return nil if title.empty?
|
|
168
|
+
|
|
169
|
+
version = (info["version"] || "1.0").to_s
|
|
170
|
+
api_name = title.split(/[\s\-_]/).map(&:capitalize).join
|
|
171
|
+
version_str = version.split(".").first.to_s.sub(/^v/i, "")
|
|
172
|
+
vis = visibility.split("-").map(&:capitalize).join
|
|
173
|
+
"#{vis}:#{api_name}:v#{version_str}:RestAPI"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
Archsight::Import::Registry.register("go-grapher", Archsight::Import::Handlers::GoGrapher)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
|
|
5
|
+
# Shared Go module parsing utilities included by GoGrapher and GoDepResolver.
|
|
6
|
+
#
|
|
7
|
+
# Provides go.mod discovery, module name reading, require parsing, and the
|
|
8
|
+
# component_name convention (strip SCM host, join path segments with ":").
|
|
9
|
+
module Archsight::Import::Handlers::GoModuleParser
|
|
10
|
+
# Discover all Go modules in a repository root.
|
|
11
|
+
# Handles go.work workspaces, single-module repos, and multi-module monorepos
|
|
12
|
+
# (root go.mod plus subdirectory go.mod files without go.work).
|
|
13
|
+
#
|
|
14
|
+
# @param repo_root [String] Absolute path to the repository root
|
|
15
|
+
# @return [Array<Array<String>>] List of [rel_dir, mod_name] pairs
|
|
16
|
+
def discover_modules(repo_root)
|
|
17
|
+
work_file = File.join(repo_root, "go.work")
|
|
18
|
+
modules = []
|
|
19
|
+
|
|
20
|
+
if File.exist?(work_file)
|
|
21
|
+
content = File.read(work_file)
|
|
22
|
+
dirs = if (block_match = content.match(/\buse\s*\((.*?)\)/m))
|
|
23
|
+
block_match[1].split.map(&:strip).reject(&:empty?)
|
|
24
|
+
else
|
|
25
|
+
content.scan(/\buse\s+(\S+)/).flatten
|
|
26
|
+
end
|
|
27
|
+
dirs.each do |d|
|
|
28
|
+
rel = d == "." ? "" : d.delete_prefix("./")
|
|
29
|
+
abs_dir = rel.empty? ? repo_root : File.join(repo_root, rel)
|
|
30
|
+
mod_name = read_module_name(abs_dir)
|
|
31
|
+
modules << [rel.empty? ? "." : rel, mod_name] if mod_name
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
root_mod = read_module_name(repo_root)
|
|
35
|
+
modules << [".", root_mod] if root_mod
|
|
36
|
+
|
|
37
|
+
# Also scan subdirectories for additional go.mod files (multi-module monorepo without go.work)
|
|
38
|
+
Find.find(repo_root) do |path|
|
|
39
|
+
bn = File.basename(path)
|
|
40
|
+
Find.prune if File.directory?(path) && %w[vendor testdata .git node_modules].include?(bn)
|
|
41
|
+
next unless bn == "go.mod"
|
|
42
|
+
|
|
43
|
+
mod_dir = File.dirname(path)
|
|
44
|
+
next if mod_dir == repo_root # already added above
|
|
45
|
+
|
|
46
|
+
rel = mod_dir.delete_prefix("#{repo_root}/")
|
|
47
|
+
name = read_module_name(mod_dir)
|
|
48
|
+
modules << [rel, name] if name
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
modules
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read the module name declared in go.mod.
|
|
56
|
+
# @return [String, nil] Module path or nil if go.mod absent / no module line
|
|
57
|
+
def read_module_name(mod_dir)
|
|
58
|
+
gomod = File.join(mod_dir, "go.mod")
|
|
59
|
+
return nil unless File.exist?(gomod)
|
|
60
|
+
|
|
61
|
+
File.foreach(gomod) do |line|
|
|
62
|
+
m = line.match(/^\s*module\s+(\S+)/)
|
|
63
|
+
return m[1] if m
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Parse require directives from go.mod, handling both block and single-line forms.
|
|
69
|
+
# @return [Array<String>] Unique module paths listed in require directives
|
|
70
|
+
def go_mod_requires(mod_dir)
|
|
71
|
+
gomod = File.join(mod_dir, "go.mod")
|
|
72
|
+
return [] unless File.exist?(gomod)
|
|
73
|
+
|
|
74
|
+
content = File.read(gomod)
|
|
75
|
+
paths = []
|
|
76
|
+
|
|
77
|
+
# Block form: require ( ... )
|
|
78
|
+
content.scan(/\brequire\s*\(([^)]*)\)/m) do |block|
|
|
79
|
+
block[0].each_line do |line|
|
|
80
|
+
stripped = line.strip.split("//").first&.strip
|
|
81
|
+
mod_path = stripped&.split&.first
|
|
82
|
+
paths << mod_path if mod_path && !mod_path.empty?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Single-line form: require module/path vX.Y.Z (strip block forms first to avoid double-counting)
|
|
87
|
+
content.gsub(/\brequire\s*\([^)]*\)/m, "").scan(/^\s*require\s+(\S+)/) do |m|
|
|
88
|
+
paths << m[0]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
paths.uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Return the SCM host+org prefix shared by modules in the same org, e.g. "github.com/ionos-cloud/".
|
|
95
|
+
# @return [String, nil] Prefix with trailing slash, or nil for single-segment names
|
|
96
|
+
def same_origin_prefix(mod_name)
|
|
97
|
+
parts = mod_name.split("/")
|
|
98
|
+
return nil if parts.length < 2
|
|
99
|
+
|
|
100
|
+
"#{parts[0, 2].join("/")}/"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Convert a Go module path to an ApplicationComponent name.
|
|
104
|
+
# Strips the SCM host segment and joins remaining path segments with ":".
|
|
105
|
+
# "github.com/ionos-cloud/event-gateway/pkg" → "ionos-cloud:event-gateway:pkg"
|
|
106
|
+
def component_name(mod_name)
|
|
107
|
+
parts = mod_name.split("/")
|
|
108
|
+
parts.shift
|
|
109
|
+
parts.join(":")
|
|
110
|
+
end
|
|
111
|
+
end
|