archsight 0.3.2 → 0.4.0

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 (189) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -1
  3. data/docs/archimate.md +21 -1
  4. data/docs/configuration.md +1 -0
  5. data/docs/diagram.md +15 -3
  6. data/docs/docker.md +4 -1
  7. data/docs/icons.md +32 -0
  8. data/docs/import.md +178 -55
  9. data/docs/index.md.erb +31 -8
  10. data/docs/modeling.md +772 -24
  11. data/docs/pages.md +44 -13
  12. data/docs/search.md +21 -1
  13. data/docs/togaf.md +5 -1
  14. data/lib/archsight/analysis/result.rb +8 -2
  15. data/lib/archsight/analysis/sandbox.rb +8 -0
  16. data/lib/archsight/analysis/typst_renderer.rb +246 -0
  17. data/lib/archsight/analysis.rb +1 -0
  18. data/lib/archsight/annotations/annotation.rb +30 -3
  19. data/lib/archsight/annotations/architecture_annotations.rb +47 -0
  20. data/lib/archsight/annotations/asset_annotations.rb +34 -0
  21. data/lib/archsight/annotations/link_annotations.rb +15 -0
  22. data/lib/archsight/annotations/resource_reference.rb +12 -0
  23. data/lib/archsight/annotations/risk_annotations.rb +21 -0
  24. data/lib/archsight/cli.rb +124 -23
  25. data/lib/archsight/database.rb +40 -0
  26. data/lib/archsight/diagram/cli.rb +50 -2
  27. data/lib/archsight/diagram/formatter.rb +112 -0
  28. data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
  29. data/lib/archsight/diagram/routing/edge_router.rb +29 -4
  30. data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
  31. data/lib/archsight/diagram/style/theme.rb +7 -2
  32. data/lib/archsight/documentation.rb +3 -2
  33. data/lib/archsight/editor/page_source.rb +13 -1
  34. data/lib/archsight/export/confluence/analysis_output.rb +101 -0
  35. data/lib/archsight/export/confluence/client.rb +13 -0
  36. data/lib/archsight/export/confluence/diagram_links.rb +1 -1
  37. data/lib/archsight/export/confluence/exporter.rb +40 -13
  38. data/lib/archsight/export/confluence/labels.rb +25 -0
  39. data/lib/archsight/export/confluence/page_properties.rb +4 -6
  40. data/lib/archsight/export/confluence/storage.rb +38 -2
  41. data/lib/archsight/helpers/embeds.rb +18 -7
  42. data/lib/archsight/helpers/resource_resolver.rb +25 -0
  43. data/lib/archsight/import/executor.rb +139 -16
  44. data/lib/archsight/import/handler.rb +55 -6
  45. data/lib/archsight/import/handler_loader.rb +64 -0
  46. data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
  47. data/lib/archsight/import/handlers/command.rb +120 -0
  48. data/lib/archsight/import/handlers/github.rb +4 -1
  49. data/lib/archsight/import/handlers/gitlab.rb +4 -1
  50. data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
  51. data/lib/archsight/import/handlers/go_grapher.rb +102 -5
  52. data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
  53. data/lib/archsight/import/handlers/grapher.rb +40 -1
  54. data/lib/archsight/import/handlers/repository.rb +3 -0
  55. data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
  56. data/lib/archsight/import/registry.rb +22 -1
  57. data/lib/archsight/import.rb +64 -1
  58. data/lib/archsight/linter/structure.rb +239 -0
  59. data/lib/archsight/linter.rb +217 -6
  60. data/lib/archsight/page_loader.rb +12 -1
  61. data/lib/archsight/query/evaluator.rb +13 -6
  62. data/lib/archsight/query/parser.rb +73 -6
  63. data/lib/archsight/query.rb +1 -1
  64. data/lib/archsight/references.rb +3 -3
  65. data/lib/archsight/renderer.rb +80 -23
  66. data/lib/archsight/resources/analysis.rb +3 -2
  67. data/lib/archsight/resources/application_component.rb +83 -8
  68. data/lib/archsight/resources/application_event.rb +79 -0
  69. data/lib/archsight/resources/application_service.rb +18 -2
  70. data/lib/archsight/resources/base.rb +58 -5
  71. data/lib/archsight/resources/business_actor.rb +14 -1
  72. data/lib/archsight/resources/business_contract.rb +72 -0
  73. data/lib/archsight/resources/business_control.rb +10 -2
  74. data/lib/archsight/resources/business_event.rb +79 -0
  75. data/lib/archsight/resources/business_process.rb +8 -1
  76. data/lib/archsight/resources/business_product.rb +12 -0
  77. data/lib/archsight/resources/business_role.rb +70 -0
  78. data/lib/archsight/resources/compliance_evidence.rb +8 -2
  79. data/lib/archsight/resources/data_object.rb +6 -1
  80. data/lib/archsight/resources/implementation_deliverable.rb +63 -0
  81. data/lib/archsight/resources/implementation_event.rb +52 -0
  82. data/lib/archsight/resources/implementation_gap.rb +49 -0
  83. data/lib/archsight/resources/implementation_plateau.rb +67 -0
  84. data/lib/archsight/resources/implementation_work_package.rb +84 -0
  85. data/lib/archsight/resources/import.rb +37 -11
  86. data/lib/archsight/resources/location.rb +56 -0
  87. data/lib/archsight/resources/motivation_assessment.rb +121 -0
  88. data/lib/archsight/resources/motivation_constraint.rb +7 -1
  89. data/lib/archsight/resources/motivation_driver.rb +50 -0
  90. data/lib/archsight/resources/motivation_goal.rb +13 -1
  91. data/lib/archsight/resources/motivation_principle.rb +75 -0
  92. data/lib/archsight/resources/motivation_requirement.rb +18 -2
  93. data/lib/archsight/resources/motivation_stakeholder.rb +21 -0
  94. data/lib/archsight/resources/page.rb +9 -7
  95. data/lib/archsight/resources/strategy_capability.rb +9 -0
  96. data/lib/archsight/resources/technology_artifact.rb +11 -4
  97. data/lib/archsight/resources/technology_communication_network.rb +89 -0
  98. data/lib/archsight/resources/technology_device.rb +88 -0
  99. data/lib/archsight/resources/technology_equipment.rb +75 -0
  100. data/lib/archsight/resources/technology_event.rb +80 -0
  101. data/lib/archsight/resources/technology_facility.rb +70 -0
  102. data/lib/archsight/resources/technology_node.rb +57 -1
  103. data/lib/archsight/resources/technology_service.rb +33 -0
  104. data/lib/archsight/resources/technology_system_software.rb +28 -0
  105. data/lib/archsight/resources.rb +12 -0
  106. data/lib/archsight/version.rb +1 -1
  107. data/lib/archsight/web/api/analysis_helpers.rb +55 -0
  108. data/lib/archsight/web/api/json_helpers.rb +3 -37
  109. data/lib/archsight/web/api/openapi/spec.yaml +10 -8
  110. data/lib/archsight/web/api/page_helpers.rb +1 -8
  111. data/lib/archsight/web/api/routes.rb +7 -2
  112. data/lib/archsight/web/application.rb +38 -2
  113. data/lib/archsight/web/editor/routes.rb +2 -1
  114. data/lib/archsight/web/public/vue/ApiDocsPage-COp4onwn.js +1 -0
  115. data/lib/archsight/web/public/vue/DocPage-D52OwaKc.js +1 -0
  116. data/lib/archsight/web/public/vue/EditorPage-3aqL7yVf.js +35 -0
  117. data/lib/archsight/web/public/vue/ErrorPage--b9XeSYa.js +2 -0
  118. data/lib/archsight/web/public/vue/GraphView-qh_EtRrQ.js +1 -0
  119. data/lib/archsight/web/public/vue/HomePage-C2TNh2U-.js +2 -0
  120. data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
  121. data/lib/archsight/web/public/vue/InstanceRouter-uzb_OMG0.js +1 -0
  122. data/lib/archsight/web/public/vue/KindList-CyJuP_uq.js +1 -0
  123. data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
  124. data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
  125. data/lib/archsight/web/public/vue/PageView-FyygrS-b.js +1 -0
  126. data/lib/archsight/web/public/vue/QueryError-B4Ex_csZ.js +1 -0
  127. data/lib/archsight/web/public/vue/ResourceList-BY6Z1Kcb.js +2 -0
  128. data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
  129. data/lib/archsight/web/public/vue/{SearchResults-CyPUZZSC.css → SearchResults-BV6UHriJ.css} +1 -1
  130. data/lib/archsight/web/public/vue/SearchResults-CkFpHF8m.js +1 -0
  131. data/lib/archsight/web/public/vue/WikiPage-BoHNewCX.js +3 -0
  132. data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
  133. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BvzKZE4e.js +1 -0
  134. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-D-xs_Hxh.js +1 -0
  135. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-nbjxG01S.js +1 -0
  136. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-CW6mw2ab.js +1 -0
  137. data/lib/archsight/web/public/vue/index-BGlYCbi7.js +3 -0
  138. data/lib/archsight/web/public/vue/index-eUHmJ7K2.css +1 -0
  139. data/lib/archsight/web/public/vue/info-A6RAGUB7-C12sjlHe.js +1 -0
  140. data/lib/archsight/web/public/vue/{mermaid-BjEi5URd.js → mermaid-DjsXQ_XN.js} +35 -35
  141. data/lib/archsight/web/public/vue/packet-AYTQ26CC-wMwKwHmA.js +1 -0
  142. data/lib/archsight/web/public/vue/pie-WAS4IAKB-w1-sAU_K.js +1 -0
  143. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-DpwqIFoy.js +1 -0
  144. data/lib/archsight/web/public/vue/railroad-74A4TZTK-Ce5XC24d.js +1 -0
  145. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-BSQO2pds.js +1 -0
  146. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-Dq1c2YtD.js +1 -0
  147. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DKj6dli0.js +1 -0
  148. data/lib/archsight/web/public/vue/rolldown-runtime-hePW80VL.js +1 -0
  149. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-BmHNTWxU.js +1 -0
  150. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DsEI7sSD.js +1 -0
  151. data/lib/archsight/web/public/vue/{useGraphviz-DweKV7Kg.js → useGraphviz-4evusK9i.js} +13 -1
  152. data/lib/archsight/web/public/vue/{useHighlight-D5Gf8DLl.js → useHighlight-DPguaxfm.js} +8 -8
  153. data/lib/archsight/web/public/vue/usePanZoom-seQE1TPk.js +12 -0
  154. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-CSCW83-M.js +1 -0
  155. data/lib/archsight/web/public/vue.html +5 -5
  156. metadata +72 -38
  157. data/lib/archsight/web/public/vue/ApiDocsPage-DoOxKjG0.js +0 -1
  158. data/lib/archsight/web/public/vue/DocPage-CfsC3CeQ.js +0 -1
  159. data/lib/archsight/web/public/vue/EditorPage-CsJA0q8n.js +0 -35
  160. data/lib/archsight/web/public/vue/ErrorPage-OJpJz9df.js +0 -2
  161. data/lib/archsight/web/public/vue/GraphView-CBq6oFRV.js +0 -1
  162. data/lib/archsight/web/public/vue/HomePage-BwgMLgVC.js +0 -2
  163. data/lib/archsight/web/public/vue/InstanceRouter-DQz-SsQq.js +0 -1
  164. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
  165. data/lib/archsight/web/public/vue/KindList-4q0K9Cll.js +0 -1
  166. data/lib/archsight/web/public/vue/PageView-DzeGnvoS.js +0 -1
  167. data/lib/archsight/web/public/vue/QueryError-kg6Yf-pW.js +0 -1
  168. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
  169. data/lib/archsight/web/public/vue/ResourceList-Df_0e0qt.js +0 -2
  170. data/lib/archsight/web/public/vue/SearchResults-CKgY6_wH.js +0 -1
  171. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
  172. data/lib/archsight/web/public/vue/WikiPage-DrvCHwJ7.js +0 -13
  173. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
  174. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
  175. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
  176. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
  177. data/lib/archsight/web/public/vue/index-D0Q5GZRs.js +0 -3
  178. data/lib/archsight/web/public/vue/index-oF-iyZvk.css +0 -1
  179. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
  180. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
  181. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
  182. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
  183. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
  184. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
  185. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
  186. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
  187. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
  188. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
  189. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "tmpdir"
