archsight 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- 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 +17 -0
- 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-C5lv_BWF.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-DHOFUCYc.js +0 -1
- data/lib/archsight/web/public/vue/ApiDocsPage-DhNTOH4o.css +0 -1
- data/lib/archsight/web/public/vue/DocPage-CV66qgTr.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-Dq0MuTnp.css +0 -1
- data/lib/archsight/web/public/vue/EditorPage-KqBivY-B.js +0 -34
- data/lib/archsight/web/public/vue/ErrorPage-CwPT3JUr.css +0 -1
- data/lib/archsight/web/public/vue/ErrorPage-DcbC8Kf1.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-Bg_l-F-Q.js +0 -1
- data/lib/archsight/web/public/vue/GraphView-DRcIqAiR.css +0 -1
- data/lib/archsight/web/public/vue/InstanceRouter-4VEtZM7n.css +0 -1
- data/lib/archsight/web/public/vue/InstanceRouter-D-twdyZY.js +0 -2
- data/lib/archsight/web/public/vue/KindList-CPImTKNb.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DMxm0cGh.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DP-z-j71.css +0 -1
- data/lib/archsight/web/public/vue/SearchResults-BGHbg48-.css +0 -1
- data/lib/archsight/web/public/vue/SearchResults-BqUHEWHE.js +0 -1
- data/lib/archsight/web/public/vue/_baseUniq-BjkdEi26.js +0 -1
- data/lib/archsight/web/public/vue/architectureDiagram-VXUJARFQ-JN7CxdtP.js +0 -36
- data/lib/archsight/web/public/vue/blockDiagram-VD42YOAC-teziPFHX.js +0 -122
- data/lib/archsight/web/public/vue/c4Diagram-YG6GDRKO-CVNrzCCc.js +0 -10
- data/lib/archsight/web/public/vue/chunk-4BX2VUAB-BQUARyyA.js +0 -1
- data/lib/archsight/web/public/vue/chunk-55IACEB6-BgJn0Waa.js +0 -1
- data/lib/archsight/web/public/vue/chunk-B4BG7PRW-0ghAfB1t.js +0 -165
- data/lib/archsight/web/public/vue/chunk-DI55MBZ5-HJo1DW2B.js +0 -220
- data/lib/archsight/web/public/vue/chunk-FMBD7UC4-C1GwGKgX.js +0 -15
- data/lib/archsight/web/public/vue/chunk-QN33PNHL-BOoA1KfJ.js +0 -1
- data/lib/archsight/web/public/vue/chunk-QZHKN3VN-BcTNH3IX.js +0 -1
- data/lib/archsight/web/public/vue/chunk-TZMSLE5B-58bQF3J5.js +0 -1
- data/lib/archsight/web/public/vue/classDiagram-2ON5EDUG-DAA8tpbN.js +0 -1
- data/lib/archsight/web/public/vue/classDiagram-v2-WZHVMYZB-DAA8tpbN.js +0 -1
- data/lib/archsight/web/public/vue/clone-BPcOyh7U.js +0 -1
- data/lib/archsight/web/public/vue/cose-bilkent-S5V4N54A-Bv8iR4rF.js +0 -1
- data/lib/archsight/web/public/vue/cytoscape.esm-5J0xJHOV.js +0 -321
- data/lib/archsight/web/public/vue/dagre-6UL2VRFP-B8ZPRhgU.js +0 -4
- data/lib/archsight/web/public/vue/diagram-PSM6KHXK-Dp1bZnNq.js +0 -24
- data/lib/archsight/web/public/vue/diagram-QEK2KX5R-DZBsDWP6.js +0 -43
- data/lib/archsight/web/public/vue/diagram-S2PKOQOG-BKOnLWNk.js +0 -24
- data/lib/archsight/web/public/vue/erDiagram-Q2GNP2WA-V6FqHPc9.js +0 -60
- data/lib/archsight/web/public/vue/flowDiagram-NV44I4VS-BipXjPVT.js +0 -162
- data/lib/archsight/web/public/vue/ganttDiagram-JELNMOA3-DUdVPVK1.js +0 -267
- data/lib/archsight/web/public/vue/gitGraphDiagram-V2S2FVAM-Bcf_apTG.js +0 -65
- data/lib/archsight/web/public/vue/graph-C4e9XILZ.js +0 -1
- data/lib/archsight/web/public/vue/graphviz-CJms5bxZ.js +0 -13
- data/lib/archsight/web/public/vue/index-BUI400cn.js +0 -2
- data/lib/archsight/web/public/vue/index-Tiu4C-Sb.css +0 -1
- data/lib/archsight/web/public/vue/infoDiagram-HS3SLOUP-DBwnExXO.js +0 -2
- data/lib/archsight/web/public/vue/journeyDiagram-XKPGCS4Q-D4b8PEzB.js +0 -139
- data/lib/archsight/web/public/vue/kanban-definition-3W4ZIXB7-R6r4NPxJ.js +0 -89
- data/lib/archsight/web/public/vue/katex-C-M49wc6.js +0 -261
- data/lib/archsight/web/public/vue/layout-CXL8NrCi.js +0 -1
- data/lib/archsight/web/public/vue/mermaid-MmHzOPPB.js +0 -250
- data/lib/archsight/web/public/vue/min-B4MPxNTL.js +0 -1
- data/lib/archsight/web/public/vue/mindmap-definition-VGOIOE7T-BypPT67y.js +0 -68
- data/lib/archsight/web/public/vue/pieDiagram-ADFJNKIX-BJ4nb15u.js +0 -30
- data/lib/archsight/web/public/vue/quadrantDiagram-AYHSOK5B-D4Ee5XRs.js +0 -7
- data/lib/archsight/web/public/vue/requirementDiagram-UZGBJVZJ-DuxpUmt2.js +0 -64
- data/lib/archsight/web/public/vue/sankeyDiagram-TZEHDZUN-CgWlWmza.js +0 -10
- data/lib/archsight/web/public/vue/sequenceDiagram-WL72ISMW-v2l2CbI0.js +0 -145
- data/lib/archsight/web/public/vue/stateDiagram-FKZM4ZOC-B5ROi2_1.js +0 -1
- data/lib/archsight/web/public/vue/stateDiagram-v2-4FDKWEC3-CQhha5H7.js +0 -1
- data/lib/archsight/web/public/vue/timeline-definition-IT6M3QCI-Bnx2HvxP.js +0 -61
- data/lib/archsight/web/public/vue/treemap-GDKQZRPO-BVho_qBy.js +0 -162
- data/lib/archsight/web/public/vue/useHighlight-yg_u-WUA.js +0 -10
- data/lib/archsight/web/public/vue/useMermaid-BPBwn39g.js +0 -1
- data/lib/archsight/web/public/vue/usePanZoom-CgSmFLId.js +0 -11
- data/lib/archsight/web/public/vue/xychartDiagram-PRI3JC2R-CDkeZXF1.js +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0c195f805808234600e39cd6a3f80cf2ee105c9aaec92d29aca78795f634c4e
|
|
4
|
+
data.tar.gz: c0ecdcd65b22cd03567fc3e28a03b5b7eb0607114ba5e5950e05b744b3dd7a5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5380e24d80507b0bb223f9351e5e0efc18cd3d9119dd0c34ee810404992da87714e7d444fd7a3d70f8dfa4dada77edb989018d4b21914100fb1782d5df4f8177
|
|
7
|
+
data.tar.gz: b88868e7b3656eb45920b92a7eed4ea7ae453bacff1b41a659b95bea52210d7c789f05184db51a3de9590b42f4a854b33a64f22f42fce4fc40519a8a18b89e99
|
data/lib/archsight/cli.rb
CHANGED
|
@@ -3,6 +3,70 @@
|
|
|
3
3
|
require "thor"
|
|
4
4
|
|
|
5
5
|
module Archsight
|
|
6
|
+
class ModuleCLI < Thor
|
|
7
|
+
def self.exit_on_failure?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "graph PATH", "Print module dependency graph (DOT) for a repository to stdout"
|
|
12
|
+
option :language, aliases: "-l", type: :string, default: "auto",
|
|
13
|
+
desc: "Language: go, python, java, or auto (default)"
|
|
14
|
+
option :ranksep, type: :numeric, default: 0.6, desc: "Horizontal gap between rank columns"
|
|
15
|
+
option :nodesep, type: :numeric, default: 0.15, desc: "Vertical gap between nodes"
|
|
16
|
+
def graph(path)
|
|
17
|
+
path = File.expand_path(path)
|
|
18
|
+
unless File.directory?(path)
|
|
19
|
+
warn "Error: not a directory: #{path}"
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
load_handlers
|
|
24
|
+
handler_classes = resolve_handler(path, options[:language])
|
|
25
|
+
if handler_classes.empty?
|
|
26
|
+
warn "Could not detect language for #{path} — use --language go|python|java"
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
require "archsight/import/progress"
|
|
31
|
+
stub = Struct.new(:name, :annotations, :path_ref).new("module-graph", {}, nil)
|
|
32
|
+
any_output = false
|
|
33
|
+
handler_classes.each do |handler_class|
|
|
34
|
+
progress = Archsight::Import::Progress.new(output: $stderr)
|
|
35
|
+
handler = handler_class.new(stub, database: nil, resources_dir: Dir.tmpdir,
|
|
36
|
+
progress: progress)
|
|
37
|
+
dot = handler.dot_graph(path: path, ranksep: options[:ranksep],
|
|
38
|
+
nodesep: options[:nodesep])
|
|
39
|
+
next unless dot
|
|
40
|
+
|
|
41
|
+
puts "# #{handler_class.language_name} modules" if handler_classes.size > 1
|
|
42
|
+
puts dot
|
|
43
|
+
any_output = true
|
|
44
|
+
end
|
|
45
|
+
return if any_output
|
|
46
|
+
|
|
47
|
+
warn "No modules found in #{path}"
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def load_handlers
|
|
54
|
+
handlers_dir = File.expand_path("import/handlers", __dir__)
|
|
55
|
+
Dir.glob(File.join(handlers_dir, "*.rb")).each { |f| require f }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_handler(path, language)
|
|
59
|
+
require "archsight/import/registry"
|
|
60
|
+
if language == "auto" || language.nil?
|
|
61
|
+
Archsight::Import::Registry.handlers_for(path)
|
|
62
|
+
else
|
|
63
|
+
handler = Archsight::Import::Registry.handler_for_language(language)
|
|
64
|
+
warn "Unknown language: #{language}. Use go, python, or java." unless handler
|
|
65
|
+
[handler].compact
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
6
70
|
class CLI < Thor
|
|
7
71
|
def self.exit_on_failure?
|
|
8
72
|
true
|
|
@@ -175,6 +239,9 @@ module Archsight
|
|
|
175
239
|
puts "archsight #{Archsight::VERSION}"
|
|
176
240
|
end
|
|
177
241
|
|
|
242
|
+
desc "module SUBCOMMAND", "Module analysis commands (e.g. module graph PATH)"
|
|
243
|
+
subcommand "module", ModuleCLI
|
|
244
|
+
|
|
178
245
|
default_task :version
|
|
179
246
|
|
|
180
247
|
private
|
data/lib/archsight/graph.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
3
5
|
module Archsight
|
|
4
6
|
# Graphvis implements a graphviz abstraction
|
|
5
7
|
class Graphvis
|
|
@@ -54,6 +56,14 @@ module Archsight
|
|
|
54
56
|
val =~ /^<.*>$/ ? "<#{val}>" : val.inspect
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
def defaults(type, attrs = {})
|
|
60
|
+
@file.print " #{type} ["
|
|
61
|
+
attrs.each do |k, v|
|
|
62
|
+
@file.print " #{k.to_s.inspect} = #{value v}"
|
|
63
|
+
end
|
|
64
|
+
@file.puts " ];"
|
|
65
|
+
end
|
|
66
|
+
|
|
57
67
|
def same_rank
|
|
58
68
|
@file.puts " { rank = same; "
|
|
59
69
|
yield(self)
|
|
@@ -305,6 +305,13 @@ class Archsight::Import::Executor
|
|
|
305
305
|
slot_progress = @concurrent_progress.acquire_slot(imp.name)
|
|
306
306
|
|
|
307
307
|
begin
|
|
308
|
+
# Skip work queued before the interrupt — let already-running imports finish
|
|
309
|
+
if @interrupted
|
|
310
|
+
@mutex.synchronize { @executed_this_run.delete(imp.name) }
|
|
311
|
+
slot_progress.complete("Skipped")
|
|
312
|
+
next
|
|
313
|
+
end
|
|
314
|
+
|
|
308
315
|
result = execute_single_import(imp, slot_progress)
|
|
309
316
|
if result == :cached
|
|
310
317
|
slot_progress.complete("Cached")
|
|
@@ -313,11 +320,12 @@ class Archsight::Import::Executor
|
|
|
313
320
|
slot_progress.complete("Done")
|
|
314
321
|
end
|
|
315
322
|
rescue StandardError => e
|
|
323
|
+
detail = [e.message, e.backtrace.first].compact.join(" @ ")
|
|
316
324
|
@mutex.synchronize do
|
|
317
|
-
@failed_imports[imp.name] =
|
|
318
|
-
@first_error ||= { name: imp.name, message:
|
|
325
|
+
@failed_imports[imp.name] = detail
|
|
326
|
+
@first_error ||= { name: imp.name, message: detail }
|
|
319
327
|
end
|
|
320
|
-
slot_progress.error(
|
|
328
|
+
slot_progress.error(detail)
|
|
321
329
|
ensure
|
|
322
330
|
# Update overall progress and release slot
|
|
323
331
|
@concurrent_progress.increment_completed
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
require_relative "grapher"
|
|
5
|
+
require_relative "../registry"
|
|
6
|
+
|
|
7
|
+
# CppGrapher — analyses a C/C++ repository and generates a GraphViz DOT graph
|
|
8
|
+
# of its directory/module structure, stored as architecture/cpp/modules on the
|
|
9
|
+
# TechnologyArtifact.
|
|
10
|
+
#
|
|
11
|
+
# C/C++ has no formal module system, so directory structure is the de facto
|
|
12
|
+
# module boundary. Quoted #include "..." statements express local dependencies;
|
|
13
|
+
# angled #include <...> are system/external headers and are ignored.
|
|
14
|
+
#
|
|
15
|
+
# Supports single-project and multi-project (CMake add_subdirectory) layouts.
|
|
16
|
+
# Pure static regex analysis — no compiler or CMake required.
|
|
17
|
+
#
|
|
18
|
+
# File-to-package mapping:
|
|
19
|
+
# src/engine/core.cpp → project/engine/core → capped → project/engine
|
|
20
|
+
# include/engine/core.h → project/engine/core → capped → project/engine
|
|
21
|
+
# src/renderer.cpp → project/renderer (depth 1, not capped)
|
|
22
|
+
# main.cpp → dep extraction skipped (entry point)
|
|
23
|
+
#
|
|
24
|
+
# Configuration:
|
|
25
|
+
# import/config/path - Path to the C/C++ repository root
|
|
26
|
+
# import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
|
|
27
|
+
# import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
|
|
28
|
+
class Archsight::Import::Handlers::CppGrapher < Archsight::Import::Handlers::Grapher
|
|
29
|
+
def self.language_name = "cpp"
|
|
30
|
+
|
|
31
|
+
def self.applicable?(path)
|
|
32
|
+
File.exist?(File.join(path, "CMakeLists.txt")) ||
|
|
33
|
+
File.exist?(File.join(path, "meson.build")) ||
|
|
34
|
+
Dir.glob(File.join(path, "*.{cpp,c,cc,cxx,h,hpp}")).any? ||
|
|
35
|
+
Dir.glob(File.join(path, "src/*.{cpp,c,cc,cxx}")).any? ||
|
|
36
|
+
Dir.glob(File.join(path, "include/*.{h,hpp,hh}")).any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wrap_single_module?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
SKIP_DIRS = %w[build .build out .git third_party extern external vendor test tests
|
|
44
|
+
googletest gtest CMakeFiles cmake .cache _deps generated].freeze
|
|
45
|
+
MAX_PKG_DEPTH = 2
|
|
46
|
+
SOURCE_EXTS = %w[.cpp .c .cc .cxx .h .hpp .hh .hxx].freeze
|
|
47
|
+
SRC_PREFIXES = %w[src/ include/ lib/ source/].freeze
|
|
48
|
+
INCLUDE_RE = /^\s*#\s*include\s+"([^"]+)"/
|
|
49
|
+
ENTRY_FILES = %w[main.cpp main.c Main.cpp Main.c].freeze
|
|
50
|
+
EXT_RE = /\.(cpp|c|cc|cxx|hpp|h|hh|hxx)\z/
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# ── Module discovery ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def discover_modules(repo_root)
|
|
57
|
+
[[".", cmake_project_name(repo_root) || File.basename(repo_root)]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cmake_project_name(dir)
|
|
61
|
+
cmake = File.join(dir, "CMakeLists.txt")
|
|
62
|
+
return nil unless File.exist?(cmake)
|
|
63
|
+
|
|
64
|
+
content = File.read(cmake, encoding: "utf-8")
|
|
65
|
+
m = content.match(/^\s*project\s*\(\s*([^\s)]+)/i)
|
|
66
|
+
return nil unless m
|
|
67
|
+
|
|
68
|
+
m[1].gsub(/[^a-zA-Z0-9]/, "_").downcase
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── Package collection ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def collect_packages(repo_root, modules, _prefix)
|
|
76
|
+
all_pkgs = {}
|
|
77
|
+
file_registry = build_file_registry(repo_root, modules)
|
|
78
|
+
scan_all_sources(repo_root, modules, file_registry, all_pkgs)
|
|
79
|
+
pkg_set = all_pkgs.keys.to_set
|
|
80
|
+
all_pkgs.each_value { |deps| deps.select! { |d| pkg_set.include?(d) } }
|
|
81
|
+
all_pkgs
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_file_registry(repo_root, modules)
|
|
85
|
+
file_registry = {}
|
|
86
|
+
modules.each do |rel_dir, mod_name|
|
|
87
|
+
mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
|
|
88
|
+
register_files(mod_dir, mod_name, file_registry)
|
|
89
|
+
end
|
|
90
|
+
file_registry
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scan_all_sources(repo_root, modules, file_registry, all_pkgs)
|
|
94
|
+
modules.each do |rel_dir, mod_name|
|
|
95
|
+
mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
|
|
96
|
+
scan_sources(mod_dir, mod_name, file_registry, all_pkgs)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ── File registry ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
# Registers multiple lookup keys per file so that both full-path includes
|
|
103
|
+
# (#include "engine/core.h") and short-form includes (#include "core.h")
|
|
104
|
+
# resolve to the correct package. First registration wins to avoid ambiguity
|
|
105
|
+
# when the same basename appears in multiple directories.
|
|
106
|
+
def register_files(mod_dir, mod_name, file_registry)
|
|
107
|
+
Find.find(mod_dir) do |path|
|
|
108
|
+
if File.directory?(path)
|
|
109
|
+
Find.prune if SKIP_DIRS.include?(File.basename(path))
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
next unless SOURCE_EXTS.include?(File.extname(path))
|
|
113
|
+
|
|
114
|
+
pkg = cap_depth(file_to_pkg(path, mod_dir, mod_name), mod_name)
|
|
115
|
+
rel_parts = path.delete_prefix("#{mod_dir}/").split("/")
|
|
116
|
+
rel_parts.length.times do |i|
|
|
117
|
+
file_registry[rel_parts[i..].join("/")] ||= pkg
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ── Source scanning ───────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def scan_sources(mod_dir, mod_name, file_registry, all_pkgs)
|
|
125
|
+
Find.find(mod_dir) do |path|
|
|
126
|
+
if File.directory?(path)
|
|
127
|
+
Find.prune if SKIP_DIRS.include?(File.basename(path))
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
next unless SOURCE_EXTS.include?(File.extname(path))
|
|
131
|
+
|
|
132
|
+
pkg = cap_depth(file_to_pkg(path, mod_dir, mod_name), mod_name)
|
|
133
|
+
all_pkgs[pkg] ||= []
|
|
134
|
+
|
|
135
|
+
# main.cpp / main.c are entry points — skipping their deps avoids a
|
|
136
|
+
# hub-spoke pattern where every module fans out from the invisible root.
|
|
137
|
+
next if ENTRY_FILES.include?(File.basename(path))
|
|
138
|
+
|
|
139
|
+
extract_includes(path, file_registry).each do |dep|
|
|
140
|
+
next if dep == pkg || all_pkgs[pkg].include?(dep)
|
|
141
|
+
|
|
142
|
+
all_pkgs[pkg] << dep
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def extract_includes(path, file_registry)
|
|
148
|
+
deps = []
|
|
149
|
+
File.foreach(path, encoding: "utf-8") do |line|
|
|
150
|
+
m = line.match(INCLUDE_RE)
|
|
151
|
+
next unless m
|
|
152
|
+
|
|
153
|
+
dep = file_registry[m[1]]
|
|
154
|
+
deps << dep if dep
|
|
155
|
+
end
|
|
156
|
+
deps.uniq
|
|
157
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError, ArgumentError
|
|
158
|
+
[]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ── Package path helpers ──────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
# Maps a source/header file to its package path. Standard source directory
|
|
164
|
+
# prefixes (src/, include/, lib/, source/) are stripped so that files in
|
|
165
|
+
# separate src/ and include/ trees map to the same package hierarchy.
|
|
166
|
+
def file_to_pkg(abs_path, mod_dir, mod_name)
|
|
167
|
+
rel = abs_path.delete_prefix("#{mod_dir}/").sub(EXT_RE, "")
|
|
168
|
+
SRC_PREFIXES.each do |p|
|
|
169
|
+
if rel.start_with?(p)
|
|
170
|
+
rel = rel.delete_prefix(p)
|
|
171
|
+
break
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
return mod_name if rel.empty?
|
|
175
|
+
return rel if rel.start_with?("#{mod_name}/")
|
|
176
|
+
|
|
177
|
+
"#{mod_name}/#{rel}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def cap_depth(pkg, mod_name)
|
|
181
|
+
return pkg if pkg != mod_name && !pkg.start_with?("#{mod_name}/")
|
|
182
|
+
|
|
183
|
+
suffix = pkg.delete_prefix("#{mod_name}/")
|
|
184
|
+
return mod_name if suffix == pkg
|
|
185
|
+
|
|
186
|
+
parts = suffix.split("/")
|
|
187
|
+
return pkg if parts.length <= MAX_PKG_DEPTH - 1
|
|
188
|
+
|
|
189
|
+
"#{mod_name}/#{parts.first(MAX_PKG_DEPTH - 1).join("/")}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
Archsight::Import::Registry.register("cpp-grapher", Archsight::Import::Handlers::CppGrapher)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "grapher"
|
|
4
|
+
require_relative "../registry"
|
|
5
|
+
|
|
6
|
+
# CrystalGrapher — analyses a Crystal repository and generates a GraphViz DOT
|
|
7
|
+
# graph of its shard/module structure, stored as architecture/crystal/modules
|
|
8
|
+
# on the TechnologyArtifact.
|
|
9
|
+
#
|
|
10
|
+
# Supports single-shard projects (shard.yml at root) and multi-shard monorepos
|
|
11
|
+
# (*/shard.yml). Uses pure static regex analysis of relative require statements
|
|
12
|
+
# — no Crystal toolchain required.
|
|
13
|
+
#
|
|
14
|
+
# Only relative requires (./foo or ../bar) point to internal code; absolute
|
|
15
|
+
# requires (require "shard_name") address installed shards in lib/ and are ignored.
|
|
16
|
+
#
|
|
17
|
+
# Configuration:
|
|
18
|
+
# import/config/path - Path to the Crystal repository root
|
|
19
|
+
# import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
|
|
20
|
+
# import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
|
|
21
|
+
class Archsight::Import::Handlers::CrystalGrapher < Archsight::Import::Handlers::Grapher
|
|
22
|
+
def self.language_name = "crystal"
|
|
23
|
+
|
|
24
|
+
def self.applicable?(path)
|
|
25
|
+
File.exist?(File.join(path, "shard.yml")) ||
|
|
26
|
+
Dir.glob(File.join(path, "*/shard.yml")).any?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def wrap_single_module?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
SKIP_DIRS = %w[lib spec .git bin .crystal tmp coverage].freeze
|
|
34
|
+
|
|
35
|
+
# MAX_PKG_DEPTH = 2: my_shard/feature is the natural depth for Crystal shards.
|
|
36
|
+
MAX_PKG_DEPTH = 2
|
|
37
|
+
|
|
38
|
+
# Only relative requires point to internal code; absolute requires are external shards.
|
|
39
|
+
REQUIRE_RE = %r{^\s*require\s+"(\.\.?/[^"]+)"}
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# ── Module discovery ─────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def discover_modules(repo_root)
|
|
46
|
+
root_yml = File.join(repo_root, "shard.yml")
|
|
47
|
+
|
|
48
|
+
if File.exist?(root_yml) && parse_shard_name(root_yml)
|
|
49
|
+
mod_name = parse_shard_name(root_yml) ||
|
|
50
|
+
src_top_dir(File.join(repo_root, "src")) ||
|
|
51
|
+
File.basename(repo_root)
|
|
52
|
+
return [[".", mod_name]]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sub_shards = Dir.glob(File.join(repo_root, "*/shard.yml")).filter_map do |yml|
|
|
56
|
+
sub_dir = File.dirname(yml)
|
|
57
|
+
rel_dir = sub_dir.delete_prefix("#{repo_root}/")
|
|
58
|
+
next if SKIP_DIRS.any? { |d| rel_dir.split("/").include?(d) }
|
|
59
|
+
|
|
60
|
+
mod_name = parse_shard_name(yml) || File.basename(sub_dir)
|
|
61
|
+
[rel_dir, mod_name]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return sub_shards if sub_shards.any?
|
|
65
|
+
|
|
66
|
+
# Fallback: shard.yml exists but has no name
|
|
67
|
+
mod_name = src_top_dir(File.join(repo_root, "src")) || File.basename(repo_root)
|
|
68
|
+
[[".", mod_name]]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_shard_name(shard_yml_path)
|
|
72
|
+
content = File.read(shard_yml_path, encoding: "utf-8")
|
|
73
|
+
content.match(/^name:\s*(\S+)/)[1]
|
|
74
|
+
rescue StandardError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def src_top_dir(src_dir)
|
|
79
|
+
return nil unless Dir.exist?(src_dir)
|
|
80
|
+
|
|
81
|
+
dirs = Dir.children(src_dir).select { |e| File.directory?(File.join(src_dir, e)) }
|
|
82
|
+
dirs.length == 1 ? dirs.first : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ── Package collection ────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def collect_packages(repo_root, modules, _prefix)
|
|
88
|
+
src_dirs = modules.each_with_object({}) do |(rel_dir, mod_name), h|
|
|
89
|
+
mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
|
|
90
|
+
src_dir = Dir.exist?(File.join(mod_dir, "src")) ? File.join(mod_dir, "src") : mod_dir
|
|
91
|
+
h[src_dir] = mod_name
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
all_pkgs = {}
|
|
95
|
+
|
|
96
|
+
src_dirs.each do |src_dir, mod_name|
|
|
97
|
+
scan_src_dir(src_dir, mod_name, src_dirs, all_pkgs)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Drop deps that don't correspond to any scanned package (removes references to
|
|
101
|
+
# Crystal stdlib modules whose prefix happens to match an internal namespace).
|
|
102
|
+
pkg_set = all_pkgs.keys.to_set
|
|
103
|
+
all_pkgs.each_value { |deps| deps.select! { |d| pkg_set.include?(d) } }
|
|
104
|
+
|
|
105
|
+
all_pkgs
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ── Scanning ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def scan_src_dir(src_dir, mod_name, src_dirs, all_pkgs)
|
|
111
|
+
safe_glob(File.join(src_dir, "**", "*.cr")).each do |cr_file|
|
|
112
|
+
rel_parts = cr_file.delete_prefix("#{src_dir}/").split("/")
|
|
113
|
+
next if rel_parts.any? { |p| SKIP_DIRS.include?(p) }
|
|
114
|
+
|
|
115
|
+
pkg = cap_depth(file_to_pkg(cr_file, src_dir, mod_name), mod_name)
|
|
116
|
+
all_pkgs[pkg] ||= []
|
|
117
|
+
|
|
118
|
+
extract_deps(cr_file, src_dirs).each do |dep|
|
|
119
|
+
dep = cap_depth(dep, mod_name)
|
|
120
|
+
next if dep == pkg || all_pkgs[pkg].include?(dep)
|
|
121
|
+
|
|
122
|
+
all_pkgs[pkg] << dep
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ── Require extraction ────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def extract_deps(cr_file, src_dirs)
|
|
130
|
+
content = File.read(cr_file, encoding: "utf-8")
|
|
131
|
+
base_dir = File.dirname(cr_file)
|
|
132
|
+
deps = []
|
|
133
|
+
|
|
134
|
+
content.scan(REQUIRE_RE) do |(req)|
|
|
135
|
+
clean = req.delete_suffix(".cr")
|
|
136
|
+
expanded = File.expand_path(clean, base_dir)
|
|
137
|
+
dep = resolve_cr_path(expanded, src_dirs)
|
|
138
|
+
deps << dep if dep
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
deps.uniq
|
|
142
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
143
|
+
[]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Crystal convention: require "./foo" resolves as foo.cr OR foo/foo.cr (directory require).
|
|
147
|
+
def resolve_cr_path(expanded, src_dirs)
|
|
148
|
+
candidates = ["#{expanded}.cr", "#{expanded}/#{File.basename(expanded)}.cr"]
|
|
149
|
+
candidates.each do |candidate|
|
|
150
|
+
src_dirs.each do |src_dir, mod_name|
|
|
151
|
+
next unless candidate.start_with?("#{src_dir}/")
|
|
152
|
+
|
|
153
|
+
return file_to_pkg(candidate, src_dir, mod_name)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ── Package path helpers ──────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
# Uses full src-relative path (minus .cr extension). Prepends mod_name when the
|
|
162
|
+
# path doesn't already carry it — handles both layouts:
|
|
163
|
+
# src/my_shard/feature.cr (src_dir = src/) → my_shard/feature
|
|
164
|
+
# src/feature.cr (src_dir = src/my_shard/) → my_shard/feature
|
|
165
|
+
def file_to_pkg(abs_path, src_dir, mod_name)
|
|
166
|
+
rel = abs_path.delete_prefix("#{src_dir}/").delete_suffix(".cr")
|
|
167
|
+
return mod_name if rel == mod_name
|
|
168
|
+
return rel if rel.start_with?("#{mod_name}/")
|
|
169
|
+
|
|
170
|
+
"#{mod_name}/#{rel}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def cap_depth(pkg, mod_name)
|
|
174
|
+
return pkg if pkg != mod_name && !pkg.start_with?("#{mod_name}/")
|
|
175
|
+
|
|
176
|
+
suffix = pkg.delete_prefix("#{mod_name}/")
|
|
177
|
+
return mod_name if suffix == pkg
|
|
178
|
+
|
|
179
|
+
parts = suffix.split("/")
|
|
180
|
+
return pkg if parts.length <= MAX_PKG_DEPTH - 1
|
|
181
|
+
|
|
182
|
+
"#{mod_name}/#{parts.first(MAX_PKG_DEPTH - 1).join("/")}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
Archsight::Import::Registry.register("crystal-grapher", Archsight::Import::Handlers::CrystalGrapher)
|