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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require_relative "grapher"
5
+ require_relative "../registry"
6
+
7
+ # JavaGrapher handler - analyses a Java repository and generates a GraphViz DOT
8
+ # graph of its package/module structure, stored as architecture/modules on the
9
+ # TechnologyArtifact so it can be rendered in the frontend.
10
+ #
11
+ # Understands Maven (pom.xml) single and multi-module projects as well as
12
+ # Gradle (build.gradle / settings.gradle) layouts. Scanning is pure Ruby —
13
+ # no JDK or build tool installation required.
14
+ #
15
+ # Configuration:
16
+ # import/config/path - Path to the Java repository root
17
+ # import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
18
+ # import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
19
+ class Archsight::Import::Handlers::JavaGrapher < Archsight::Import::Handlers::Grapher
20
+ def self.language_name = "java"
21
+
22
+ def self.applicable?(path)
23
+ File.exist?(File.join(path, "pom.xml")) ||
24
+ File.exist?(File.join(path, "build.gradle")) ||
25
+ File.exist?(File.join(path, "build.gradle.kts")) ||
26
+ Dir.glob(File.join(path, "*/pom.xml")).any?
27
+ end
28
+
29
+ SKIP_DIRS = %w[test tests generated target build .git node_modules .gradle resources].freeze
30
+
31
+ # Cap relative package depth at 2 levels to keep the graph readable.
32
+ # Packages deeper than this are folded into their depth-2 ancestor.
33
+ MAX_PKG_DEPTH = 2
34
+
35
+ private
36
+
37
+ def wrap_single_module?
38
+ true
39
+ end
40
+
41
+ # Always render package nodes: Java packages with direct files should remain
42
+ # visible even when their label matches the enclosing cluster label.
43
+ def show_root_package_node?
44
+ true
45
+ end
46
+
47
+ # Only suppress edges to structural ancestors (paths not in pkg_set).
48
+ # Java packages with both direct files and sub-packages must keep their edges.
49
+ def suppress_edge_to?(dep, pkg_set, has_children)
50
+ has_children.include?(dep) && !pkg_set.include?(dep)
51
+ end
52
+
53
+ # ── Module discovery ─────────────────────────────────────────────────────
54
+
55
+ def discover_modules(repo_root)
56
+ discover_maven_modules(repo_root) ||
57
+ discover_gradle_modules(repo_root) ||
58
+ discover_source_fallback(repo_root) ||
59
+ []
60
+ end
61
+
62
+ def discover_maven_modules(repo_root)
63
+ root_pom = File.join(repo_root, "pom.xml")
64
+ return unless File.exist?(root_pom)
65
+
66
+ sub_dirs = maven_submodule_dirs(root_pom)
67
+ if sub_dirs.any?
68
+ modules = sub_module_list(repo_root, sub_dirs)
69
+ return modules if modules.any?
70
+ end
71
+
72
+ single_module_from_root(repo_root)
73
+ end
74
+
75
+ def discover_gradle_modules(repo_root)
76
+ settings = ["settings.gradle", "settings.gradle.kts"]
77
+ .map { |f| File.join(repo_root, f) }
78
+ .find { |f| File.exist?(f) }
79
+ return unless settings
80
+
81
+ includes = gradle_includes(settings)
82
+ if includes.any?
83
+ modules = sub_module_list(repo_root, includes)
84
+ return modules if modules.any?
85
+ end
86
+
87
+ single_module_from_root(repo_root)
88
+ end
89
+
90
+ def discover_source_fallback(repo_root)
91
+ single_module_from_root(repo_root)
92
+ end
93
+
94
+ def sub_module_list(repo_root, rel_dirs)
95
+ rel_dirs.filter_map do |rel|
96
+ abs = File.join(repo_root, rel)
97
+ next unless File.directory?(abs)
98
+
99
+ src = find_source_dir(abs)
100
+ next unless src
101
+
102
+ pkg_prefix = common_java_prefix(src)
103
+ next unless pkg_prefix
104
+
105
+ [rel, pkg_prefix]
106
+ end
107
+ end
108
+
109
+ def single_module_from_root(repo_root)
110
+ src = find_source_dir(repo_root)
111
+ return unless src
112
+
113
+ pkg_prefix = common_java_prefix(src)
114
+ return unless pkg_prefix
115
+
116
+ [[".", pkg_prefix]]
117
+ end
118
+
119
+ # ── Package collection ────────────────────────────────────────────────────
120
+
121
+ def collect_packages(repo_root, modules, _prefix)
122
+ all_pkgs = {}
123
+ modules.each do |rel_dir, mod_name|
124
+ mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
125
+ src = find_source_dir(mod_dir)
126
+ next unless src
127
+
128
+ scan_java_packages(src, mod_name).each do |pkg, deps|
129
+ all_pkgs[pkg] = (all_pkgs[pkg] || []) + deps
130
+ end
131
+ end
132
+
133
+ # Retain only internal dependencies (drop java.*, org.springframework, etc.)
134
+ all_pkg_set = all_pkgs.keys.to_set
135
+ all_pkgs.transform_values! { |deps| deps.select { |d| all_pkg_set.include?(d) }.uniq }
136
+
137
+ # Add a synthetic "main" node for any detected entry point classes
138
+ entry_pkgs = detect_main_packages(repo_root, modules)
139
+ if entry_pkgs.any?
140
+ all_pkgs["main"] ||= []
141
+ all_pkgs["main"].concat(entry_pkgs.select { |p| all_pkg_set.include?(p) })
142
+ all_pkgs.delete("main") if all_pkgs["main"].empty?
143
+ end
144
+
145
+ all_pkgs
146
+ end
147
+
148
+ # ── Build system helpers ──────────────────────────────────────────────────
149
+
150
+ def maven_submodule_dirs(pom_path)
151
+ content = File.read(pom_path)
152
+ m = content.match(%r{<modules>(.*?)</modules>}m)
153
+ return [] unless m
154
+
155
+ m[1].scan(%r{<module>(.*?)</module>}).flatten.map(&:strip).reject(&:empty?)
156
+ end
157
+
158
+ def gradle_includes(settings_path)
159
+ content = File.read(settings_path)
160
+ content.scan(/include\s*[('":]+([^'"):\s,]+)/).flatten.map do |name|
161
+ name.tr(":", "/").delete_prefix("/")
162
+ end.reject(&:empty?)
163
+ end
164
+
165
+ # ── Source directory helpers ──────────────────────────────────────────────
166
+
167
+ def find_source_dir(mod_dir)
168
+ [
169
+ File.join(mod_dir, "src", "main", "java"),
170
+ File.join(mod_dir, "src", "java"),
171
+ File.join(mod_dir, "src"),
172
+ mod_dir
173
+ ].find { |d| File.directory?(d) }
174
+ end
175
+
176
+ # Returns the common Java package prefix (as a "/"-path) for all .java files
177
+ # under src_dir, or nil if no .java files are found.
178
+ def common_java_prefix(src_dir)
179
+ prefixes = []
180
+ Find.find(src_dir) do |path|
181
+ Find.prune if File.directory?(path) && SKIP_DIRS.include?(File.basename(path))
182
+ next unless path.end_with?(".java")
183
+
184
+ File.foreach(path) do |line|
185
+ if (m = line.match(/^\s*package\s+([\w.]+)\s*;/))
186
+ prefixes << m[1].split(".")
187
+ break
188
+ end
189
+ end
190
+ end
191
+ return nil if prefixes.empty?
192
+
193
+ common = prefixes.first.dup
194
+ prefixes.drop(1).each do |parts|
195
+ common = common.zip(parts).take_while { |a, b| a == b }.map(&:first)
196
+ end
197
+ return nil if common.empty?
198
+
199
+ common.join("/")
200
+ end
201
+
202
+ # Walk src_dir and return { pkg_path => [import_pkg_paths] } for all packages
203
+ # whose package declaration starts with mod_name (using "/" separators).
204
+ # Packages deeper than MAX_PKG_DEPTH relative levels are folded into their
205
+ # depth-capped ancestor to keep the graph readable.
206
+ def scan_java_packages(src_dir, mod_name)
207
+ pkgs = {}
208
+ Find.find(src_dir) do |path|
209
+ if File.directory?(path)
210
+ Find.prune if SKIP_DIRS.include?(File.basename(path))
211
+ next
212
+ end
213
+ next unless path.end_with?(".java")
214
+
215
+ pkg_name = nil
216
+ imports = []
217
+ File.foreach(path) do |line|
218
+ if (m = line.match(/^\s*package\s+([\w.]+)\s*;/))
219
+ pkg_name = cap_depth(m[1].tr(".", "/"), mod_name)
220
+ elsif (m = line.match(/^\s*import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;/))
221
+ imp = strip_class_suffix(m[1].delete_suffix(".*").tr(".", "/"))
222
+ imports << cap_depth(imp, mod_name) unless imp.empty?
223
+ end
224
+ end
225
+
226
+ next unless pkg_name
227
+ next unless pkg_name == mod_name || pkg_name.start_with?("#{mod_name}/")
228
+
229
+ pkgs[pkg_name] ||= []
230
+ pkgs[pkg_name].concat(imports)
231
+ end
232
+
233
+ pkgs.transform_values(&:uniq)
234
+ end
235
+
236
+ # Drop trailing path components that begin with an uppercase letter (class names).
237
+ def strip_class_suffix(slash_path)
238
+ parts = slash_path.split("/")
239
+ parts.pop while parts.last&.match?(/\A[A-Z]/)
240
+ parts.join("/")
241
+ end
242
+
243
+ # Fold a package path deeper than MAX_PKG_DEPTH (from mod_name) into its
244
+ # depth-capped ancestor. Paths not under mod_name are returned unchanged.
245
+ def cap_depth(pkg, mod_name)
246
+ rel = pkg.delete_prefix("#{mod_name}/")
247
+ return pkg if rel == pkg
248
+
249
+ parts = rel.split("/")
250
+ return pkg if parts.length <= MAX_PKG_DEPTH
251
+
252
+ "#{mod_name}/#{parts.first(MAX_PKG_DEPTH).join("/")}"
253
+ end
254
+
255
+ # Scan source directories for Java entry point classes and return their
256
+ # (depth-capped) package paths. Detects: traditional main methods,
257
+ # @SpringBootApplication, and @QuarkusMain.
258
+ def detect_main_packages(repo_root, modules)
259
+ entry_pkgs = []
260
+ modules.each do |rel_dir, mod_name|
261
+ mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
262
+ src = find_source_dir(mod_dir)
263
+ next unless src
264
+
265
+ Find.find(src) do |path|
266
+ if File.directory?(path)
267
+ Find.prune if SKIP_DIRS.include?(File.basename(path))
268
+ next
269
+ end
270
+ next unless path.end_with?(".java")
271
+
272
+ content = File.read(path, encoding: "utf-8", invalid: :replace)
273
+ next unless content.match?(/public\s+static\s+void\s+main\s*\(/) ||
274
+ content.match?(/@SpringBootApplication\b/) ||
275
+ content.match?(/@QuarkusMain\b/)
276
+
277
+ if (m = content.match(/^\s*package\s+([\w.]+)\s*;/))
278
+ entry_pkgs << cap_depth(m[1].tr(".", "/"), mod_name)
279
+ end
280
+ end
281
+ end
282
+ entry_pkgs.uniq
283
+ end
284
+ end
285
+
286
+ Archsight::Import::Registry.register("java-grapher", Archsight::Import::Handlers::JavaGrapher)
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require_relative "grapher"
6
+ require_relative "../registry"
7
+
8
+ # JavaScriptGrapher — analyses a JavaScript or TypeScript repository and generates
9
+ # a GraphViz DOT graph of its package/module structure, stored as
10
+ # architecture/javascript/modules on the TechnologyArtifact.
11
+ #
12
+ # Covers .js, .mjs, .cjs, .ts, .tsx, .jsx (single grapher for both JS and TS).
13
+ #
14
+ # Supported project layouts:
15
+ # - Single package (package.json at root)
16
+ # - NPM / Yarn workspaces ("workspaces" key in root package.json)
17
+ # - PNPM workspaces (pnpm-workspace.yaml)
18
+ # - Lerna (lerna.json)
19
+ # - Nx / Turborepo (nx.json / turbo.json — scan direct subdirs)
20
+ #
21
+ # Import resolution:
22
+ # - Relative imports (./foo, ../bar)
23
+ # - TypeScript tsconfig.json path aliases (@/utils → src/utils)
24
+ # - Cross-workspace package imports (@company/shared → module root)
25
+ # - "import type" lines are ignored (no runtime dependency)
26
+ #
27
+ # Configuration:
28
+ # import/config/path - Path to the repository root
29
+ # import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
30
+ # import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
31
+ class Archsight::Import::Handlers::JavaScriptGrapher < Archsight::Import::Handlers::Grapher
32
+ def self.language_name = "javascript"
33
+
34
+ def self.applicable?(path)
35
+ File.exist?(File.join(path, "package.json")) ||
36
+ Dir.glob(File.join(path, "*/package.json")).any?
37
+ end
38
+
39
+ def wrap_single_module?
40
+ true
41
+ end
42
+
43
+ SKIP_DIRS = %w[node_modules dist build .next .nuxt out .turbo .nx .cache
44
+ coverage .git test tests __tests__ __mocks__ e2e cypress
45
+ playwright .storybook storybook-static fixtures temp tmp].freeze
46
+
47
+ SOURCE_EXTS = %w[.ts .tsx .js .jsx .mjs .cjs].freeze
48
+
49
+ # Packages with more than this many path components are folded into their ancestor.
50
+ # Two levels gives mod_name/feature — matching Java and Ruby conventions.
51
+ MAX_PKG_DEPTH = 2
52
+
53
+ FROM_RE = /\bfrom\s+["']([^"'\n]+)["']/
54
+ REQUIRE_RE = /\brequire\s*\(\s*["']([^"'\n]+)["']\s*\)/
55
+ DYNAMIC_RE = /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/
56
+ TYPE_RE = /\bimport\s+type\b/
57
+
58
+ private
59
+
60
+ # ── Module discovery ─────────────────────────────────────────────────────
61
+
62
+ def discover_modules(repo_root)
63
+ # PNPM workspaces
64
+ pnpm_ws = File.join(repo_root, "pnpm-workspace.yaml")
65
+ if File.exist?(pnpm_ws)
66
+ modules = workspace_modules_from_globs(repo_root, pnpm_workspace_patterns(pnpm_ws))
67
+ return modules if modules.any?
68
+ end
69
+
70
+ root_pkg = read_package_json(repo_root)
71
+
72
+ # NPM / Yarn workspaces ("workspaces" key in package.json)
73
+ ws_globs = workspace_globs_from_package(root_pkg)
74
+ if ws_globs.any?
75
+ modules = workspace_modules_from_globs(repo_root, ws_globs)
76
+ return modules if modules.any?
77
+ end
78
+
79
+ # Lerna
80
+ lerna_path = File.join(repo_root, "lerna.json")
81
+ if File.exist?(lerna_path)
82
+ lerna = safe_json(lerna_path)
83
+ if lerna
84
+ lerna_globs = Array(lerna["packages"])
85
+ lerna_globs = ["packages/*"] if lerna_globs.empty?
86
+ modules = workspace_modules_from_globs(repo_root, lerna_globs)
87
+ return modules if modules.any?
88
+ end
89
+ end
90
+
91
+ # Nx / Turborepo — scan direct subdirs
92
+ if File.exist?(File.join(repo_root, "nx.json")) || File.exist?(File.join(repo_root, "turbo.json"))
93
+ modules = scan_subdir_modules(repo_root)
94
+ return modules if modules.any?
95
+ end
96
+
97
+ # No root package.json but subdirectory packages exist (e.g. a repo with a frontend/ subdir)
98
+ if root_pkg.nil?
99
+ modules = scan_subdir_modules(repo_root)
100
+ return modules if modules.any?
101
+ end
102
+
103
+ # Single module fallback
104
+ name = root_pkg&.dig("name") || File.basename(repo_root)
105
+ [[".", name]]
106
+ end
107
+
108
+ # ── Package collection ────────────────────────────────────────────────────
109
+
110
+ def collect_packages(repo_root, modules, _prefix)
111
+ workspace_names = build_workspace_name_map(modules)
112
+ all_pkgs = {}
113
+
114
+ modules.each do |rel_dir, mod_name|
115
+ mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
116
+ src_root = locate_src_root(mod_dir)
117
+ tsconfig_paths = load_tsconfig_paths(mod_dir)
118
+
119
+ scan_source_files(src_root, mod_name, tsconfig_paths, workspace_names, all_pkgs)
120
+ end
121
+
122
+ all_pkgs
123
+ end
124
+
125
+ # ── File scanning ─────────────────────────────────────────────────────────
126
+
127
+ def scan_source_files(src_root, mod_name, tsconfig_paths, workspace_names, all_pkgs)
128
+ glob = File.join(src_root, "**", "*{#{SOURCE_EXTS.join(",")}}")
129
+ safe_glob(glob).each do |source_file|
130
+ rel_parts = source_file.delete_prefix("#{src_root}/").split("/")
131
+ next if rel_parts.any? { |p| SKIP_DIRS.include?(p) }
132
+
133
+ pkg = cap_depth(file_to_pkg(source_file, src_root, mod_name), mod_name)
134
+ all_pkgs[pkg] ||= []
135
+
136
+ extract_imports(source_file).each do |req|
137
+ dep = resolve_import(req, source_file, src_root, mod_name, tsconfig_paths, workspace_names)
138
+ next unless dep
139
+
140
+ dep = cap_depth(dep, mod_name)
141
+ next if dep == pkg || all_pkgs[pkg].include?(dep)
142
+
143
+ all_pkgs[pkg] << dep
144
+ end
145
+ end
146
+ end
147
+
148
+ # ── Import extraction ─────────────────────────────────────────────────────
149
+
150
+ def extract_imports(source_file)
151
+ content = File.read(source_file, encoding: "utf-8")
152
+ deps = []
153
+
154
+ content.each_line do |line|
155
+ next if line.match?(TYPE_RE)
156
+
157
+ line.scan(FROM_RE) { |(m)| deps << m }
158
+ end
159
+
160
+ content.scan(REQUIRE_RE) { |(m)| deps << m }
161
+ content.scan(DYNAMIC_RE) { |(m)| deps << m }
162
+
163
+ deps.uniq
164
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
165
+ []
166
+ end
167
+
168
+ # ── Import resolution ─────────────────────────────────────────────────────
169
+
170
+ def resolve_import(req, source_file, src_root, mod_name, tsconfig_paths, workspace_names)
171
+ return resolve_relative(req, source_file, src_root, mod_name) if req.start_with?("./", "../")
172
+
173
+ dep = resolve_alias(req, tsconfig_paths, src_root, mod_name)
174
+ return dep if dep
175
+
176
+ resolve_workspace_import(req, workspace_names)
177
+ end
178
+
179
+ def resolve_relative(req, source_file, src_root, mod_name)
180
+ clean = req.sub(/\.(js|ts|jsx|tsx|mjs|cjs)$/, "")
181
+ expanded = File.expand_path(clean, File.dirname(source_file))
182
+ return nil unless expanded.start_with?(src_root)
183
+
184
+ if Dir.exist?(expanded)
185
+ rel = expanded.delete_prefix("#{src_root}/")
186
+ rel == "." ? mod_name : "#{mod_name}/#{rel}"
187
+ else
188
+ file_to_pkg("#{expanded}.ts", src_root, mod_name)
189
+ end
190
+ end
191
+
192
+ def resolve_alias(req, tsconfig_paths, src_root, mod_name)
193
+ tsconfig_paths.each do |prefix, target_dir|
194
+ next unless req.start_with?(prefix)
195
+
196
+ rest = req.delete_prefix(prefix)
197
+ expanded = rest.empty? ? target_dir : File.join(target_dir, rest)
198
+ next unless expanded.start_with?(src_root)
199
+
200
+ return file_to_pkg("#{expanded}.ts", src_root, mod_name)
201
+ end
202
+ nil
203
+ end
204
+
205
+ def resolve_workspace_import(req, workspace_names)
206
+ workspace_names.each do |pkg_name, mod_name|
207
+ return mod_name if req == pkg_name
208
+ next unless req.start_with?("#{pkg_name}/")
209
+
210
+ sub = req.delete_prefix("#{pkg_name}/")
211
+ return "#{mod_name}/#{sub.split("/").first}"
212
+ end
213
+ nil
214
+ end
215
+
216
+ # ── Package path helpers ──────────────────────────────────────────────────
217
+
218
+ # Maps a source file to a package path: mod_name/dir where dir is the
219
+ # file's directory within src_root. Files at the src_root level map to mod_name.
220
+ def file_to_pkg(abs_path, src_root, mod_name)
221
+ rel = abs_path.delete_prefix("#{src_root}/")
222
+ dir = File.dirname(rel)
223
+ dir == "." ? mod_name : "#{mod_name}/#{dir}"
224
+ end
225
+
226
+ def cap_depth(pkg, mod_name)
227
+ # Cross-module packages (e.g. workspace deps) are returned unchanged.
228
+ return pkg if pkg != mod_name && !pkg.start_with?("#{mod_name}/")
229
+
230
+ suffix = pkg.delete_prefix("#{mod_name}/")
231
+ return mod_name if suffix == pkg # pkg IS the module root
232
+
233
+ parts = suffix.split("/")
234
+ return pkg if parts.length <= MAX_PKG_DEPTH - 1
235
+
236
+ "#{mod_name}/#{parts.first(MAX_PKG_DEPTH - 1).join("/")}"
237
+ end
238
+
239
+ # ── Source root detection ─────────────────────────────────────────────────
240
+
241
+ def locate_src_root(mod_dir)
242
+ %w[src lib app].each do |subdir|
243
+ candidate = File.join(mod_dir, subdir)
244
+ return candidate if Dir.exist?(candidate)
245
+ end
246
+ mod_dir
247
+ end
248
+
249
+ # ── tsconfig path loading ─────────────────────────────────────────────────
250
+
251
+ def load_tsconfig_paths(mod_dir)
252
+ paths = {}
253
+ %w[tsconfig.json tsconfig.base.json].each do |fname|
254
+ tsconfig_file = File.join(mod_dir, fname)
255
+ next unless File.exist?(tsconfig_file)
256
+
257
+ data = safe_json(tsconfig_file)
258
+ break unless data
259
+
260
+ raw_paths = data.dig("compilerOptions", "paths") || {}
261
+ base_url = data.dig("compilerOptions", "baseUrl") || "."
262
+ base_dir = File.expand_path(File.join(mod_dir, base_url))
263
+
264
+ raw_paths.each do |pattern, targets|
265
+ next if targets.empty?
266
+
267
+ # Extract the literal prefix before the wildcard: "@/*" → "@/", "shared" → "shared"
268
+ prefix = pattern.sub(/\*.*$/, "")
269
+ target_dir = File.expand_path(File.join(base_dir, targets.first.sub(/\*.*$/, "")))
270
+ paths[prefix] = target_dir
271
+ end
272
+ break
273
+ end
274
+ paths
275
+ end
276
+
277
+ # ── Workspace helpers ─────────────────────────────────────────────────────
278
+
279
+ def workspace_globs_from_package(pkg)
280
+ return [] unless pkg
281
+
282
+ ws = pkg["workspaces"]
283
+ Array(ws.is_a?(Hash) ? ws["packages"] : ws)
284
+ end
285
+
286
+ def pnpm_workspace_patterns(pnpm_ws_path)
287
+ data = begin
288
+ YAML.safe_load(File.read(pnpm_ws_path, encoding: "utf-8"))
289
+ rescue StandardError
290
+ {}
291
+ end
292
+ Array(data&.dig("packages")).reject { |p| p.to_s.start_with?("!") }
293
+ end
294
+
295
+ def workspace_modules_from_globs(repo_root, globs)
296
+ globs.flat_map do |glob|
297
+ Dir.glob(File.join(repo_root, glob)).filter_map do |dir|
298
+ next unless File.directory?(dir) && File.exist?(File.join(dir, "package.json"))
299
+
300
+ rel = dir.delete_prefix("#{repo_root}/")
301
+ next if rel.split("/").any? { |part| SKIP_DIRS.include?(part) }
302
+
303
+ pkg = read_package_json(dir)
304
+ mod_name = pkg&.dig("name") || File.basename(dir)
305
+ [rel, mod_name]
306
+ end
307
+ end.uniq
308
+ end
309
+
310
+ def scan_subdir_modules(repo_root)
311
+ modules = Dir.each_child(repo_root).filter_map do |entry|
312
+ dir = File.join(repo_root, entry)
313
+ next unless File.directory?(dir) && !SKIP_DIRS.include?(entry) && !entry.start_with?(".")
314
+ next unless File.exist?(File.join(dir, "package.json"))
315
+
316
+ pkg = read_package_json(dir)
317
+ mod_name = pkg&.dig("name") || entry
318
+ [entry, mod_name]
319
+ end
320
+ modules.sort_by { |rel, _| rel }
321
+ end
322
+
323
+ def build_workspace_name_map(modules)
324
+ modules.each_with_object({}) do |(_rel_dir, mod_name), map|
325
+ map[mod_name] = mod_name
326
+ end
327
+ end
328
+
329
+ def read_package_json(dir)
330
+ safe_json(File.join(dir, "package.json"))
331
+ end
332
+
333
+ def safe_json(path)
334
+ JSON.parse(File.read(path, encoding: "utf-8"))
335
+ rescue JSON::ParserError, Errno::ENOENT, Encoding::InvalidByteSequenceError
336
+ nil
337
+ end
338
+ end
339
+
340
+ Archsight::Import::Registry.register("javascript-grapher", Archsight::Import::Handlers::JavaScriptGrapher)