6
+ require "yaml"
7
+ require_relative "../handler"
8
+ require_relative "../registry"
9
+
10
+ # Command handler - runs a program of the resources repository as an import, in any language. The program writes its
11
+ # resources itself (usually to ARCHSIGHT_OUTPUT_PATH); archsight adds the self marker, so caching, `import/dependsOn`,
12
+ # `-f` and `-n` work as for any other import.
13
+ #
14
+ # The program runs in the root of the repository that holds the resources directory (the resources directory when it
15
+ # is not in a git repository), with a clean environment: PATH, HOME, LANG and the variables below, plus the variables
16
+ # listed in `import/config/env` (credentials, for example).
17
+ #
18
+ # ARCHSIGHT_RESOURCES_DIR - the resources directory
19
+ # ARCHSIGHT_CACHE_DIR - where the git-based imports clone repositories
20
+ # ARCHSIGHT_IMPORT_NAME - the name of the Import
21
+ # ARCHSIGHT_OUTPUT_PATH - a file for the YAML resources the program generates; archsight writes them unchanged to
22
+ # import/outputPath (generated/<name>.yaml without one), followed by the import's marker
23
+ #
24
+ # The output file belongs to the import: it is replaced on every run and removed when outputPath changes. Documents
25
+ # are not marked as generated; a program that wants that sets `generated/script: <import name>` itself (only on kinds
26
+ # that have the `generated` annotations), while documents that add relations to hand-written resources stay unmarked.
27
+ #
28
+ # Configuration:
29
+ # import/config/command - the command line (split like a shell does, but run without one; use `sh -c '...'` for
30
+ # pipes); a relative program path is relative to the working directory
31
+ # import/config/env - names of further environment variables to pass, comma separated
32
+ # import/config/outputs - files the program writes besides the output path, glob patterns relative to the
33
+ # resources directory, comma separated; recorded like Handler#write_file
34
+ # import/config/workdir - working directory relative to the resources directory (default: the repository root)
35
+ class Archsight::Import::Handlers::Command < Archsight::Import::Handler
36
+ PASSED_ENV = %w[PATH HOME LANG LC_ALL TMPDIR].freeze
37
+
38
+ def execute
39
+ command = Shellwords.split(config("command").to_s)
40
+ raise "Missing required config: command" if command.empty?
41
+
42
+ Dir.mktmpdir("archsight-command") do |tmp|
43
+ output_path = File.join(tmp, "output.yaml")
44
+ progress.update("Running #{command.first}")
45
+ out, err, status = Open3.capture3(environment(output_path), *command, chdir: working_directory, unsetenv_others: true)
46
+ raise "Command failed (exit #{status.exitstatus}): #{last_lines(err.empty? ? out : err)}" unless status.success?
47
+
48
+ record_declared_outputs
49
+ write_output(program_output(output_path))
50
+ end
51
+ rescue Errno::ENOENT => e
52
+ raise "Command not found: #{e.message}"
53
+ end
54
+
55
+ private
56
+
57
+ # What the program wrote to ARCHSIGHT_OUTPUT_PATH (a temporary file), as written: it must be YAML documents that
58
+ # are mappings, and stays byte for byte as it is (comments, order, no added annotations)
59
+ def program_output(output_path)
60
+ return "" unless File.file?(output_path)
61
+
62
+ text = File.read(output_path)
63
+ YAML.load_stream(text).compact.each do |document|
64
+ raise "#{import_resource.name}: the command wrote a document that is not a resource" unless document.is_a?(Hash)
65
+ end
66
+ text.end_with?("\n") || text.empty? ? text : "#{text}\n"
67
+ rescue Psych::SyntaxError => e
68
+ raise "The command wrote invalid YAML: #{e.message}"
69
+ end
70
+
71
+ # The output file is the import's own: written whole (not through the shared writer, which would drop the
72
+ # unmarked documents when another import flushes the file) and recorded as an output, so a changed outputPath
73
+ # removes the old file. Recorded before the marker is built, so the marker lists it.
74
+ def write_output(text)
75
+ rel_path = Archsight::Import.output_path(import_resource)
76
+ record_output(rel_path)
77
+ write_file(rel_path, text + YAML.dump(self_marker))
78
+ end
79
+
80
+ def environment(output_path)
81
+ passed = PASSED_ENV + config("env").to_s.split(",").map(&:strip).reject(&:empty?)
82
+ env = passed.to_h { |name| [name, ENV.fetch(name, nil)] }.compact
83
+ env.merge(
84
+ "ARCHSIGHT_RESOURCES_DIR" => File.expand_path(resources_dir),
85
+ "ARCHSIGHT_CACHE_DIR" => cache_root,
86
+ "ARCHSIGHT_IMPORT_NAME" => import_resource.name,
87
+ "ARCHSIGHT_OUTPUT_PATH" => File.expand_path(output_path)
88
+ )
89
+ end
90
+
91
+ def working_directory
92
+ workdir = config("workdir")
93
+ if workdir
94
+ root = File.expand_path(resources_dir)
95
+ path = File.expand_path(workdir, root)
96
+ return path if path == root || path.start_with?("#{root}/")
97
+
98
+ raise "workdir #{workdir} is outside the resources directory"
99
+ end
100
+
101
+ toplevel, _, status = Open3.capture3("git", "rev-parse", "--show-toplevel", chdir: resources_dir)
102
+ status.success? ? toplevel.strip : File.expand_path(resources_dir)
103
+ rescue Errno::ENOENT
104
+ File.expand_path(resources_dir)
105
+ end
106
+
107
+ def record_declared_outputs
108
+ patterns = config("outputs").to_s.split(",").map(&:strip).reject(&:empty?)
109
+ root = File.expand_path(resources_dir)
110
+ patterns.flat_map { |pattern| Dir.glob(pattern, base: root) }.uniq.each do |file|
111
+ record_output(file) if File.file?(File.join(root, file))
112
+ end
113
+ end
114
+
115
+ def last_lines(text)
116
+ text.to_s.lines.last(5).join.strip
117
+ end
118
+ end
119
+
120
+ Archsight::Import::Registry.register("command", Archsight::Import::Handlers::Command)
@@ -35,6 +35,9 @@ require_relative "cache_pruner"
35
35
  # the API (e.g. a repo that was renamed or deleted), so stale clones don't
