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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/lib/archsight/analysis/sandbox.rb +1 -1
  3. data/lib/archsight/cli.rb +67 -0
  4. data/lib/archsight/graph.rb +10 -0
  5. data/lib/archsight/import/executor.rb +11 -3
  6. data/lib/archsight/import/handlers/cpp_grapher.rb +193 -0
  7. data/lib/archsight/import/handlers/crystal_grapher.rb +186 -0
  8. data/lib/archsight/import/handlers/elixir_grapher.rb +202 -0
  9. data/lib/archsight/import/handlers/go_grapher.rb +127 -0
  10. data/lib/archsight/import/handlers/grapher.rb +552 -0
  11. data/lib/archsight/import/handlers/java_grapher.rb +286 -0
  12. data/lib/archsight/import/handlers/javascript_grapher.rb +340 -0
  13. data/lib/archsight/import/handlers/python_grapher.rb +270 -0
  14. data/lib/archsight/import/handlers/repository.rb +41 -17
  15. data/lib/archsight/import/handlers/ruby_grapher.rb +203 -0
  16. data/lib/archsight/import/handlers/rust_grapher.rb +227 -0
  17. data/lib/archsight/import/registry.rb +23 -0
  18. data/lib/archsight/resources/import.rb +1 -0
  19. data/lib/archsight/resources/technology_artifact.rb +18 -1
  20. data/lib/archsight/version.rb +1 -1
  21. data/lib/archsight/web/api/json_helpers.rb +1 -1
  22. data/lib/archsight/web/public/vue/ApiDocsPage-C0y953v0.css +1 -0
  23. data/lib/archsight/web/public/vue/ApiDocsPage-DHSCaHEn.js +1 -0
  24. data/lib/archsight/web/public/vue/DocPage-DszOPlFy.js +1 -0
  25. data/lib/archsight/web/public/vue/EditorPage-CPZ0Ei4l.css +1 -0
  26. data/lib/archsight/web/public/vue/EditorPage-DsiuZ7fg.js +35 -0
  27. data/lib/archsight/web/public/vue/ErrorPage-C4JutrYc.js +2 -0
  28. data/lib/archsight/web/public/vue/ErrorPage-uMDnfY5_.css +1 -0
  29. data/lib/archsight/web/public/vue/GraphView-Bqlbt6dK.js +1 -0
  30. data/lib/archsight/web/public/vue/GraphView-Cj2V2stN.css +1 -0
  31. data/lib/archsight/web/public/vue/InstanceRouter-D8SEY2eu.js +2 -0
  32. data/lib/archsight/web/public/vue/InstanceRouter-D9hclKFt.css +1 -0
  33. data/lib/archsight/web/public/vue/KindList-CPDaNron.js +1 -0
  34. data/lib/archsight/web/public/vue/ResourceList-B5w9yiyS.js +1 -0
  35. data/lib/archsight/web/public/vue/ResourceList-DxZfNbOg.css +1 -0
  36. data/lib/archsight/web/public/vue/SearchResults-DSHpVO-c.css +1 -0
  37. data/lib/archsight/web/public/vue/SearchResults-FpkhdBFu.js +1 -0
  38. data/lib/archsight/web/public/vue/architecture-7EHR7CIX-DpNNjAIc.js +1 -0
  39. data/lib/archsight/web/public/vue/eventmodeling-FCH6USID-CiThxoWl.js +1 -0
  40. data/lib/archsight/web/public/vue/gitGraph-WXDBUCRP-BODMGpAm.js +1 -0
  41. data/lib/archsight/web/public/vue/graphviz-09t3o0af.js +13 -0
  42. data/lib/archsight/web/public/vue/index-BW0IzY6X.css +1 -0
  43. data/lib/archsight/web/public/vue/index-T1YqCmM1.js +2 -0
  44. data/lib/archsight/web/public/vue/info-J43DQDTF-fLq04sri.js +1 -0
  45. data/lib/archsight/web/public/vue/katex-5qHlIbPR.js +261 -0
  46. data/lib/archsight/web/public/vue/mermaid-DYyHQk7x.js +3093 -0
  47. data/lib/archsight/web/public/vue/packet-YPE3B663-DoY1fbqu.js +1 -0
  48. data/lib/archsight/web/public/vue/pie-LRSECV5Y-C7ZQVwRe.js +1 -0
  49. data/lib/archsight/web/public/vue/radar-GUYGQ44K-CRtY5oqf.js +1 -0
  50. data/lib/archsight/web/public/vue/rolldown-runtime-QTnfLwEv.js +1 -0
  51. data/lib/archsight/web/public/vue/treeView-BLDUP644-Csx2WLLh.js +1 -0
  52. data/lib/archsight/web/public/vue/treemap-LRROVOQU-CfEnRbTx.js +1 -0
  53. data/lib/archsight/web/public/vue/{useGraphviz-BN4iwLLN.js → useGraphviz-EKSrE4q_.js} +5 -4
  54. data/lib/archsight/web/public/vue/useHighlight-BcVbGyrK.js +10 -0
  55. data/lib/archsight/web/public/vue/useMermaid-CIZxhy_r.js +2 -0
  56. data/lib/archsight/web/public/vue/usePanZoom-C2slpyY9.js +11 -0
  57. data/lib/archsight/web/public/vue/wardley-L42UT6IY-97oUvxhz.js +1 -0
  58. data/lib/archsight/web/public/vue.html +4 -3
  59. metadata +51 -72
  60. data/lib/archsight/web/public/vue/ApiDocsPage-Cwn04X61.js +0 -1
  61. data/lib/archsight/web/public/vue/ApiDocsPage-DhNTOH4o.css +0 -1
  62. data/lib/archsight/web/public/vue/DocPage-Y83PCbYi.js +0 -1
  63. data/lib/archsight/web/public/vue/EditorPage-Dq0MuTnp.css +0 -1
  64. data/lib/archsight/web/public/vue/EditorPage-DqRMOBE6.js +0 -34
  65. data/lib/archsight/web/public/vue/ErrorPage-CwPT3JUr.css +0 -1
  66. data/lib/archsight/web/public/vue/ErrorPage-D0lKMCXA.js +0 -2
  67. data/lib/archsight/web/public/vue/GraphView-Byq-Nfd9.js +0 -1
  68. data/lib/archsight/web/public/vue/GraphView-DRcIqAiR.css +0 -1
  69. data/lib/archsight/web/public/vue/InstanceRouter-B3Q2fH0X.js +0 -2
  70. data/lib/archsight/web/public/vue/InstanceRouter-BJkDRXZY.css +0 -1
  71. data/lib/archsight/web/public/vue/KindList-DlDrvJDd.js +0 -1
  72. data/lib/archsight/web/public/vue/ResourceList-DP-z-j71.css +0 -1
  73. data/lib/archsight/web/public/vue/ResourceList-DwsfI85-.js +0 -1
  74. data/lib/archsight/web/public/vue/SearchResults-BGHbg48-.css +0 -1
  75. data/lib/archsight/web/public/vue/SearchResults-DlWGROho.js +0 -1
  76. data/lib/archsight/web/public/vue/_basePickBy-DXGWsL9H.js +0 -1
  77. data/lib/archsight/web/public/vue/_baseUniq-C8pAAASt.js +0 -1
  78. data/lib/archsight/web/public/vue/architectureDiagram-VXUJARFQ-Dg_wTk4u.js +0 -36
  79. data/lib/archsight/web/public/vue/blockDiagram-VD42YOAC-C8HXvtNT.js +0 -122
  80. data/lib/archsight/web/public/vue/c4Diagram-YG6GDRKO-QzXboDJ8.js +0 -10
  81. data/lib/archsight/web/public/vue/chunk-4BX2VUAB-DSPzEX5F.js +0 -1
  82. data/lib/archsight/web/public/vue/chunk-55IACEB6-Dd5Z8Bov.js +0 -1
  83. data/lib/archsight/web/public/vue/chunk-B4BG7PRW-B_hXD1nI.js +0 -165
  84. data/lib/archsight/web/public/vue/chunk-DI55MBZ5-C-2DUMJY.js +0 -220
  85. data/lib/archsight/web/public/vue/chunk-FMBD7UC4-BlBtfKnL.js +0 -15
  86. data/lib/archsight/web/public/vue/chunk-QN33PNHL-Db3REDIz.js +0 -1
  87. data/lib/archsight/web/public/vue/chunk-QZHKN3VN-BqVqGMTy.js +0 -1
  88. data/lib/archsight/web/public/vue/chunk-TZMSLE5B-DfX4VDWu.js +0 -1
  89. data/lib/archsight/web/public/vue/classDiagram-2ON5EDUG-C9Kk58xl.js +0 -1
  90. data/lib/archsight/web/public/vue/classDiagram-v2-WZHVMYZB-C9Kk58xl.js +0 -1
  91. data/lib/archsight/web/public/vue/clone-B6uzD5eH.js +0 -1
  92. data/lib/archsight/web/public/vue/cose-bilkent-S5V4N54A-CfkQxn-a.js +0 -1
  93. data/lib/archsight/web/public/vue/cytoscape.esm-5J0xJHOV.js +0 -321
  94. data/lib/archsight/web/public/vue/dagre-6UL2VRFP-D13da1qu.js +0 -4
  95. data/lib/archsight/web/public/vue/diagram-PSM6KHXK-BwzbeHPK.js +0 -24
  96. data/lib/archsight/web/public/vue/diagram-QEK2KX5R-COjSoDC8.js +0 -43
  97. data/lib/archsight/web/public/vue/diagram-S2PKOQOG-FH65FafS.js +0 -24
  98. data/lib/archsight/web/public/vue/erDiagram-Q2GNP2WA-D1mxJWSp.js +0 -60
  99. data/lib/archsight/web/public/vue/flowDiagram-NV44I4VS-DpRd5cPP.js +0 -162
  100. data/lib/archsight/web/public/vue/ganttDiagram-JELNMOA3-D04Sdd3Q.js +0 -267
  101. data/lib/archsight/web/public/vue/gitGraphDiagram-V2S2FVAM-DgNNP2nj.js +0 -65
  102. data/lib/archsight/web/public/vue/graph-Cnoy0p_X.js +0 -1
  103. data/lib/archsight/web/public/vue/graphviz-CJms5bxZ.js +0 -13
  104. data/lib/archsight/web/public/vue/index-Tiu4C-Sb.css +0 -1
  105. data/lib/archsight/web/public/vue/index-Zr9MoxJi.js +0 -2
  106. data/lib/archsight/web/public/vue/infoDiagram-HS3SLOUP-D5asL_9P.js +0 -2
  107. data/lib/archsight/web/public/vue/journeyDiagram-XKPGCS4Q-D-SRalYk.js +0 -139
  108. data/lib/archsight/web/public/vue/kanban-definition-3W4ZIXB7-CuOjHa3p.js +0 -89
  109. data/lib/archsight/web/public/vue/katex-C-M49wc6.js +0 -261
  110. data/lib/archsight/web/public/vue/layout-CD8FBujT.js +0 -1
  111. data/lib/archsight/web/public/vue/mermaid-DUllW9QE.js +0 -250
  112. data/lib/archsight/web/public/vue/mindmap-definition-VGOIOE7T-BfbYXGBk.js +0 -68
  113. data/lib/archsight/web/public/vue/pieDiagram-ADFJNKIX-mb757Gpq.js +0 -30
  114. data/lib/archsight/web/public/vue/quadrantDiagram-AYHSOK5B-DMtvHJQW.js +0 -7
  115. data/lib/archsight/web/public/vue/requirementDiagram-UZGBJVZJ-CHguirsB.js +0 -64
  116. data/lib/archsight/web/public/vue/sankeyDiagram-TZEHDZUN-nblWMNF6.js +0 -10
  117. data/lib/archsight/web/public/vue/sequenceDiagram-WL72ISMW-B83ZoXls.js +0 -145
  118. data/lib/archsight/web/public/vue/stateDiagram-FKZM4ZOC-Ct0OgmPh.js +0 -1
  119. data/lib/archsight/web/public/vue/stateDiagram-v2-4FDKWEC3-CJZXQ6xd.js +0 -1
  120. data/lib/archsight/web/public/vue/timeline-definition-IT6M3QCI-D1Wd-DLb.js +0 -61
  121. data/lib/archsight/web/public/vue/treemap-GDKQZRPO-DFPZrNlp.js +0 -162
  122. data/lib/archsight/web/public/vue/useHighlight-DmGaxZxx.js +0 -10
  123. data/lib/archsight/web/public/vue/useMermaid-DSo5f1Jc.js +0 -1
  124. data/lib/archsight/web/public/vue/usePanZoom-BEXq_r0S.js +0 -11
  125. 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
- # Access denied or other git errors - create minimal artifact
39
- if access_denied_error?(e.message)
40
- progress.update("Access denied - creating minimal artifact")
41
- write_minimal_artifact(
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 with self-marker for caching
84
- yaml_content = YAML.dump(resource) + YAML.dump(self_marker)
85
- write_yaml(yaml_content)
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)