archsight 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/lib/archsight/cli.rb +67 -0
  3. data/lib/archsight/graph.rb +10 -0
  4. data/lib/archsight/import/executor.rb +11 -3
  5. data/lib/archsight/import/handlers/cpp_grapher.rb +193 -0
  6. data/lib/archsight/import/handlers/crystal_grapher.rb +186 -0
  7. data/lib/archsight/import/handlers/elixir_grapher.rb +202 -0
  8. data/lib/archsight/import/handlers/go_grapher.rb +127 -0
  9. data/lib/archsight/import/handlers/grapher.rb +552 -0
  10. data/lib/archsight/import/handlers/java_grapher.rb +286 -0
  11. data/lib/archsight/import/handlers/javascript_grapher.rb +340 -0
  12. data/lib/archsight/import/handlers/python_grapher.rb +270 -0
  13. data/lib/archsight/import/handlers/repository.rb +41 -17
  14. data/lib/archsight/import/handlers/ruby_grapher.rb +203 -0
  15. data/lib/archsight/import/handlers/rust_grapher.rb +227 -0
  16. data/lib/archsight/import/registry.rb +23 -0
  17. data/lib/archsight/resources/import.rb +1 -0
  18. data/lib/archsight/resources/technology_artifact.rb +17 -0
  19. data/lib/archsight/version.rb +1 -1
  20. data/lib/archsight/web/api/json_helpers.rb +1 -1
  21. data/lib/archsight/web/public/vue/ApiDocsPage-C0y953v0.css +1 -0
  22. data/lib/archsight/web/public/vue/ApiDocsPage-DHSCaHEn.js +1 -0
  23. data/lib/archsight/web/public/vue/DocPage-DszOPlFy.js +1 -0
  24. data/lib/archsight/web/public/vue/EditorPage-CPZ0Ei4l.css +1 -0
  25. data/lib/archsight/web/public/vue/EditorPage-DsiuZ7fg.js +35 -0
  26. data/lib/archsight/web/public/vue/ErrorPage-C4JutrYc.js +2 -0
  27. data/lib/archsight/web/public/vue/ErrorPage-uMDnfY5_.css +1 -0
  28. data/lib/archsight/web/public/vue/GraphView-Bqlbt6dK.js +1 -0
  29. data/lib/archsight/web/public/vue/GraphView-Cj2V2stN.css +1 -0
  30. data/lib/archsight/web/public/vue/InstanceRouter-D8SEY2eu.js +2 -0
  31. data/lib/archsight/web/public/vue/InstanceRouter-D9hclKFt.css +1 -0
  32. data/lib/archsight/web/public/vue/KindList-CPDaNron.js +1 -0
  33. data/lib/archsight/web/public/vue/ResourceList-B5w9yiyS.js +1 -0
  34. data/lib/archsight/web/public/vue/ResourceList-DxZfNbOg.css +1 -0
  35. data/lib/archsight/web/public/vue/SearchResults-DSHpVO-c.css +1 -0
  36. data/lib/archsight/web/public/vue/SearchResults-FpkhdBFu.js +1 -0
  37. data/lib/archsight/web/public/vue/architecture-7EHR7CIX-DpNNjAIc.js +1 -0
  38. data/lib/archsight/web/public/vue/eventmodeling-FCH6USID-CiThxoWl.js +1 -0
  39. data/lib/archsight/web/public/vue/gitGraph-WXDBUCRP-BODMGpAm.js +1 -0
  40. data/lib/archsight/web/public/vue/graphviz-09t3o0af.js +13 -0
  41. data/lib/archsight/web/public/vue/index-BW0IzY6X.css +1 -0
  42. data/lib/archsight/web/public/vue/index-T1YqCmM1.js +2 -0
  43. data/lib/archsight/web/public/vue/info-J43DQDTF-fLq04sri.js +1 -0
  44. data/lib/archsight/web/public/vue/katex-5qHlIbPR.js +261 -0
  45. data/lib/archsight/web/public/vue/mermaid-DYyHQk7x.js +3093 -0
  46. data/lib/archsight/web/public/vue/packet-YPE3B663-DoY1fbqu.js +1 -0
  47. data/lib/archsight/web/public/vue/pie-LRSECV5Y-C7ZQVwRe.js +1 -0
  48. data/lib/archsight/web/public/vue/radar-GUYGQ44K-CRtY5oqf.js +1 -0
  49. data/lib/archsight/web/public/vue/rolldown-runtime-QTnfLwEv.js +1 -0
  50. data/lib/archsight/web/public/vue/treeView-BLDUP644-Csx2WLLh.js +1 -0
  51. data/lib/archsight/web/public/vue/treemap-LRROVOQU-CfEnRbTx.js +1 -0
  52. data/lib/archsight/web/public/vue/{useGraphviz-C5lv_BWF.js → useGraphviz-EKSrE4q_.js} +5 -4
  53. data/lib/archsight/web/public/vue/useHighlight-BcVbGyrK.js +10 -0
  54. data/lib/archsight/web/public/vue/useMermaid-CIZxhy_r.js +2 -0
  55. data/lib/archsight/web/public/vue/usePanZoom-C2slpyY9.js +11 -0
  56. data/lib/archsight/web/public/vue/wardley-L42UT6IY-97oUvxhz.js +1 -0
  57. data/lib/archsight/web/public/vue.html +4 -3
  58. metadata +51 -72
  59. data/lib/archsight/web/public/vue/ApiDocsPage-DHOFUCYc.js +0 -1
  60. data/lib/archsight/web/public/vue/ApiDocsPage-DhNTOH4o.css +0 -1
  61. data/lib/archsight/web/public/vue/DocPage-CV66qgTr.js +0 -1
  62. data/lib/archsight/web/public/vue/EditorPage-Dq0MuTnp.css +0 -1
  63. data/lib/archsight/web/public/vue/EditorPage-KqBivY-B.js +0 -34
  64. data/lib/archsight/web/public/vue/ErrorPage-CwPT3JUr.css +0 -1
  65. data/lib/archsight/web/public/vue/ErrorPage-DcbC8Kf1.js +0 -2
  66. data/lib/archsight/web/public/vue/GraphView-Bg_l-F-Q.js +0 -1
  67. data/lib/archsight/web/public/vue/GraphView-DRcIqAiR.css +0 -1
  68. data/lib/archsight/web/public/vue/InstanceRouter-4VEtZM7n.css +0 -1
  69. data/lib/archsight/web/public/vue/InstanceRouter-D-twdyZY.js +0 -2
  70. data/lib/archsight/web/public/vue/KindList-CPImTKNb.js +0 -1
  71. data/lib/archsight/web/public/vue/ResourceList-DMxm0cGh.js +0 -1
  72. data/lib/archsight/web/public/vue/ResourceList-DP-z-j71.css +0 -1
  73. data/lib/archsight/web/public/vue/SearchResults-BGHbg48-.css +0 -1
  74. data/lib/archsight/web/public/vue/SearchResults-BqUHEWHE.js +0 -1
  75. data/lib/archsight/web/public/vue/_baseUniq-BjkdEi26.js +0 -1
  76. data/lib/archsight/web/public/vue/architectureDiagram-VXUJARFQ-JN7CxdtP.js +0 -36
  77. data/lib/archsight/web/public/vue/blockDiagram-VD42YOAC-teziPFHX.js +0 -122
  78. data/lib/archsight/web/public/vue/c4Diagram-YG6GDRKO-CVNrzCCc.js +0 -10
  79. data/lib/archsight/web/public/vue/chunk-4BX2VUAB-BQUARyyA.js +0 -1
  80. data/lib/archsight/web/public/vue/chunk-55IACEB6-BgJn0Waa.js +0 -1
  81. data/lib/archsight/web/public/vue/chunk-B4BG7PRW-0ghAfB1t.js +0 -165
  82. data/lib/archsight/web/public/vue/chunk-DI55MBZ5-HJo1DW2B.js +0 -220
  83. data/lib/archsight/web/public/vue/chunk-FMBD7UC4-C1GwGKgX.js +0 -15
  84. data/lib/archsight/web/public/vue/chunk-QN33PNHL-BOoA1KfJ.js +0 -1
  85. data/lib/archsight/web/public/vue/chunk-QZHKN3VN-BcTNH3IX.js +0 -1
  86. data/lib/archsight/web/public/vue/chunk-TZMSLE5B-58bQF3J5.js +0 -1
  87. data/lib/archsight/web/public/vue/classDiagram-2ON5EDUG-DAA8tpbN.js +0 -1
  88. data/lib/archsight/web/public/vue/classDiagram-v2-WZHVMYZB-DAA8tpbN.js +0 -1
  89. data/lib/archsight/web/public/vue/clone-BPcOyh7U.js +0 -1
  90. data/lib/archsight/web/public/vue/cose-bilkent-S5V4N54A-Bv8iR4rF.js +0 -1
  91. data/lib/archsight/web/public/vue/cytoscape.esm-5J0xJHOV.js +0 -321
  92. data/lib/archsight/web/public/vue/dagre-6UL2VRFP-B8ZPRhgU.js +0 -4
  93. data/lib/archsight/web/public/vue/diagram-PSM6KHXK-Dp1bZnNq.js +0 -24
  94. data/lib/archsight/web/public/vue/diagram-QEK2KX5R-DZBsDWP6.js +0 -43
  95. data/lib/archsight/web/public/vue/diagram-S2PKOQOG-BKOnLWNk.js +0 -24
  96. data/lib/archsight/web/public/vue/erDiagram-Q2GNP2WA-V6FqHPc9.js +0 -60
  97. data/lib/archsight/web/public/vue/flowDiagram-NV44I4VS-BipXjPVT.js +0 -162
  98. data/lib/archsight/web/public/vue/ganttDiagram-JELNMOA3-DUdVPVK1.js +0 -267
  99. data/lib/archsight/web/public/vue/gitGraphDiagram-V2S2FVAM-Bcf_apTG.js +0 -65
  100. data/lib/archsight/web/public/vue/graph-C4e9XILZ.js +0 -1
  101. data/lib/archsight/web/public/vue/graphviz-CJms5bxZ.js +0 -13
  102. data/lib/archsight/web/public/vue/index-BUI400cn.js +0 -2
  103. data/lib/archsight/web/public/vue/index-Tiu4C-Sb.css +0 -1
  104. data/lib/archsight/web/public/vue/infoDiagram-HS3SLOUP-DBwnExXO.js +0 -2
  105. data/lib/archsight/web/public/vue/journeyDiagram-XKPGCS4Q-D4b8PEzB.js +0 -139
  106. data/lib/archsight/web/public/vue/kanban-definition-3W4ZIXB7-R6r4NPxJ.js +0 -89
  107. data/lib/archsight/web/public/vue/katex-C-M49wc6.js +0 -261
  108. data/lib/archsight/web/public/vue/layout-CXL8NrCi.js +0 -1
  109. data/lib/archsight/web/public/vue/mermaid-MmHzOPPB.js +0 -250
  110. data/lib/archsight/web/public/vue/min-B4MPxNTL.js +0 -1
  111. data/lib/archsight/web/public/vue/mindmap-definition-VGOIOE7T-BypPT67y.js +0 -68
  112. data/lib/archsight/web/public/vue/pieDiagram-ADFJNKIX-BJ4nb15u.js +0 -30
  113. data/lib/archsight/web/public/vue/quadrantDiagram-AYHSOK5B-D4Ee5XRs.js +0 -7
  114. data/lib/archsight/web/public/vue/requirementDiagram-UZGBJVZJ-DuxpUmt2.js +0 -64
  115. data/lib/archsight/web/public/vue/sankeyDiagram-TZEHDZUN-CgWlWmza.js +0 -10
  116. data/lib/archsight/web/public/vue/sequenceDiagram-WL72ISMW-v2l2CbI0.js +0 -145
  117. data/lib/archsight/web/public/vue/stateDiagram-FKZM4ZOC-B5ROi2_1.js +0 -1
  118. data/lib/archsight/web/public/vue/stateDiagram-v2-4FDKWEC3-CQhha5H7.js +0 -1
  119. data/lib/archsight/web/public/vue/timeline-definition-IT6M3QCI-Bnx2HvxP.js +0 -61
  120. data/lib/archsight/web/public/vue/treemap-GDKQZRPO-BVho_qBy.js +0 -162
  121. data/lib/archsight/web/public/vue/useHighlight-yg_u-WUA.js +0 -10
  122. data/lib/archsight/web/public/vue/useMermaid-BPBwn39g.js +0 -1
  123. data/lib/archsight/web/public/vue/usePanZoom-CgSmFLId.js +0 -11
  124. data/lib/archsight/web/public/vue/xychartDiagram-PRI3JC2R-CDkeZXF1.js +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3915931a74e1a99c1916113fb6a4002d35384aa3c7d40858c62ec7923b42bf83
4
- data.tar.gz: eb585529128c07c269dec0bdea9bd48199d0fc55fc23753d52f53d4de4d87f0a
3
+ metadata.gz: e0c195f805808234600e39cd6a3f80cf2ee105c9aaec92d29aca78795f634c4e
4
+ data.tar.gz: c0ecdcd65b22cd03567fc3e28a03b5b7eb0607114ba5e5950e05b744b3dd7a5f
5
5
  SHA512:
6
- metadata.gz: 9ebe5f64aab31b80c4d8f39280312850ab6c0f131be1fdcfc24355dc219506b1d2ffa597549dc2e3e453ab086b2c14bc4dd685bd850c7d72464845889b2e46f9
7
- data.tar.gz: 267d143d4ecfc7e0d40580dec15fc0c208d51fcc4ebd2b65a3b81565d9a6613df7bf1b3e8da9cae0d7ccaa0505d93fe02bdba2729d624decf80314d3dc930815
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
@@ -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] = e.message
318
- @first_error ||= { name: imp.name, message: e.message }
325
+ @failed_imports[imp.name] = detail
326
+ @first_error ||= { name: imp.name, message: detail }
319
327
  end
320
- slot_progress.error(e.message)
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)