36
36
  # linger and get picked up by later importers.
37
37
  class Archsight::Import::Handlers::Github < Archsight::Import::Handler
38
+ # generates child imports (see Handler.generates_imports?)
39
+ def self.generates_imports? = true
40
+
38
41
  include Archsight::Import::Handlers::CachePruner
39
42
 
40
43
  PER_PAGE = 100
@@ -49,7 +52,7 @@ class Archsight::Import::Handlers::Github < Archsight::Import::Handler
49
52
  @repo_output_path = config("repoOutputPath")
50
53
  @child_cache_time = config("childCacheTime")
51
54
  @default_visibility = config("defaultVisibility", default: "internal")
52
- cache_root = config("cacheRoot", default: File.join(Dir.home, ".cache", "archsight", "git"))
55
+ cache_root = Archsight::Import.cache_root(config("cacheRoot"))
53
56
  @target_dir = File.join(cache_root, "github", @org)
54
57
 
55
58
  # Fetch all repositories with pagination
@@ -38,6 +38,9 @@ require_relative "cache_pruner"
38
38
  # API (e.g. a project that was renamed, moved, or deleted), so stale clones
39
39
  # don't linger and get picked up by later importers.
40
40
  class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
41
+ # generates child imports (see Handler.generates_imports?)
42
+ def self.generates_imports? = true
43
+
41
44
  include Archsight::Import::Handlers::CachePruner
