archsight 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/archsight/analysis/sandbox.rb +1 -1
- data/lib/archsight/cli.rb +67 -0
- data/lib/archsight/graph.rb +10 -0
- data/lib/archsight/import/executor.rb +11 -3
- data/lib/archsight/import/handlers/cpp_grapher.rb +193 -0
- data/lib/archsight/import/handlers/crystal_grapher.rb +186 -0
- data/lib/archsight/import/handlers/elixir_grapher.rb +202 -0
- data/lib/archsight/import/handlers/go_grapher.rb +127 -0
- data/lib/archsight/import/handlers/grapher.rb +552 -0
- data/lib/archsight/import/handlers/java_grapher.rb +286 -0
- data/lib/archsight/import/handlers/javascript_grapher.rb +340 -0
- data/lib/archsight/import/handlers/python_grapher.rb +270 -0
- data/lib/archsight/import/handlers/repository.rb +41 -17
- data/lib/archsight/import/handlers/ruby_grapher.rb +203 -0
- data/lib/archsight/import/handlers/rust_grapher.rb +227 -0
- data/lib/archsight/import/registry.rb +23 -0
- data/lib/archsight/resources/import.rb +1 -0
- data/lib/archsight/resources/technology_artifact.rb +18 -1
- 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-BN4iwLLN.js → useGraphviz-EKSrE4q_.js} +5 -4
- data/lib/archsight/web/public/vue/useHighlight-BcVbGyrK.js +10 -0
- data/lib/archsight/web/public/vue/useMermaid-CIZxhy_r.js +2 -0
- data/lib/archsight/web/public/vue/usePanZoom-C2slpyY9.js +11 -0
- data/lib/archsight/web/public/vue/wardley-L42UT6IY-97oUvxhz.js +1 -0
- data/lib/archsight/web/public/vue.html +4 -3
- metadata +51 -72
- data/lib/archsight/web/public/vue/ApiDocsPage-Cwn04X61.js +0 -1
- data/lib/archsight/web/public/vue/ApiDocsPage-DhNTOH4o.css +0 -1
- data/lib/archsight/web/public/vue/DocPage-Y83PCbYi.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-Dq0MuTnp.css +0 -1
- data/lib/archsight/web/public/vue/EditorPage-DqRMOBE6.js +0 -34
- data/lib/archsight/web/public/vue/ErrorPage-CwPT3JUr.css +0 -1
- data/lib/archsight/web/public/vue/ErrorPage-D0lKMCXA.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-Byq-Nfd9.js +0 -1
- data/lib/archsight/web/public/vue/GraphView-DRcIqAiR.css +0 -1
- data/lib/archsight/web/public/vue/InstanceRouter-B3Q2fH0X.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-BJkDRXZY.css +0 -1
- data/lib/archsight/web/public/vue/KindList-DlDrvJDd.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DP-z-j71.css +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DwsfI85-.js +0 -1
- data/lib/archsight/web/public/vue/SearchResults-BGHbg48-.css +0 -1
- data/lib/archsight/web/public/vue/SearchResults-DlWGROho.js +0 -1
- data/lib/archsight/web/public/vue/_basePickBy-DXGWsL9H.js +0 -1
- data/lib/archsight/web/public/vue/_baseUniq-C8pAAASt.js +0 -1
- data/lib/archsight/web/public/vue/architectureDiagram-VXUJARFQ-Dg_wTk4u.js +0 -36
- data/lib/archsight/web/public/vue/blockDiagram-VD42YOAC-C8HXvtNT.js +0 -122
- data/lib/archsight/web/public/vue/c4Diagram-YG6GDRKO-QzXboDJ8.js +0 -10
- data/lib/archsight/web/public/vue/chunk-4BX2VUAB-DSPzEX5F.js +0 -1
- data/lib/archsight/web/public/vue/chunk-55IACEB6-Dd5Z8Bov.js +0 -1
- data/lib/archsight/web/public/vue/chunk-B4BG7PRW-B_hXD1nI.js +0 -165
- data/lib/archsight/web/public/vue/chunk-DI55MBZ5-C-2DUMJY.js +0 -220
- data/lib/archsight/web/public/vue/chunk-FMBD7UC4-BlBtfKnL.js +0 -15
- data/lib/archsight/web/public/vue/chunk-QN33PNHL-Db3REDIz.js +0 -1
- data/lib/archsight/web/public/vue/chunk-QZHKN3VN-BqVqGMTy.js +0 -1
- data/lib/archsight/web/public/vue/chunk-TZMSLE5B-DfX4VDWu.js +0 -1
- data/lib/archsight/web/public/vue/classDiagram-2ON5EDUG-C9Kk58xl.js +0 -1
- data/lib/archsight/web/public/vue/classDiagram-v2-WZHVMYZB-C9Kk58xl.js +0 -1
- data/lib/archsight/web/public/vue/clone-B6uzD5eH.js +0 -1
- data/lib/archsight/web/public/vue/cose-bilkent-S5V4N54A-CfkQxn-a.js +0 -1
- data/lib/archsight/web/public/vue/cytoscape.esm-5J0xJHOV.js +0 -321
- data/lib/archsight/web/public/vue/dagre-6UL2VRFP-D13da1qu.js +0 -4
- data/lib/archsight/web/public/vue/diagram-PSM6KHXK-BwzbeHPK.js +0 -24
- data/lib/archsight/web/public/vue/diagram-QEK2KX5R-COjSoDC8.js +0 -43
- data/lib/archsight/web/public/vue/diagram-S2PKOQOG-FH65FafS.js +0 -24
- data/lib/archsight/web/public/vue/erDiagram-Q2GNP2WA-D1mxJWSp.js +0 -60
- data/lib/archsight/web/public/vue/flowDiagram-NV44I4VS-DpRd5cPP.js +0 -162
- data/lib/archsight/web/public/vue/ganttDiagram-JELNMOA3-D04Sdd3Q.js +0 -267
- data/lib/archsight/web/public/vue/gitGraphDiagram-V2S2FVAM-DgNNP2nj.js +0 -65
- data/lib/archsight/web/public/vue/graph-Cnoy0p_X.js +0 -1
- data/lib/archsight/web/public/vue/graphviz-CJms5bxZ.js +0 -13
- data/lib/archsight/web/public/vue/index-Tiu4C-Sb.css +0 -1
- data/lib/archsight/web/public/vue/index-Zr9MoxJi.js +0 -2
- data/lib/archsight/web/public/vue/infoDiagram-HS3SLOUP-D5asL_9P.js +0 -2
- data/lib/archsight/web/public/vue/journeyDiagram-XKPGCS4Q-D-SRalYk.js +0 -139
- data/lib/archsight/web/public/vue/kanban-definition-3W4ZIXB7-CuOjHa3p.js +0 -89
- data/lib/archsight/web/public/vue/katex-C-M49wc6.js +0 -261
- data/lib/archsight/web/public/vue/layout-CD8FBujT.js +0 -1
- data/lib/archsight/web/public/vue/mermaid-DUllW9QE.js +0 -250
- data/lib/archsight/web/public/vue/mindmap-definition-VGOIOE7T-BfbYXGBk.js +0 -68
- data/lib/archsight/web/public/vue/pieDiagram-ADFJNKIX-mb757Gpq.js +0 -30
- data/lib/archsight/web/public/vue/quadrantDiagram-AYHSOK5B-DMtvHJQW.js +0 -7
- data/lib/archsight/web/public/vue/requirementDiagram-UZGBJVZJ-CHguirsB.js +0 -64
- data/lib/archsight/web/public/vue/sankeyDiagram-TZEHDZUN-nblWMNF6.js +0 -10
- data/lib/archsight/web/public/vue/sequenceDiagram-WL72ISMW-B83ZoXls.js +0 -145
- data/lib/archsight/web/public/vue/stateDiagram-FKZM4ZOC-Ct0OgmPh.js +0 -1
- data/lib/archsight/web/public/vue/stateDiagram-v2-4FDKWEC3-CJZXQ6xd.js +0 -1
- data/lib/archsight/web/public/vue/timeline-definition-IT6M3QCI-D1Wd-DLb.js +0 -61
- data/lib/archsight/web/public/vue/treemap-GDKQZRPO-DFPZrNlp.js +0 -162
- data/lib/archsight/web/public/vue/useHighlight-DmGaxZxx.js +0 -10
- data/lib/archsight/web/public/vue/useMermaid-DSo5f1Jc.js +0 -1
- data/lib/archsight/web/public/vue/usePanZoom-BEXq_r0S.js +0 -11
- data/lib/archsight/web/public/vue/xychartDiagram-PRI3JC2R-i_eB4HAQ.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)
|