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,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