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,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require_relative "grapher"
|
|
6
|
+
require_relative "../registry"
|
|
7
|
+
|
|
8
|
+
# PythonGrapher handler - analyses a Python repository and generates a GraphViz
|
|
9
|
+
# DOT graph of its package/module structure, stored as architecture/modules on
|
|
10
|
+
# the TechnologyArtifact so it can be rendered in the frontend.
|
|
11
|
+
#
|
|
12
|
+
# Uses static AST analysis (python3 stdlib only — no external Python packages
|
|
13
|
+
# required). Package paths are normalised to "/" separators internally so they
|
|
14
|
+
# are compatible with the generic Grapher layout engine.
|
|
15
|
+
#
|
|
16
|
+
# Configuration:
|
|
17
|
+
# import/config/path - Path to the Python repository root
|
|
18
|
+
# import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
|
|
19
|
+
# import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
|
|
20
|
+
class Archsight::Import::Handlers::PythonGrapher < Archsight::Import::Handlers::Grapher
|
|
21
|
+
def self.language_name = "python"
|
|
22
|
+
|
|
23
|
+
def self.applicable?(path)
|
|
24
|
+
File.exist?(File.join(path, "__init__.py")) ||
|
|
25
|
+
File.exist?(File.join(path, "pyproject.toml")) ||
|
|
26
|
+
File.exist?(File.join(path, "setup.py")) ||
|
|
27
|
+
Dir.glob(File.join(path, "*/__init__.py")).any?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Inline Python3 script — scans a single package directory with stdlib ast.
|
|
31
|
+
# Argv: <pkg_root_dir> <pkg_name>
|
|
32
|
+
# Stdout: JSON object mapping slash-separated module paths to arrays of deps.
|
|
33
|
+
#
|
|
34
|
+
# "from pkg import name" is resolved to "pkg/name" when that submodule exists
|
|
35
|
+
# on disk, so that intra-package submodule imports produce correct edges.
|
|
36
|
+
PYTHON_SCANNER = <<~PYTHON
|
|
37
|
+
import ast, os, sys, json
|
|
38
|
+
|
|
39
|
+
def collect_all_mods(pkg_root, pkg_name):
|
|
40
|
+
mods = set()
|
|
41
|
+
for dirpath, dirs, files in os.walk(pkg_root):
|
|
42
|
+
dirs[:] = sorted(d for d in dirs if d != '__pycache__' and not d.startswith('.'))
|
|
43
|
+
for f in files:
|
|
44
|
+
if not f.endswith('.py'):
|
|
45
|
+
continue
|
|
46
|
+
rel = os.path.relpath(os.path.join(dirpath, f), pkg_root)
|
|
47
|
+
parts = rel.replace(os.sep, '/').split('/')
|
|
48
|
+
parts[-1] = parts[-1][:-3]
|
|
49
|
+
if parts[-1] == '__init__':
|
|
50
|
+
parts = parts[:-1]
|
|
51
|
+
elif parts[-1] == '__main__':
|
|
52
|
+
parts[-1] = 'main'
|
|
53
|
+
mods.add(pkg_name if not parts else pkg_name + '/' + '/'.join(parts))
|
|
54
|
+
return mods
|
|
55
|
+
|
|
56
|
+
def mod_from_path(path, pkg_root, pkg_name):
|
|
57
|
+
rel = os.path.relpath(path, pkg_root)
|
|
58
|
+
parts = rel.replace(os.sep, '/').split('/')
|
|
59
|
+
parts[-1] = parts[-1][:-3]
|
|
60
|
+
if parts[-1] == '__init__':
|
|
61
|
+
parts = parts[:-1]
|
|
62
|
+
elif parts[-1] == '__main__':
|
|
63
|
+
parts[-1] = 'main'
|
|
64
|
+
return pkg_name if not parts else pkg_name + '/' + '/'.join(parts)
|
|
65
|
+
|
|
66
|
+
def resolve_relative_base(current_mod, level, module_name):
|
|
67
|
+
"""Resolve a relative import to an absolute slash-path.
|
|
68
|
+
|
|
69
|
+
level=1 means same package (go up one component from the module name),
|
|
70
|
+
level=2 means parent package, etc.
|
|
71
|
+
"""
|
|
72
|
+
parts = current_mod.split('/')
|
|
73
|
+
# Strip `level` trailing components to get the anchor package
|
|
74
|
+
base_parts = parts[:-level] if level <= len(parts) else []
|
|
75
|
+
if module_name:
|
|
76
|
+
base_parts = base_parts + module_name.replace('.', '/').split('/')
|
|
77
|
+
return '/'.join(base_parts) if base_parts else None
|
|
78
|
+
|
|
79
|
+
def resolve_from_import(base, names, all_mods):
|
|
80
|
+
resolved = []
|
|
81
|
+
for alias in names:
|
|
82
|
+
sub = base + '/' + alias.name
|
|
83
|
+
resolved.append(sub if sub in all_mods else base)
|
|
84
|
+
return resolved or [base]
|
|
85
|
+
|
|
86
|
+
def scan_imports(path, current_mod, pkg_name, all_mods):
|
|
87
|
+
try:
|
|
88
|
+
tree = ast.parse(open(path, encoding='utf-8', errors='replace').read())
|
|
89
|
+
except SyntaxError:
|
|
90
|
+
return []
|
|
91
|
+
out = []
|
|
92
|
+
for node in ast.walk(tree):
|
|
93
|
+
if isinstance(node, ast.Import):
|
|
94
|
+
for alias in node.names:
|
|
95
|
+
name = alias.name.replace('.', '/')
|
|
96
|
+
if name == pkg_name or name.startswith(pkg_name + '/'):
|
|
97
|
+
out.append(name)
|
|
98
|
+
elif isinstance(node, ast.ImportFrom):
|
|
99
|
+
if node.level == 0:
|
|
100
|
+
if node.module is None:
|
|
101
|
+
continue
|
|
102
|
+
base = node.module.replace('.', '/')
|
|
103
|
+
else:
|
|
104
|
+
base = resolve_relative_base(current_mod, node.level, node.module)
|
|
105
|
+
if base is None:
|
|
106
|
+
continue
|
|
107
|
+
if base == pkg_name or base.startswith(pkg_name + '/'):
|
|
108
|
+
out.extend(resolve_from_import(base, node.names, all_mods))
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
def is_trivial_init(path):
|
|
112
|
+
"""True if __init__.py has no function/class defs and no non-dunder assignments."""
|
|
113
|
+
try:
|
|
114
|
+
tree = ast.parse(open(path, encoding='utf-8', errors='replace').read())
|
|
115
|
+
except SyntaxError:
|
|
116
|
+
return True
|
|
117
|
+
for node in ast.iter_child_nodes(tree):
|
|
118
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
119
|
+
return False
|
|
120
|
+
if isinstance(node, ast.Assign):
|
|
121
|
+
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
|
|
122
|
+
if any(not t.startswith('__') for t in targets):
|
|
123
|
+
return False
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
pkg_root, pkg_name = sys.argv[1], sys.argv[2]
|
|
127
|
+
all_mods = collect_all_mods(pkg_root, pkg_name)
|
|
128
|
+
edges = {}
|
|
129
|
+
init_file_mods = {}
|
|
130
|
+
for dirpath, dirs, files in os.walk(pkg_root):
|
|
131
|
+
dirs[:] = sorted(d for d in dirs if d != '__pycache__' and not d.startswith('.'))
|
|
132
|
+
for f in files:
|
|
133
|
+
if not f.endswith('.py'):
|
|
134
|
+
continue
|
|
135
|
+
p = os.path.join(dirpath, f)
|
|
136
|
+
mod = mod_from_path(p, pkg_root, pkg_name)
|
|
137
|
+
if f == '__init__.py':
|
|
138
|
+
init_file_mods[mod] = p
|
|
139
|
+
imps = scan_imports(p, mod, pkg_name, all_mods)
|
|
140
|
+
if mod not in edges:
|
|
141
|
+
edges[mod] = []
|
|
142
|
+
edges[mod].extend(i for i in imps if i != mod)
|
|
143
|
+
for mod, fpath in init_file_mods.items():
|
|
144
|
+
if not edges.get(mod) and is_trivial_init(fpath):
|
|
145
|
+
edges.pop(mod, None)
|
|
146
|
+
print(json.dumps(edges))
|
|
147
|
+
PYTHON
|
|
148
|
+
|
|
149
|
+
# Scans a list of root-level Python scripts for imports from known packages.
|
|
150
|
+
# Argv[1]: JSON {"paths": [...], "packages": [...], "all_mods": [...]}
|
|
151
|
+
# Stdout: JSON {"<pkg>/main": ["<dep>", ...]}
|
|
152
|
+
ROOT_SCANNER = <<~PYTHON
|
|
153
|
+
import ast, sys, json
|
|
154
|
+
|
|
155
|
+
def extract_imports(path, pkg_names, all_mods):
|
|
156
|
+
try:
|
|
157
|
+
tree = ast.parse(open(path, encoding='utf-8', errors='replace').read())
|
|
158
|
+
except (SyntaxError, OSError):
|
|
159
|
+
return []
|
|
160
|
+
out = []
|
|
161
|
+
for node in ast.walk(tree):
|
|
162
|
+
if isinstance(node, ast.Import):
|
|
163
|
+
for alias in node.names:
|
|
164
|
+
base = alias.name.replace('.', '/')
|
|
165
|
+
if any(base == p or base.startswith(p + '/') for p in pkg_names):
|
|
166
|
+
out.append(base)
|
|
167
|
+
elif isinstance(node, ast.ImportFrom):
|
|
168
|
+
if node.level == 0 and node.module:
|
|
169
|
+
base = node.module.replace('.', '/')
|
|
170
|
+
if any(base == p or base.startswith(p + '/') for p in pkg_names):
|
|
171
|
+
sub = base + '/' + node.names[0].name if node.names else base
|
|
172
|
+
out.append(sub if sub in all_mods else base)
|
|
173
|
+
return list(dict.fromkeys(out))
|
|
174
|
+
|
|
175
|
+
cfg = json.loads(sys.argv[1])
|
|
176
|
+
pkg_names = cfg['packages']
|
|
177
|
+
all_mods = set(cfg.get('all_mods', []))
|
|
178
|
+
result = {}
|
|
179
|
+
for path in cfg['paths']:
|
|
180
|
+
deps = extract_imports(path, pkg_names, all_mods)
|
|
181
|
+
if not deps:
|
|
182
|
+
continue
|
|
183
|
+
main_pkg = next((p for dep in deps for p in pkg_names if dep == p or dep.startswith(p + '/')), None)
|
|
184
|
+
if not main_pkg:
|
|
185
|
+
continue
|
|
186
|
+
result['main'] = list(dict.fromkeys(result.get('main', []) + deps))
|
|
187
|
+
print(json.dumps(result))
|
|
188
|
+
PYTHON
|
|
189
|
+
|
|
190
|
+
def wrap_single_module?
|
|
191
|
+
true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
SKIP_DIRS = %w[test tests docs doc examples example vendor .git __pycache__ dist build
|
|
195
|
+
node_modules .tox .venv venv env].freeze
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# ── Module discovery ─────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def discover_modules(repo_root)
|
|
202
|
+
# If the root itself is a Python package, treat it as a single module.
|
|
203
|
+
return [[".", File.basename(repo_root)]] if File.exist?(File.join(repo_root, "__init__.py"))
|
|
204
|
+
|
|
205
|
+
modules = []
|
|
206
|
+
Dir.each_child(repo_root) do |entry|
|
|
207
|
+
next if SKIP_DIRS.include?(entry) || entry.start_with?(".")
|
|
208
|
+
|
|
209
|
+
dir = File.join(repo_root, entry)
|
|
210
|
+
next unless File.directory?(dir) && File.exist?(File.join(dir, "__init__.py"))
|
|
211
|
+
|
|
212
|
+
modules << [entry, entry]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
modules.sort_by { |rel, _| rel }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# ── Package collection ────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def collect_packages(repo_root, modules, _prefix)
|
|
221
|
+
all_pkgs = {}
|
|
222
|
+
|
|
223
|
+
modules.each do |rel_dir, mod_name|
|
|
224
|
+
mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
|
|
225
|
+
out, err, status = Open3.capture3("python3", "-c", PYTHON_SCANNER, mod_dir, mod_name)
|
|
226
|
+
|
|
227
|
+
unless status.success?
|
|
228
|
+
progress.warn("Skipping #{rel_dir}: #{err.lines.first.to_s.strip}")
|
|
229
|
+
next
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
JSON.parse(out).each do |pkg, deps|
|
|
233
|
+
all_pkgs[pkg] ||= []
|
|
234
|
+
all_pkgs[pkg].concat(deps)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
root_scripts = find_root_python_scripts(repo_root)
|
|
239
|
+
if root_scripts.any?
|
|
240
|
+
pkg_names = modules.map { |_, mod_name| mod_name }
|
|
241
|
+
config = { "paths" => root_scripts, "packages" => pkg_names,
|
|
242
|
+
"all_mods" => all_pkgs.keys }.to_json
|
|
243
|
+
out, _err, status = Open3.capture3("python3", "-c", ROOT_SCANNER, config)
|
|
244
|
+
if status.success?
|
|
245
|
+
JSON.parse(out).each do |pkg, deps|
|
|
246
|
+
all_pkgs[pkg] ||= []
|
|
247
|
+
all_pkgs[pkg].concat(deps)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
all_pkgs
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def find_root_python_scripts(repo_root)
|
|
256
|
+
Dir.each_child(repo_root).filter_map do |f|
|
|
257
|
+
path = File.join(repo_root, f)
|
|
258
|
+
next unless File.file?(path) && !f.end_with?(".py")
|
|
259
|
+
|
|
260
|
+
begin
|
|
261
|
+
first_line = File.open(path, &:readline).strip
|
|
262
|
+
path if first_line.match?(/python/)
|
|
263
|
+
rescue StandardError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
Archsight::Import::Registry.register("python-grapher", Archsight::Import::Handlers::PythonGrapher)
|
|
@@ -20,6 +20,7 @@ require_relative "../team_matcher"
|
|
|
20
20
|
# import/config/fallbackTeam - Optional team name when no contributor match found
|
|
21
21
|
# import/config/botTeam - Optional team name for bot-only repositories
|
|
22
22
|
# import/config/corporateAffixes - Optional comma-separated corporate username affixes for team matching (e.g., "ionos,1and1")
|
|
23
|
+
# import/config/grapherOutputPath - Optional output path for language-grapher child imports
|
|
23
24
|
class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
24
25
|
def execute
|
|
25
26
|
@path = config("path")
|
|
@@ -35,19 +36,10 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
|
35
36
|
return
|
|
36
37
|
end
|
|
37
38
|
rescue StandardError => e
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
status: "inaccessible",
|
|
43
|
-
reason: "Repository not accessible",
|
|
44
|
-
error: e.message,
|
|
45
|
-
visibility: "private"
|
|
46
|
-
)
|
|
47
|
-
write_generates_meta
|
|
48
|
-
return
|
|
49
|
-
end
|
|
50
|
-
raise
|
|
39
|
+
raise unless access_denied_error?(e.message)
|
|
40
|
+
|
|
41
|
+
write_inaccessible_artifact(e.message)
|
|
42
|
+
return
|
|
51
43
|
end
|
|
52
44
|
end
|
|
53
45
|
|
|
@@ -80,9 +72,14 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
|
80
72
|
progress.update("Generating resource")
|
|
81
73
|
resource = build_technology_artifact(@path, scc_data, git_data, team_result, license_data)
|
|
82
74
|
|
|
83
|
-
# Write output
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
# Write output: artifact + child imports for every applicable language grapher + self-marker
|
|
76
|
+
artifact_name = resource["metadata"]["name"]
|
|
77
|
+
docs = [resource]
|
|
78
|
+
Archsight::Import::Registry.handlers_for(@path).each do |handler_class|
|
|
79
|
+
docs << grapher_import(artifact_name, handler_class)
|
|
80
|
+
end
|
|
81
|
+
docs << self_marker
|
|
82
|
+
write_yaml(docs.map { |d| YAML.dump(d) }.join)
|
|
86
83
|
|
|
87
84
|
write_generates_meta
|
|
88
85
|
end
|
|
@@ -159,6 +156,17 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
|
159
156
|
first_line.length > 100 ? "#{first_line[0, 97]}..." : first_line
|
|
160
157
|
end
|
|
161
158
|
|
|
159
|
+
def write_inaccessible_artifact(error_message)
|
|
160
|
+
progress.update("Access denied - creating minimal artifact")
|
|
161
|
+
write_minimal_artifact(
|
|
162
|
+
status: "inaccessible",
|
|
163
|
+
reason: "Repository not accessible",
|
|
164
|
+
error: error_message,
|
|
165
|
+
visibility: "private"
|
|
166
|
+
)
|
|
167
|
+
write_generates_meta
|
|
168
|
+
end
|
|
169
|
+
|
|
162
170
|
# Check if error message indicates access denied
|
|
163
171
|
def access_denied_error?(message)
|
|
164
172
|
return false if message.nil?
|
|
@@ -211,7 +219,7 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
|
211
219
|
private
|
|
212
220
|
|
|
213
221
|
def run_scc(path)
|
|
214
|
-
cmd = ["scc", "-f", "json2", "--sort", "name", path]
|
|
222
|
+
cmd = ["scc", "--exclude-dir", ".git,.hg,.svn,vendor,node_modules", "-f", "json2", "--sort", "name", path]
|
|
215
223
|
|
|
216
224
|
out, err, status = Open3.capture3(*cmd)
|
|
217
225
|
raise "scc failed: #{cmd.join(" ")}\n#{err}" unless status.success?
|
|
@@ -453,6 +461,22 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
|
|
|
453
461
|
annotations
|
|
454
462
|
end
|
|
455
463
|
|
|
464
|
+
def grapher_import(artifact_name, handler_class)
|
|
465
|
+
lang = handler_class.language_name
|
|
466
|
+
handler_name = Archsight::Import::Registry.name_for(handler_class)
|
|
467
|
+
child_name = "Import:#{lang.capitalize}Grapher:#{artifact_name.delete_prefix("Repo:")}"
|
|
468
|
+
child_annotations = {}
|
|
469
|
+
if (output_path = config("grapherOutputPath"))
|
|
470
|
+
child_annotations["import/outputPath"] = output_path
|
|
471
|
+
end
|
|
472
|
+
import_yaml(
|
|
473
|
+
name: child_name,
|
|
474
|
+
handler: handler_name,
|
|
475
|
+
config: { "path" => @path },
|
|
476
|
+
annotations: child_annotations
|
|
477
|
+
)
|
|
478
|
+
end
|
|
479
|
+
|
|
456
480
|
def build_deployment_annotations(git_data)
|
|
457
481
|
annotations = {}
|
|
458
482
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "grapher"
|
|
4
|
+
require_relative "../registry"
|
|
5
|
+
|
|
6
|
+
# RubyGrapher handler - analyses a Ruby repository and generates a GraphViz DOT
|
|
7
|
+
# graph of its gem/package structure, stored as architecture/modules on
|
|
8
|
+
# the TechnologyArtifact so it can be rendered in the frontend.
|
|
9
|
+
#
|
|
10
|
+
# Uses static regex analysis of require/require_relative statements.
|
|
11
|
+
# Package paths are normalised to "/" separators internally so they are
|
|
12
|
+
# compatible with the generic Grapher layout engine.
|
|
13
|
+
#
|
|
14
|
+
# Configuration:
|
|
15
|
+
# import/config/path - Path to the Ruby 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::RubyGrapher < Archsight::Import::Handlers::Grapher
|
|
19
|
+
def self.language_name = "ruby"
|
|
20
|
+
|
|
21
|
+
def self.applicable?(path)
|
|
22
|
+
File.exist?(File.join(path, "Gemfile")) ||
|
|
23
|
+
Dir.glob(File.join(path, "*.gemspec")).any? ||
|
|
24
|
+
Dir.glob(File.join(path, "*/*.gemspec")).any?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def wrap_single_module?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
SKIP_DIRS = %w[test spec tests vendor .git node_modules tmp log coverage .bundle
|
|
32
|
+
pkg doc docs generated].freeze
|
|
33
|
+
|
|
34
|
+
# Packages with more than this many path components are folded into their ancestor.
|
|
35
|
+
# Ruby gems use lib/<gem>/<feature>.rb, so depth 2 gives one level of features.
|
|
36
|
+
MAX_PKG_DEPTH = 2
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# ── Module discovery ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def discover_modules(repo_root)
|
|
43
|
+
# Root-level gemspecs → single-gem repo.
|
|
44
|
+
# Use the actual top-level directory in lib/ as the module name so that
|
|
45
|
+
# package paths (which are lib-relative) match without re-prefixing.
|
|
46
|
+
root_gemspecs = Dir.glob(File.join(repo_root, "*.gemspec"))
|
|
47
|
+
if root_gemspecs.any?
|
|
48
|
+
return root_gemspecs.map do |path|
|
|
49
|
+
lib = File.join(repo_root, "lib")
|
|
50
|
+
mod_name = lib_top_dir(lib) || gemspec_name(path) || File.basename(path, ".gemspec")
|
|
51
|
+
[".", mod_name]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Subdirectory gemspecs → monorepo
|
|
56
|
+
sub_gemspecs = Dir.glob(File.join(repo_root, "*/*.gemspec")).reject do |p|
|
|
57
|
+
SKIP_DIRS.any? { |d| p.split("/").include?(d) }
|
|
58
|
+
end
|
|
59
|
+
if sub_gemspecs.any?
|
|
60
|
+
return sub_gemspecs.sort.map do |path|
|
|
61
|
+
rel_dir = File.dirname(path).delete_prefix("#{repo_root}/")
|
|
62
|
+
lib = File.join(repo_root, rel_dir, "lib")
|
|
63
|
+
mod_name = lib_top_dir(lib) || gemspec_name(path) || File.basename(path, ".gemspec")
|
|
64
|
+
[rel_dir, mod_name]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Fallback: Gemfile without gemspec
|
|
69
|
+
lib = File.join(repo_root, "lib")
|
|
70
|
+
[[".", lib_top_dir(lib) || File.basename(repo_root)]]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── Package collection ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def collect_packages(repo_root, modules, _prefix)
|
|
76
|
+
# Build a map of lib dirs for cross-module require resolution:
|
|
77
|
+
# mod_name => absolute lib dir
|
|
78
|
+
lib_dirs = modules.filter_map do |rel_dir, mod_name|
|
|
79
|
+
abs_lib = File.join(rel_dir == "." ? repo_root : File.join(repo_root, rel_dir), "lib")
|
|
80
|
+
[mod_name, abs_lib] if Dir.exist?(abs_lib)
|
|
81
|
+
end.to_h
|
|
82
|
+
|
|
83
|
+
all_pkgs = {}
|
|
84
|
+
|
|
85
|
+
modules.each do |_rel_dir, mod_name| # rubocop:disable Style/HashEachMethods
|
|
86
|
+
lib_dir = lib_dirs[mod_name]
|
|
87
|
+
next unless lib_dir
|
|
88
|
+
|
|
89
|
+
scan_lib_dir(lib_dir, mod_name, lib_dirs, all_pkgs)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
all_pkgs
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ── Scanning helpers ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def scan_lib_dir(lib_dir, mod_name, lib_dirs, all_pkgs)
|
|
98
|
+
safe_glob(File.join(lib_dir, "**", "*.rb")).each do |rb_file|
|
|
99
|
+
rel_parts = rb_file.delete_prefix("#{lib_dir}/").split("/")
|
|
100
|
+
next if rel_parts.any? { |p| SKIP_DIRS.include?(p) }
|
|
101
|
+
|
|
102
|
+
pkg = file_to_pkg(rb_file, lib_dir, mod_name)
|
|
103
|
+
pkg = cap_depth(pkg, mod_name)
|
|
104
|
+
all_pkgs[pkg] ||= []
|
|
105
|
+
|
|
106
|
+
extract_deps(rb_file, lib_dir, mod_name, lib_dirs).each do |dep|
|
|
107
|
+
dep = cap_depth(dep, mod_name)
|
|
108
|
+
next if dep == pkg || all_pkgs[pkg].include?(dep)
|
|
109
|
+
|
|
110
|
+
all_pkgs[pkg] << dep
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def extract_deps(rb_file, lib_dir, mod_name, lib_dirs)
|
|
116
|
+
content = File.read(rb_file, encoding: "utf-8")
|
|
117
|
+
deps = []
|
|
118
|
+
|
|
119
|
+
content.scan(/^\s*require\s+["']([^"']+)["']/) do |(req)|
|
|
120
|
+
dep = resolve_require(req, lib_dirs)
|
|
121
|
+
deps << dep if dep
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
content.scan(/^\s*require_relative\s+["']([^"']+)["']/) do |(req)|
|
|
125
|
+
dep = resolve_require_relative(req, rb_file, lib_dir, mod_name)
|
|
126
|
+
deps << dep if dep
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
deps.uniq
|
|
130
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
131
|
+
[]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Resolve an absolute `require` path against all known lib dirs.
|
|
135
|
+
def resolve_require(req, lib_dirs)
|
|
136
|
+
lib_dirs.each do |mod_name, lib_dir|
|
|
137
|
+
rb_path = File.join(lib_dir, "#{req}.rb")
|
|
138
|
+
return file_to_pkg(rb_path, lib_dir, mod_name) if File.exist?(rb_path)
|
|
139
|
+
|
|
140
|
+
# require 'mod_name' or require 'mod_name/sub' with no .rb — treat as pkg path
|
|
141
|
+
return req if Dir.exist?(File.join(lib_dir, req))
|
|
142
|
+
end
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Resolve a `require_relative` path relative to the current file.
|
|
147
|
+
def resolve_require_relative(req, rb_file, lib_dir, mod_name)
|
|
148
|
+
base_dir = File.dirname(rb_file)
|
|
149
|
+
expanded = File.expand_path(req, base_dir)
|
|
150
|
+
|
|
151
|
+
rb_path = expanded.end_with?(".rb") ? expanded : "#{expanded}.rb"
|
|
152
|
+
return file_to_pkg(rb_path, lib_dir, mod_name) if File.exist?(rb_path)
|
|
153
|
+
|
|
154
|
+
# Directory index file: foo/foo.rb
|
|
155
|
+
if Dir.exist?(expanded)
|
|
156
|
+
index = File.join(expanded, "#{File.basename(expanded)}.rb")
|
|
157
|
+
return file_to_pkg(index, lib_dir, mod_name) if File.exist?(index)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Convert an absolute .rb path to a slash-separated package path.
|
|
164
|
+
# Uses the full lib-relative path (without .rb) so that flat files like
|
|
165
|
+
# lib/gem/database.rb become their own package (gem/database) rather than
|
|
166
|
+
# collapsing into the root gem package. cap_depth then folds deeper paths.
|
|
167
|
+
def file_to_pkg(rb_path, lib_dir, mod_name)
|
|
168
|
+
rel = rb_path.delete_prefix("#{lib_dir}/").delete_suffix(".rb")
|
|
169
|
+
rel == mod_name ? mod_name : rel
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Fold packages deeper than MAX_PKG_DEPTH levels into their ancestor.
|
|
173
|
+
def cap_depth(pkg, mod_name)
|
|
174
|
+
suffix = pkg.delete_prefix("#{mod_name}/")
|
|
175
|
+
return mod_name if suffix == pkg # pkg == mod_name exactly
|
|
176
|
+
|
|
177
|
+
parts = suffix.split("/")
|
|
178
|
+
return pkg if parts.length <= MAX_PKG_DEPTH - 1
|
|
179
|
+
|
|
180
|
+
"#{mod_name}/#{parts.first(MAX_PKG_DEPTH - 1).join("/")}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Returns the single top-level directory inside lib/, which is the gem name
|
|
184
|
+
# as used on the filesystem (underscores, not hyphens).
|
|
185
|
+
def lib_top_dir(lib_dir)
|
|
186
|
+
return nil unless Dir.exist?(lib_dir)
|
|
187
|
+
|
|
188
|
+
dirs = Dir.children(lib_dir).select { |e| File.directory?(File.join(lib_dir, e)) }
|
|
189
|
+
dirs.length == 1 ? dirs.first : nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def gemspec_name(gemspec_path)
|
|
193
|
+
content = begin
|
|
194
|
+
File.read(gemspec_path, encoding: "utf-8")
|
|
195
|
+
rescue StandardError
|
|
196
|
+
""
|
|
197
|
+
end
|
|
198
|
+
match = content.match(/\.name\s*=\s*["']([^"']+)["']/)
|
|
199
|
+
match&.captures&.first
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
Archsight::Import::Registry.register("ruby-grapher", Archsight::Import::Handlers::RubyGrapher)
|