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.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/lib/archsight/cli.rb +67 -0
  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_grapher.rb +127 -0
  9. data/lib/archsight/import/handlers/grapher.rb +552 -0
  10. data/lib/archsight/import/handlers/java_grapher.rb +286 -0
  11. data/lib/archsight/import/handlers/javascript_grapher.rb +340 -0
  12. data/lib/archsight/import/handlers/python_grapher.rb +270 -0
  13. data/lib/archsight/import/handlers/repository.rb +41 -17
  14. data/lib/archsight/import/handlers/ruby_grapher.rb +203 -0
  15. data/lib/archsight/import/handlers/rust_grapher.rb +227 -0
  16. data/lib/archsight/import/registry.rb +23 -0
  17. data/lib/archsight/resources/import.rb +1 -0
  18. data/lib/archsight/resources/technology_artifact.rb +17 -0
  19. data/lib/archsight/version.rb +1 -1
  20. data/lib/archsight/web/api/json_helpers.rb +1 -1
  21. data/lib/archsight/web/public/vue/ApiDocsPage-C0y953v0.css +1 -0
  22. data/lib/archsight/web/public/vue/ApiDocsPage-DHSCaHEn.js +1 -0
  23. data/lib/archsight/web/public/vue/DocPage-DszOPlFy.js +1 -0
  24. data/lib/archsight/web/public/vue/EditorPage-CPZ0Ei4l.css +1 -0
  25. data/lib/archsight/web/public/vue/EditorPage-DsiuZ7fg.js +35 -0
  26. data/lib/archsight/web/public/vue/ErrorPage-C4JutrYc.js +2 -0
  27. data/lib/archsight/web/public/vue/ErrorPage-uMDnfY5_.css +1 -0
  28. data/lib/archsight/web/public/vue/GraphView-Bqlbt6dK.js +1 -0
  29. data/lib/archsight/web/public/vue/GraphView-Cj2V2stN.css +1 -0
  30. data/lib/archsight/web/public/vue/InstanceRouter-D8SEY2eu.js +2 -0
  31. data/lib/archsight/web/public/vue/InstanceRouter-D9hclKFt.css +1 -0
  32. data/lib/archsight/web/public/vue/KindList-CPDaNron.js +1 -0
  33. data/lib/archsight/web/public/vue/ResourceList-B5w9yiyS.js +1 -0
  34. data/lib/archsight/web/public/vue/ResourceList-DxZfNbOg.css +1 -0
  35. data/lib/archsight/web/public/vue/SearchResults-DSHpVO-c.css +1 -0
  36. data/lib/archsight/web/public/vue/SearchResults-FpkhdBFu.js +1 -0
  37. data/lib/archsight/web/public/vue/architecture-7EHR7CIX-DpNNjAIc.js +1 -0
  38. data/lib/archsight/web/public/vue/eventmodeling-FCH6USID-CiThxoWl.js +1 -0
  39. data/lib/archsight/web/public/vue/gitGraph-WXDBUCRP-BODMGpAm.js +1 -0
  40. data/lib/archsight/web/public/vue/graphviz-09t3o0af.js +13 -0
  41. data/lib/archsight/web/public/vue/index-BW0IzY6X.css +1 -0
  42. data/lib/archsight/web/public/vue/index-T1YqCmM1.js +2 -0
  43. data/lib/archsight/web/public/vue/info-J43DQDTF-fLq04sri.js +1 -0
  44. data/lib/archsight/web/public/vue/katex-5qHlIbPR.js +261 -0
  45. data/lib/archsight/web/public/vue/mermaid-DYyHQk7x.js +3093 -0
  46. data/lib/archsight/web/public/vue/packet-YPE3B663-DoY1fbqu.js +1 -0
  47. data/lib/archsight/web/public/vue/pie-LRSECV5Y-C7ZQVwRe.js +1 -0
  48. data/lib/archsight/web/public/vue/radar-GUYGQ44K-CRtY5oqf.js +1 -0
  49. data/lib/archsight/web/public/vue/rolldown-runtime-QTnfLwEv.js +1 -0
  50. data/lib/archsight/web/public/vue/treeView-BLDUP644-Csx2WLLh.js +1 -0
  51. data/lib/archsight/web/public/vue/treemap-LRROVOQU-CfEnRbTx.js +1 -0
  52. data/lib/archsight/web/public/vue/{useGraphviz-C5lv_BWF.js → useGraphviz-EKSrE4q_.js} +5 -4
  53. data/lib/archsight/web/public/vue/useHighlight-BcVbGyrK.js +10 -0
  54. data/lib/archsight/web/public/vue/useMermaid-CIZxhy_r.js +2 -0
  55. data/lib/archsight/web/public/vue/usePanZoom-C2slpyY9.js +11 -0
  56. data/lib/archsight/web/public/vue/wardley-L42UT6IY-97oUvxhz.js +1 -0
  57. data/lib/archsight/web/public/vue.html +4 -3
  58. metadata +51 -72
  59. data/lib/archsight/web/public/vue/ApiDocsPage-DHOFUCYc.js +0 -1
  60. data/lib/archsight/web/public/vue/ApiDocsPage-DhNTOH4o.css +0 -1
  61. data/lib/archsight/web/public/vue/DocPage-CV66qgTr.js +0 -1
  62. data/lib/archsight/web/public/vue/EditorPage-Dq0MuTnp.css +0 -1
  63. data/lib/archsight/web/public/vue/EditorPage-KqBivY-B.js +0 -34
  64. data/lib/archsight/web/public/vue/ErrorPage-CwPT3JUr.css +0 -1
  65. data/lib/archsight/web/public/vue/ErrorPage-DcbC8Kf1.js +0 -2
  66. data/lib/archsight/web/public/vue/GraphView-Bg_l-F-Q.js +0 -1
  67. data/lib/archsight/web/public/vue/GraphView-DRcIqAiR.css +0 -1
  68. data/lib/archsight/web/public/vue/InstanceRouter-4VEtZM7n.css +0 -1
  69. data/lib/archsight/web/public/vue/InstanceRouter-D-twdyZY.js +0 -2
  70. data/lib/archsight/web/public/vue/KindList-CPImTKNb.js +0 -1
  71. data/lib/archsight/web/public/vue/ResourceList-DMxm0cGh.js +0 -1
  72. data/lib/archsight/web/public/vue/ResourceList-DP-z-j71.css +0 -1
  73. data/lib/archsight/web/public/vue/SearchResults-BGHbg48-.css +0 -1
  74. data/lib/archsight/web/public/vue/SearchResults-BqUHEWHE.js +0 -1
  75. data/lib/archsight/web/public/vue/_baseUniq-BjkdEi26.js +0 -1
  76. data/lib/archsight/web/public/vue/architectureDiagram-VXUJARFQ-JN7CxdtP.js +0 -36
  77. data/lib/archsight/web/public/vue/blockDiagram-VD42YOAC-teziPFHX.js +0 -122
  78. data/lib/archsight/web/public/vue/c4Diagram-YG6GDRKO-CVNrzCCc.js +0 -10
  79. data/lib/archsight/web/public/vue/chunk-4BX2VUAB-BQUARyyA.js +0 -1
  80. data/lib/archsight/web/public/vue/chunk-55IACEB6-BgJn0Waa.js +0 -1
  81. data/lib/archsight/web/public/vue/chunk-B4BG7PRW-0ghAfB1t.js +0 -165
  82. data/lib/archsight/web/public/vue/chunk-DI55MBZ5-HJo1DW2B.js +0 -220
  83. data/lib/archsight/web/public/vue/chunk-FMBD7UC4-C1GwGKgX.js +0 -15
  84. data/lib/archsight/web/public/vue/chunk-QN33PNHL-BOoA1KfJ.js +0 -1
  85. data/lib/archsight/web/public/vue/chunk-QZHKN3VN-BcTNH3IX.js +0 -1
  86. data/lib/archsight/web/public/vue/chunk-TZMSLE5B-58bQF3J5.js +0 -1
  87. data/lib/archsight/web/public/vue/classDiagram-2ON5EDUG-DAA8tpbN.js +0 -1
  88. data/lib/archsight/web/public/vue/classDiagram-v2-WZHVMYZB-DAA8tpbN.js +0 -1
  89. data/lib/archsight/web/public/vue/clone-BPcOyh7U.js +0 -1
  90. data/lib/archsight/web/public/vue/cose-bilkent-S5V4N54A-Bv8iR4rF.js +0 -1
  91. data/lib/archsight/web/public/vue/cytoscape.esm-5J0xJHOV.js +0 -321
  92. data/lib/archsight/web/public/vue/dagre-6UL2VRFP-B8ZPRhgU.js +0 -4
  93. data/lib/archsight/web/public/vue/diagram-PSM6KHXK-Dp1bZnNq.js +0 -24
  94. data/lib/archsight/web/public/vue/diagram-QEK2KX5R-DZBsDWP6.js +0 -43
  95. data/lib/archsight/web/public/vue/diagram-S2PKOQOG-BKOnLWNk.js +0 -24
  96. data/lib/archsight/web/public/vue/erDiagram-Q2GNP2WA-V6FqHPc9.js +0 -60
  97. data/lib/archsight/web/public/vue/flowDiagram-NV44I4VS-BipXjPVT.js +0 -162
  98. data/lib/archsight/web/public/vue/ganttDiagram-JELNMOA3-DUdVPVK1.js +0 -267
  99. data/lib/archsight/web/public/vue/gitGraphDiagram-V2S2FVAM-Bcf_apTG.js +0 -65
  100. data/lib/archsight/web/public/vue/graph-C4e9XILZ.js +0 -1
  101. data/lib/archsight/web/public/vue/graphviz-CJms5bxZ.js +0 -13
  102. data/lib/archsight/web/public/vue/index-BUI400cn.js +0 -2
  103. data/lib/archsight/web/public/vue/index-Tiu4C-Sb.css +0 -1
  104. data/lib/archsight/web/public/vue/infoDiagram-HS3SLOUP-DBwnExXO.js +0 -2
  105. data/lib/archsight/web/public/vue/journeyDiagram-XKPGCS4Q-D4b8PEzB.js +0 -139
  106. data/lib/archsight/web/public/vue/kanban-definition-3W4ZIXB7-R6r4NPxJ.js +0 -89
  107. data/lib/archsight/web/public/vue/katex-C-M49wc6.js +0 -261
  108. data/lib/archsight/web/public/vue/layout-CXL8NrCi.js +0 -1
  109. data/lib/archsight/web/public/vue/mermaid-MmHzOPPB.js +0 -250
  110. data/lib/archsight/web/public/vue/min-B4MPxNTL.js +0 -1
  111. data/lib/archsight/web/public/vue/mindmap-definition-VGOIOE7T-BypPT67y.js +0 -68
  112. data/lib/archsight/web/public/vue/pieDiagram-ADFJNKIX-BJ4nb15u.js +0 -30
  113. data/lib/archsight/web/public/vue/quadrantDiagram-AYHSOK5B-D4Ee5XRs.js +0 -7
  114. data/lib/archsight/web/public/vue/requirementDiagram-UZGBJVZJ-DuxpUmt2.js +0 -64
  115. data/lib/archsight/web/public/vue/sankeyDiagram-TZEHDZUN-CgWlWmza.js +0 -10
  116. data/lib/archsight/web/public/vue/sequenceDiagram-WL72ISMW-v2l2CbI0.js +0 -145
  117. data/lib/archsight/web/public/vue/stateDiagram-FKZM4ZOC-B5ROi2_1.js +0 -1
  118. data/lib/archsight/web/public/vue/stateDiagram-v2-4FDKWEC3-CQhha5H7.js +0 -1
  119. data/lib/archsight/web/public/vue/timeline-definition-IT6M3QCI-Bnx2HvxP.js +0 -61
  120. data/lib/archsight/web/public/vue/treemap-GDKQZRPO-BVho_qBy.js +0 -162
  121. data/lib/archsight/web/public/vue/useHighlight-yg_u-WUA.js +0 -10
  122. data/lib/archsight/web/public/vue/useMermaid-BPBwn39g.js +0 -1
  123. data/lib/archsight/web/public/vue/usePanZoom-CgSmFLId.js +0 -11
  124. 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)