42
45
 
43
46
  def execute
@@ -51,7 +54,7 @@ class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
51
54
  @child_cache_time = config("childCacheTime")
52
55
  @default_visibility = config("defaultVisibility", default: "internal")
53
56
 
54
- cache_root = config("cacheRoot", default: File.join(Dir.home, ".cache", "archsight", "git"))
57
+ cache_root = Archsight::Import.cache_root(config("cacheRoot"))
55
58
  @target_dir = File.join(cache_root, "gitlab")
56
59
  @explore_groups = config("exploreGroups") == "true"
57
60
  @per_page = config("perPage", default: "100").to_i
@@ -20,13 +20,19 @@ require_relative "../registry"
20
20
  #
21
21
  # Configuration:
22
22
  # import/config/path - Path to the Go repository root (same as GoGrapher)
23
+ # import/config/artifact - Repository artifact the modules belong to; names the component when two repositories
24
+ # declare the same module path (see GoModuleParser#owned_component_name)
23
25
  class Archsight::Import::Handlers::GoDepResolver < Archsight::Import::Handler
24
26
  include Archsight::Import::Handlers::GoModuleParser
25
27
 
26
28
  def execute
27
29
  @path = config_path("path")
28
30
  raise "Missing required config: path" unless @path
29
- raise "Directory not found: #{@path}" unless File.directory?(@path)
31
+
32
+ unless File.directory?(@path)
33
+ progress.warn("Skipping, repository not cloned: #{@path}")
34
+ return
35
+ end
30
36
 
31
37
  import_resource.annotations["import/outputPath"] ||= "generated/relationships.yaml"
32
38
 
@@ -37,11 +43,13 @@ class Archsight::Import::Handlers::GoDepResolver < Archsight::Import::Handler
37
43
  output = +""
38
44
 
39
45
  modules.each do |rel_dir, mod_name|
46
+ next if non_runtime_module?(rel_dir) || copied_module?(rel_dir, mod_name, config("artifact"))
47
+
40
48
  mod_dir = rel_dir == "." ? @path : File.join(@path, rel_dir)
41
49
  dep_names = compute_deps(mod_dir, mod_name, existing_components)
42
50
  next if dep_names.empty?
43
51
 
44
- comp_name = component_name(mod_name)
52
+ comp_name = owned_component_name(mod_name, config("artifact"))
45
53
  existing = existing_components[comp_name]
46
54
  next unless existing
47
55
 
@@ -23,8 +23,18 @@ require_relative "../registry"
23
23
  # import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
24
24
  # import/config/interface_visibility - Visibility prefix for detected interfaces (default: Private)
25
25
  class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grapher
26
+ # generates child imports (see Handler.generates_imports?)
27
+ def self.generates_imports? = true
28
+
26
29
  include Archsight::Import::Handlers::GoModuleParser
27
30
 
31
+ # Directories not scanned for Go packages
32
+ PACKAGE_SKIP_DIRS = %w[vendor testdata node_modules].freeze
33
+ # Packages below these directories are not meant to be imported by other modules
34
+ NON_LIBRARY_DIRS = %w[internal cmd hack tools examples example e2e test tests].freeze
35
+ MAX_ENTRYPOINTS = 10
36
+ IGNORED_BUILD = %r{^(//go:build\s+ignore\b|//\s*\+build\s+ignore\b)}
37
+
28
38
  OPENAPI_FILENAMES = %w[openapi.yaml openapi.yml openapi.json swagger.yaml swagger.yml swagger.json].freeze
29
39
  OPENAPI_SUBDIRS = %w[api docs spec].freeze
30
40
 
@@ -55,7 +65,14 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
55
65
  output = +""
56
66
 
57
67
  modules.each do |rel_dir, mod_name|
58
- comp_name = component_name(mod_name)
68
+ next if non_runtime_module?(rel_dir)
69
+
70
+ if copied_module?(rel_dir, mod_name, artifact_name)
71
+ progress.warn("Skipping copied go.mod at #{rel_dir}: module #{mod_name} belongs to another repository")
72
+ next
73
+ end
74
+
75
+ comp_name = owned_component_name(mod_name, artifact_name)
59
76
  next unless comp_name
60
77
 
61
78
  mod_dir = rel_dir == "." ? path : File.join(path, rel_dir)
@@ -64,6 +81,9 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
64
81
  interface_names = specs.filter_map { |s| interface_name_from_spec(s, visibility: visibility) }
65
82
  .select { |n| existing_interfaces.key?(n) }
66
83
 
84
+ existing = database&.instances_by_kind("ApplicationComponent")&.[](comp_name)
85
+ facts = curated_component_type?(existing) ? {} : component_facts(path, rel_dir, mod_name)
86
+
67
87
  comp_spec = { "realizedThrough" => { "technologyArtifacts" => [artifact_name] } }
68
88
  comp_spec["exposes"] = { "applicationInterfaces" => interface_names } if interface_names.any?
69
89
 
@@ -73,7 +93,8 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
73
93
  component = untracked_resource_yaml(
74
94
  kind: "ApplicationComponent",
75
95
  name: comp_name,
76
- spec: comp_spec
96
+ spec: comp_spec,
97
+ annotations: component_annotations(facts, existing_tags: existing&.annotations&.[]("architecture/tags"))
77
98
  )
78
99
  output << YAML.dump(component)
79
100
  end
@@ -85,7 +106,7 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
85
106
  output << YAML.dump(import_yaml(
86
107
  name: resolver_name,
87
108
  handler: "go-dep-resolver",
88
- config: { "path" => path }
109
+ config: { "path" => path, "artifact" => artifact_name }
89
110
  ))
90
111
 
91
112
  output
@@ -130,7 +151,83 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
130
151
 
131
152
  # ── OpenAPI detection ─────────────────────────────────────────────────────
132
153
 
133
- def untracked_resource_yaml(kind:, name:, spec: {})
154
+ # A component type another source set (a person or an earlier import) is kept; this import's own earlier
155
+ # output is regenerated.
156
+ def curated_component_type?(existing)
157
+ return false unless existing&.annotations&.key?("component/type")
158
+
159
+ existing.annotations["generated/script"] != import_resource.name
160
+ end
161
+
162
+ # Classify a go.mod module from its packages: with `package main` it is an executable, without it a library,
163
+ # and a module with both main packages and importable packages is a module.
164
+ def component_facts(repo_root, rel_dir, _mod_name)
165
+ mod_dir = rel_dir == "." ? repo_root : File.join(repo_root, rel_dir)
166
+ mains, libraries = scan_go_packages(mod_dir)
167
+ type = if mains.empty?
168
+ "library"
169
+ elsif libraries.empty?
170
+ "executable"
171
+ else
172
+ "module"
173
+ end
174
+
175
+ { type: type,
176
+ tags: %w[ecosystem:go packaging:go-module], entrypoints: mains.first(MAX_ENTRYPOINTS) }
177
+ end
178
+
179
+ # @return [Array<Array<String>>] [main package dirs, importable library package dirs], relative to mod_dir
180
+ def scan_go_packages(mod_dir)
181
+ mains = []
182
+ libraries = []
183
+
184
+ Find.find(mod_dir) do |path|
185
+ if File.directory?(path)
186
+ Find.prune if path != mod_dir && skip_go_dir?(path)
187
+ next
188
+ end
189
+ next unless go_source?(path)
190
+
191
+ name = go_package_name(path)
192
+ next unless name
193
+
194
+ dir = File.dirname(path).delete_prefix(mod_dir).delete_prefix("/")
195
+ dir = "." if dir.empty?
196
+ if name == "main"
197
+ mains << dir
198
+ elsif !dir.split("/").intersect?(NON_LIBRARY_DIRS)
199
+ libraries << dir
200
+ end
201
+ end
202
+
203
+ [mains.uniq.sort, libraries.uniq.sort]
204
+ end
205
+
206
+ # Hidden and vendored directories, and nested modules (they are components of their own)
207
+ def skip_go_dir?(path)
208
+ base = File.basename(path)
209
+ PACKAGE_SKIP_DIRS.include?(base) || base.start_with?(".") || File.exist?(File.join(path, "go.mod"))
210
+ end
211
+
212
+ def go_source?(path)
213
+ path.end_with?(".go") && !path.end_with?("_test.go")
214
+ end
215
+
216
+ # Package name of a Go file, nil for a file that is excluded from normal builds (`//go:build ignore`)
217
+ def go_package_name(path)
218
+ head = File.foreach(path, encoding: "utf-8").first(40)
219
+ return nil if head.any? { |line| line.match?(IGNORED_BUILD) }
220
+
221
+ head.each do |line|
222
+ match = line.match(/\Apackage\s+(\w+)/)
223
+ return match[1] if match
224
+ end
225
+ nil
226
+ rescue SystemCallError, ArgumentError
227
+ nil
228
+ end
229
+
230
+ def untracked_resource_yaml(kind:, name:, spec: {}, annotations: {})
134
231
  {
135
232
  "apiVersion" => "architecture/v1alpha1",
136
233
  "kind" => kind,
@@ -139,7 +236,7 @@ class Archsight::Import::Handlers::GoGrapher < Archsight::Import::Handlers::Grap
139
236
  "annotations" => {
140
237
  "generated/script" => import_resource.name,
141
238
  "generated/at" => Time.now.utc.iso8601
142
- }
239
+ }.merge(annotations)
143
240
  },
144
241
  "spec" => spec
145
242
  }
@@ -7,6 +7,13 @@ require "find"
7
7
  # Provides go.mod discovery, module name reading, require parsing, and the
8
8
  # component_name convention (strip SCM host, join path segments with ":").
9
9
  module Archsight::Import::Handlers::GoModuleParser
10
+ # Directories whose nested modules hold code that never runs in production: tests, pinned tooling, examples and
11
+ # benchmarks. Whole path segments only, so `pkg/latest` is not a test directory.
12
+ NON_RUNTIME_MODULE_DIRS = %w[
13
+ hack tools examples example bench benchmarks
14
+ test tests e2e e2etest e2etests systemtest systemtests integrationtest integrationtests fullstack
15
+ ].freeze
16
+
10
17
  # Discover all Go modules in a repository root.
11
18
  # Handles go.work workspaces, single-module repos, and multi-module monorepos
12
19
  # (root go.mod plus subdirectory go.mod files without go.work).
@@ -141,4 +148,85 @@ module Archsight::Import::Handlers::GoModuleParser
141
148
  parts.shift
142
149
  parts.join(":") unless parts.empty?
143
150
  end
151
+
152
+ # The name of the component that the module of one repository gets. Two repositories may declare the same module
153
+ # path (a prototype and its successor, a fork, a copy of the module); without care both would emit the same
154
+ # component and the loader would merge them into one. The repository that owns the plain name is the one the module
155
+ # path names (see #module_path_of_repository?), then the one that is not archived, then the first by name; the others
156
+ # get the repository appended (`alice:shop@acme:shop-prototype`).
157
+ #
158
+ # The other repositories are found through the existing component of the plain name, so a collision is resolved on
159
+ # the import after both repositories were imported once. A component without `generated/script` is curated: a
160
+ # person deliberately lists several repositories, and it keeps the plain name.
161
+ # @return [String, nil]
162
+ def owned_component_name(mod_name, artifact_name)
163
+ base = component_name(mod_name)
164
+ return base unless base && artifact_name
165
+
166
+ others = colliding_artifacts(base, artifact_name)
167
+ return base if others.empty?
168
+
169
+ owner = ([artifact_name] + others).min_by do |name|
170
+ [module_path_of_repository?(mod_name, name) ? 0 : 1, archived_artifact?(name) ? 1 : 0, name]
171
+ end
172
+ owner == artifact_name ? base : "#{base}@#{artifact_name.delete_prefix("Repo:")}"
173
+ end
174
+
175
+ # Whether the module path names the repository: the repository is the module itself or a module below it.
176
+ # `github.com/acme/library` and `github.com/acme/library/v2/client` belong to `Repo:acme:library` (and to
177
+ # `Repo:library`, an artifact named after its directory), not to `Repo:acme:app-poc`. The segments the artifact name
178
+ # and the module path both have must agree, so `github.com/alice/shop` does not name `Repo:acme:shop`.
179
+ # @return [Boolean]
180
+ def module_path_of_repository?(mod_name, artifact_name)
181
+ repo = artifact_name.delete_prefix("Repo:").split(":").reject(&:empty?)
182
+ path = module_path_segments(mod_name)
183
+ return false if repo.empty? || path.empty?
184
+
185
+ path.each_index.any? do |i|
186
+ next false if i.zero? && path.length > 1 # the first segment is the owner, never the repository
187
+
188
+ overlap = [repo.length, i + 1].min
189
+ repo.last(overlap) == path[(i + 1 - overlap)..i]
190
+ end
191
+ end
192
+
193
+ # A module in a subdirectory of `artifact_name` that is a copy of another repository's module: its path does not
194
+ # name this repository, but it names another repository of the database. It gets no component; the repository it
195
+ # was copied from has it.
196
+ # @return [Boolean]
197
+ def copied_module?(rel_dir, mod_name, artifact_name)
198
+ return false if rel_dir == "." || artifact_name.nil? || module_path_of_repository?(mod_name, artifact_name)
199
+
200
+ artifacts = database&.instances_by_kind("TechnologyArtifact") || {}
201
+ artifacts.keys.any? { |name| name.start_with?("Repo:") && name != artifact_name && module_path_of_repository?(mod_name, name) }
202
+ end
203
+
204
+ # A nested module below a test, tool, example or benchmark directory (`test/e2e`, `build/tools`,
205
+ # `internal/systemtest`). It gets no component, since nothing realizes it or depends on it; its packages stay in
206
+ # the module graph of the repository. `cmd/<name>` and `internal/` hold runtime code and are not matched.
207
+ # @return [Boolean]
208
+ def non_runtime_module?(rel_dir)
209
+ return false if rel_dir == "."
210
+
211
+ rel_dir.split("/").intersect?(NON_RUNTIME_MODULE_DIRS)
212
+ end
213
+
214
+ # Path segments of a module without the host and without a major version suffix (`/v2`)
215
+ def module_path_segments(mod_name)
216
+ mod_name.split("/").drop(1).grep_v(/\Av\d+\z/)
217
+ end
218
+
219
+ # Repository artifacts other than `artifact_name` that a generated component of this name is realized through
220
+ def colliding_artifacts(base, artifact_name)
221
+ existing = database&.instances_by_kind("ApplicationComponent")&.[](base)
222
+ return [] unless existing&.annotations&.key?("generated/script")
223
+
224
+ artifacts = Array(existing.raw.dig("spec", "realizedThrough", "technologyArtifacts"))
225
+ artifacts.select { |name| name.start_with?("Repo:") && name != artifact_name }.uniq
226
+ end
227
+
228
+ def archived_artifact?(name)
229
+ artifact = database&.instances_by_kind("TechnologyArtifact")&.[](name)
230
+ artifact&.annotations&.[]("activity/status") == "archived"
231
+ end
144
232
  end
@@ -35,6 +35,11 @@ class Archsight::Import::Handlers::Grapher < Archsight::Import::Handler
35
35
  []
36
36
  end
37
37
 
38
+ # Namespaces of the machine tags (namespace:value) that importers write into architecture/tags.
39
+ # `entrypoint` is no longer written (see component/entrypoints) but stays listed so a re-import
40
+ # removes the tags of earlier runs.
41
+ MACHINE_TAG_NAMESPACES = %w[ecosystem packaging entrypoint linkage].freeze
42
+
38
43
  PALETTE = [
39
44
  { fill: "#ddeeff", edge: "#2266cc" },
40
45
  { fill: "#ddffd8", edge: "#2a8a1e" },
@@ -51,7 +56,13 @@ class Archsight::Import::Handlers::Grapher < Archsight::Import::Handler
51
56
  def execute
52
57
  @path = config_path("path")
53
58
  raise "Missing required config: path" unless @path
54
- raise "Directory not found: #{@path}" unless File.directory?(@path)
59
+
60
+ unless File.directory?(@path)
61
+ # The clone is the repository import's job (it reports its own failure);
62
+ # no marker is written, so this runs again once the clone exists.
63
+ progress.warn("Skipping, repository not cloned: #{@path}")
64
+ return
65
+ end
55
66
 
56
67
  # Consolidate all grapher output into a single shared file unless an
57
68
  # explicit output path is already set (e.g. via grapherOutputPath on the
@@ -123,6 +134,34 @@ class Archsight::Import::Handlers::Grapher < Archsight::Import::Handler
123
134
  raise NotImplementedError, "#{self.class}#collect_packages must be implemented"
124
135
  end
125
136
 
137
+ # Hook: subclasses that emit ApplicationComponents describe each module here.
138
+ # @param _repo_root [String] Absolute path to the repository root
139
+ # @param _rel_dir [String] Directory of the module relative to the root ("." for the root)
140
+ # @param _mod_name [String] Module name from discover_modules
141
+ # @return [Hash] { type: "executable|library|module|...", tags: ["ecosystem:go", ...],
142
+ # entrypoints: ["cmd/api", ...] }, empty when unknown
143
+ def component_facts(_repo_root, _rel_dir, _mod_name)
144
+ {}
145
+ end
146
+
147
+ # component/type, component/entrypoints and the machine tags (namespace:value) in architecture/tags for the
148
+ # facts of component_facts.
149
+ # The tags a person set stay; machine tags of the importer's namespaces from an earlier run are replaced.
150
+ def component_annotations(facts, existing_tags: nil)
151
+ annotations = {}
152
+ annotations["component/type"] = facts[:type] if facts[:type]
153
+ annotations["component/entrypoints"] = facts[:entrypoints].join(",") if facts[:entrypoints]&.any?
154
+ annotations["architecture/tags"] = merge_machine_tags(existing_tags, facts[:tags]).join(",") if facts[:tags]&.any?
155
+ annotations
156
+ end
157
+
158
+ def merge_machine_tags(existing_tags, machine_tags)
159
+ kept = existing_tags.to_s.split(",").map(&:strip).reject(&:empty?).reject do |tag|
160
+ MACHINE_TAG_NAMESPACES.include?(tag.split(":", 2).first) && tag.include?(":")
161
+ end
162
+ (kept + Array(machine_tags)).uniq
163
+ end
164
+
126
165
  # ── Module coloring ───────────────────────────────────────────────────────
127
166
 
128
167
  def build_module_colors(modules)
@@ -22,6 +22,9 @@ require_relative "../team_matcher"
22
22
  # import/config/corporateAffixes - Optional comma-separated corporate username affixes for team matching (e.g., "ionos,1and1")
23
23
  # import/config/grapherOutputPath - Optional output path for language-grapher child imports
24
24
  class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
25
+ # generates child imports (see Handler.generates_imports?)
26
+ def self.generates_imports? = true
27
+
25
28
  def execute
26
29
  @path = config_path("path")
27
30
  @git_url = config("gitUrl")
@@ -43,6 +43,9 @@ require_relative "../registry"
43
43
  # ]
44
44
  # }
45
45
  class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
46
+ # generates child imports (see Handler.generates_imports?)
47
+ def self.generates_imports? = true
48
+
46
49
  VALID_GATES = %w[General-Availability Early-Access Development GA EA DEV].freeze
47
50
  GATE_TOKEN_RE = /\b(ga|ea|dev)\b/i
48
51
 
@@ -8,13 +8,25 @@ require "archsight/import"
8
8
  # The executor looks up handlers by name to instantiate and execute them.
9
9
  module Archsight::Import::Registry
10
10
  @handlers = {}
11
+ @builtin = {}
11
12
 
12
13
  class << self
13
14
  # Register a handler class with a name
14
15
  # @param name [String, Symbol] Handler name (matches import/handler annotation)
15
16
  # @param handler_class [Class] Handler class that extends Handler
17
+ # @raise [DuplicateHandlerError] if another class already registered the name
16
18
  def register(name, handler_class)
17
- @handlers[name.to_s] = handler_class
19
+ name = name.to_s
20
+ existing = @handlers[name] || @builtin[name]
21
+ raise Archsight::Import::DuplicateHandlerError, "Import handler '#{name}' is registered twice (#{existing} and #{handler_class})" if existing && existing != handler_class
22
+
23
+ @handlers[name] = handler_class
24
+ @builtin[name] = handler_class if builtin_caller?(caller_locations(1, 1)&.first)
25
+ end
26
+
27
+ # Register the built-in handlers again after `clear!` (they are only required once per process)
28
+ def restore_builtin!
29
+ @handlers = @builtin.merge(@handlers)
18
30
  end
19
31
 
20
32
  # Look up a handler class by name
@@ -70,8 +82,17 @@ module Archsight::Import::Registry
70
82
  def clear!
71
83
  @handlers = {}
72
84
  end
85
+
86
+ private
87
+
88
+ def builtin_caller?(location)
89
+ location && File.expand_path(location.path).start_with?("#{File.expand_path("handlers", __dir__)}/")
90
+ end
73
91
  end
74
92
  end
75
93
 
76
94
  # Error raised when an unknown handler is requested
77
95
  class Archsight::Import::UnknownHandlerError < StandardError; end
96
+
97
+ # Error raised when two handler classes register the same name
98
+ class Archsight::Import::DuplicateHandlerError < StandardError; end
@@ -5,10 +5,73 @@ module Archsight
5
5
  module Import
6
6
  module Handlers
7
7
  end
8
+
9
+ # Where the git-based handlers clone repositories: the handler's own `cacheRoot` setting, else `import.cache_root`
10
+ # of the user configuration (or ARCHSIGHT_IMPORT_CACHE_ROOT), else ~/.cache/archsight/git
11
+ # @param override [String, nil] a handler's own setting
12
+ # @return [String] the directory as configured (a `~` stays, so generated child imports keep the portable form;
13
+ # expand it before use)
14
+ def self.cache_root(override = nil)
15
+ require "archsight/user_config"
16
+ override || Archsight::UserConfig.setting("import", "cache_root")&.to_s ||
17
+ File.join(Dir.home, ".cache", "archsight", "git")
18
+ end
19
+
20
+ # The entries of `import/dependsOn`: import names and glob patterns
21
+ # @return [Array<String>]
22
+ def self.declared_dependencies(import)
23
+ import.annotations["import/dependsOn"].to_s.split(",").map(&:strip).reject(&:empty?)
24
+ end
25
+
26
+ # Absolute path of a file below the resources directory, nil when the path leaves it
27
+ def self.resources_path(resources_dir, rel_path)
28
+ root = File.expand_path(resources_dir)
29
+ path = File.expand_path(rel_path.to_s, root)
30
+ path.start_with?("#{root}/") ? path : nil
31
+ end
32
+
33
+ # The file an import writes its resources to, relative to the resources directory: `import/outputPath`, else
34
+ # generated/<name>.yaml (see Handler#write_yaml)
35
+ def self.output_path(import)
36
+ import.annotations["import/outputPath"] || File.join("generated", "#{import.name.gsub(/[^a-zA-Z0-9_-]/, "_")}.yaml")
37
+ end
38
+
39
+ # The files an import wrote, relative to the resources directory: its output path and the `generated/outputs`
40
+ # of its last run. Tells which import a resource of a generated file comes from.
41
+ def self.output_files(import)
42
+ [output_path(import), *import.annotations["generated/outputs"].to_s.split(",").map(&:strip)].reject(&:empty?).uniq
43
+ end
44
+
45
+ # Hash of the files `import/config/inputs` matches (glob patterns relative to the resources directory, comma
46
+ # separated): their paths, sizes and modification times. nil when the import declares no inputs.
47
+ # @return [String, nil]
48
+ def self.inputs_hash(import, resources_dir)
49
+ patterns = import.annotations["import/config/inputs"].to_s.split(",").map(&:strip).reject(&:empty?)
50
+ return nil if patterns.empty?
51
+
52
+ root = File.expand_path(resources_dir)
53
+ files = patterns.flat_map { |pattern| Dir.glob(pattern, base: root) }.uniq.sort
54
+ stats = files.filter_map do |file|
55
+ stat = File.stat(File.join(root, file))
56
+ "#{file}:#{stat.size}:#{stat.mtime.to_f}" if stat.file?
57
+ end
58
+ require "digest"
59
+ Digest::SHA256.hexdigest(stats.join("\n"))[0, 16]
60
+ end
61
+
62
+ # Whether a `import/dependsOn` entry is a glob pattern (`Import:Repo:*`)
63
+ def self.pattern?(entry)
64
+ entry.match?(/[*?\[]/)
65
+ end
66
+
67
+ # Whether an import name matches a `import/dependsOn` entry
68
+ def self.dependency_match?(entry, name)
69
+ pattern?(entry) ? File.fnmatch?(entry, name, File::FNM_EXTGLOB) : entry == name
70
+ end
8
71
  end
9
72
  end
10
73
 
11
74
  require_relative "import/registry"
12
75
  require_relative "import/handler"
13
76
  require_relative "import/executor"
14
- # Handlers are loaded by CLI's require_import_handlers method
77
+ require_relative "import/handler_loader"