archsight 0.3.3 → 0.4.1

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 (193) 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 +8 -2
  8. data/docs/import.md +178 -55
  9. data/docs/index.md.erb +24 -18
  10. data/docs/modeling.md +645 -34
  11. data/docs/pages.md +46 -15
  12. data/docs/search.md +21 -1
  13. data/docs/togaf.md +2 -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 +245 -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/link_annotations.rb +15 -0
  21. data/lib/archsight/annotations/resource_reference.rb +12 -0
  22. data/lib/archsight/cli.rb +124 -23
  23. data/lib/archsight/database.rb +40 -0
  24. data/lib/archsight/diagram/cli.rb +50 -2
  25. data/lib/archsight/diagram/formatter.rb +112 -0
  26. data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
  27. data/lib/archsight/diagram/routing/edge_router.rb +29 -4
  28. data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
  29. data/lib/archsight/diagram/style/theme.rb +7 -2
  30. data/lib/archsight/documentation.rb +4 -4
  31. data/lib/archsight/editor/page_source.rb +13 -1
  32. data/lib/archsight/export/confluence/analysis_output.rb +100 -0
  33. data/lib/archsight/export/confluence/client.rb +13 -0
  34. data/lib/archsight/export/confluence/diagram_links.rb +1 -1
  35. data/lib/archsight/export/confluence/exporter.rb +43 -14
  36. data/lib/archsight/export/confluence/labels.rb +25 -0
  37. data/lib/archsight/export/confluence/page_properties.rb +6 -9
  38. data/lib/archsight/export/confluence/storage.rb +63 -14
  39. data/lib/archsight/export/confluence/tables.rb +2 -3
  40. data/lib/archsight/git_source.rb +81 -0
  41. data/lib/archsight/helpers/asset_images.rb +3 -4
  42. data/lib/archsight/helpers/embeds.rb +18 -7
  43. data/lib/archsight/helpers/resource_resolver.rb +25 -0
  44. data/lib/archsight/import/executor.rb +140 -16
  45. data/lib/archsight/import/handler.rb +55 -6
  46. data/lib/archsight/import/handler_loader.rb +64 -0
  47. data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
  48. data/lib/archsight/import/handlers/command.rb +120 -0
  49. data/lib/archsight/import/handlers/github.rb +4 -1
  50. data/lib/archsight/import/handlers/gitlab.rb +4 -1
  51. data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
  52. data/lib/archsight/import/handlers/go_grapher.rb +102 -5
  53. data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
  54. data/lib/archsight/import/handlers/grapher.rb +40 -1
  55. data/lib/archsight/import/handlers/repository.rb +3 -0
  56. data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
  57. data/lib/archsight/import/registry.rb +22 -1
  58. data/lib/archsight/import.rb +89 -1
  59. data/lib/archsight/linter/structure.rb +239 -0
  60. data/lib/archsight/linter.rb +123 -16
  61. data/lib/archsight/markdown.rb +48 -0
  62. data/lib/archsight/openapi.rb +169 -0
  63. data/lib/archsight/page_loader.rb +12 -1
  64. data/lib/archsight/page_tree.rb +2 -3
  65. data/lib/archsight/query/evaluator.rb +13 -6
  66. data/lib/archsight/query/parser.rb +73 -6
  67. data/lib/archsight/query.rb +1 -1
  68. data/lib/archsight/references.rb +3 -3
  69. data/lib/archsight/renderer.rb +80 -23
  70. data/lib/archsight/resources/analysis.rb +3 -2
  71. data/lib/archsight/resources/application_component.rb +75 -7
  72. data/lib/archsight/resources/application_interface.rb +12 -0
  73. data/lib/archsight/resources/application_service.rb +12 -1
  74. data/lib/archsight/resources/base.rb +58 -5
  75. data/lib/archsight/resources/business_actor.rb +5 -0
  76. data/lib/archsight/resources/business_contract.rb +72 -0
  77. data/lib/archsight/resources/business_product.rb +12 -0
  78. data/lib/archsight/resources/business_role.rb +1 -0
  79. data/lib/archsight/resources/implementation_deliverable.rb +1 -0
  80. data/lib/archsight/resources/implementation_plateau.rb +8 -2
  81. data/lib/archsight/resources/implementation_work_package.rb +2 -1
  82. data/lib/archsight/resources/import.rb +37 -11
  83. data/lib/archsight/resources/location.rb +56 -0
  84. data/lib/archsight/resources/motivation_requirement.rb +6 -0
  85. data/lib/archsight/resources/motivation_stakeholder.rb +14 -0
  86. data/lib/archsight/resources/page.rb +4 -7
  87. data/lib/archsight/resources/strategy_capability.rb +9 -0
  88. data/lib/archsight/resources/technology_artifact.rb +11 -4
  89. data/lib/archsight/resources/technology_communication_network.rb +91 -0
  90. data/lib/archsight/resources/technology_device.rb +88 -0
  91. data/lib/archsight/resources/technology_equipment.rb +75 -0
  92. data/lib/archsight/resources/technology_facility.rb +70 -0
  93. data/lib/archsight/resources/technology_node.rb +50 -0
  94. data/lib/archsight/resources/technology_service.rb +33 -0
  95. data/lib/archsight/resources/technology_system_software.rb +28 -0
  96. data/lib/archsight/resources.rb +12 -0
  97. data/lib/archsight/version.rb +1 -1
  98. data/lib/archsight/web/api/analysis_helpers.rb +55 -0
  99. data/lib/archsight/web/api/docs.rb +2 -1
  100. data/lib/archsight/web/api/json_helpers.rb +3 -37
  101. data/lib/archsight/web/api/openapi/spec.yaml +146 -35
  102. data/lib/archsight/web/api/page_helpers.rb +1 -8
  103. data/lib/archsight/web/api/routes.rb +9 -4
  104. data/lib/archsight/web/application.rb +40 -4
  105. data/lib/archsight/web/editor/routes.rb +2 -1
  106. data/lib/archsight/web/public/vendor/redoc/LICENSE +22 -0
  107. data/lib/archsight/web/public/vendor/redoc/VERSION +1 -0
  108. data/lib/archsight/web/public/vendor/redoc/api.html +27 -0
  109. data/lib/archsight/web/public/vendor/redoc/api.js +19 -0
  110. data/lib/archsight/web/public/vendor/redoc/redoc.standalone.js +1851 -0
  111. data/lib/archsight/web/public/vendor/redoc/redoc.standalone.js.LICENSE.txt +102 -0
  112. data/lib/archsight/web/public/vue/DocPage-C2ubTXH7.js +1 -0
  113. data/lib/archsight/web/public/vue/EditorPage-Dso6PLMn.js +35 -0
  114. data/lib/archsight/web/public/vue/ErrorPage-DnZ6YJLi.js +2 -0
  115. data/lib/archsight/web/public/vue/GraphView-CTl03Yre.js +1 -0
  116. data/lib/archsight/web/public/vue/HomePage-C339nihQ.js +2 -0
  117. data/lib/archsight/web/public/vue/InstanceRouter-DFW7tOrD.js +1 -0
  118. data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
  119. data/lib/archsight/web/public/vue/KindList-C0oaM3CF.js +1 -0
  120. data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
  121. data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
  122. data/lib/archsight/web/public/vue/PageView-OZB6G9Wu.js +1 -0
  123. data/lib/archsight/web/public/vue/QueryError-C2xLkCEm.js +1 -0
  124. data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
  125. data/lib/archsight/web/public/vue/ResourceList-DooWFwQP.js +2 -0
  126. data/lib/archsight/web/public/vue/SearchResults-BK-HjFnv.js +1 -0
  127. data/lib/archsight/web/public/vue/{SearchResults-BewvsfOc.css → SearchResults-BV6UHriJ.css} +1 -1
  128. data/lib/archsight/web/public/vue/WikiPage-B2FYmeHL.js +3 -0
  129. data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
  130. data/lib/archsight/web/public/vue/architecture-WOLXFQ4H-BGN-TV5r.js +1 -0
  131. data/lib/archsight/web/public/vue/cynefin-EF2NZ3EQ-CZd6g_tq.js +1 -0
  132. data/lib/archsight/web/public/vue/eventmodeling-K75KTNOO-CYnbEx8O.js +1 -0
  133. data/lib/archsight/web/public/vue/gitGraph-VSP46ZUC-DialfQSk.js +1 -0
  134. data/lib/archsight/web/public/vue/graphviz-1lWBhpEX.js +3 -0
  135. data/lib/archsight/web/public/vue/index-BLSE5DSv.css +1 -0
  136. data/lib/archsight/web/public/vue/index-CLuLTE5I.js +3 -0
  137. data/lib/archsight/web/public/vue/info-OHQRW6UA-BuhOW7ss.js +1 -0
  138. data/lib/archsight/web/public/vue/katex-B_JcIwqj.js +257 -0
  139. data/lib/archsight/web/public/vue/mermaid-C3bOamfn.js +3999 -0
  140. data/lib/archsight/web/public/vue/packet-JDAUHWVQ-CUeRz7w4.js +1 -0
  141. data/lib/archsight/web/public/vue/pie-XZMESJXO-DnuewJI6.js +1 -0
  142. data/lib/archsight/web/public/vue/radar-ABXABTNO-Bi9l8bcv.js +1 -0
  143. data/lib/archsight/web/public/vue/railroad-I3PHUGI6-DSlxFbxk.js +1 -0
  144. data/lib/archsight/web/public/vue/railroad-abnf-LNEFI6M7-D6Kqu8wu.js +1 -0
  145. data/lib/archsight/web/public/vue/railroad-ebnf-DWYD2UWJ-MR7uCYT0.js +1 -0
  146. data/lib/archsight/web/public/vue/railroad-peg-JR7BVNR7-BL7aKIc-.js +1 -0
  147. data/lib/archsight/web/public/vue/rolldown-runtime-COiEfhsA.js +1 -0
  148. data/lib/archsight/web/public/vue/treeView-D4JQ5SDB-CamtB04D.js +1 -0
  149. data/lib/archsight/web/public/vue/treemap-SAJKECNS-DeQxu_HM.js +1 -0
  150. data/lib/archsight/web/public/vue/{useGraphviz-DlSnFeCL.js → useGraphviz-Dnm2pkon.js} +2 -2
  151. data/lib/archsight/web/public/vue/useHighlight-BH32C7yt.js +10 -0
  152. data/lib/archsight/web/public/vue/usePanZoom-HxgEsqq2.js +12 -0
  153. data/lib/archsight/web/public/vue/wardley-7MLQ67FV-BvKIB6Tv.js +1 -0
  154. data/lib/archsight/web/public/vue.html +5 -5
  155. metadata +68 -41
  156. data/lib/archsight/web/public/vue/ApiDocsPage-BgqnQgwa.js +0 -1
  157. data/lib/archsight/web/public/vue/ApiDocsPage-DZ0fa5-h.css +0 -1
  158. data/lib/archsight/web/public/vue/DocPage-CNO71nBH.js +0 -1
  159. data/lib/archsight/web/public/vue/EditorPage-DR0FCNTz.js +0 -35
  160. data/lib/archsight/web/public/vue/ErrorPage-BfYArv6s.js +0 -2
  161. data/lib/archsight/web/public/vue/GraphView-BVTW30Ak.js +0 -1
  162. data/lib/archsight/web/public/vue/HomePage-Wk9P4Pma.js +0 -2
  163. data/lib/archsight/web/public/vue/InstanceRouter-BV8ycydz.js +0 -1
  164. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
  165. data/lib/archsight/web/public/vue/KindList-C8yMsN5J.js +0 -1
  166. data/lib/archsight/web/public/vue/PageView-BaN6TyJB.js +0 -1
  167. data/lib/archsight/web/public/vue/QueryError-D790wH-a.js +0 -1
  168. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
  169. data/lib/archsight/web/public/vue/ResourceList-D-66nas2.js +0 -2
  170. data/lib/archsight/web/public/vue/SearchResults-CHyqIerQ.js +0 -1
  171. data/lib/archsight/web/public/vue/WikiPage-D2svpk6B.js +0 -13
  172. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
  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/graphviz-SXWTgZH5.js +0 -3
  178. data/lib/archsight/web/public/vue/index-BXXkUK1V.js +0 -3
  179. data/lib/archsight/web/public/vue/index-Cov1SnzY.css +0 -1
  180. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
  181. data/lib/archsight/web/public/vue/katex-CXMH3UgJ.js +0 -257
  182. data/lib/archsight/web/public/vue/mermaid-BjEi5URd.js +0 -3334
  183. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
  184. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
  185. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
  186. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
  187. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
  188. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
  189. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
  190. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
  191. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
  192. data/lib/archsight/web/public/vue/useHighlight-D5Gf8DLl.js +0 -10
  193. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require "pathname"
6
7
  require "yaml"
7
8
  require "archsight/import"
8
9
  require_relative "progress"
@@ -12,7 +13,14 @@ require_relative "progress"
12
13
  # Subclasses must implement the #execute method to perform the actual import.
13
14
  # Use the helper methods to read configuration, validate environment, and write output.
14
15
  class Archsight::Import::Handler
15
- attr_reader :import_resource, :database, :resources_dir, :progress, :shared_writer
16
+ attr_reader :import_resource, :database, :resources_dir, :progress, :shared_writer, :owned_outputs
17
+
18
+ # Whether the handler generates child imports (`import_yaml`). An import that depends on a pattern
19
+ # (`import/dependsOn: Import:Repo:*`) waits for such a handler on its first run, before it has a `generates` record
20
+ # that tells which imports it creates. Override in handlers that generate imports.
21
+ def self.generates_imports?
22
+ false
23
+ end
16
24
 
17
25
  # @param import_resource [Archsight::Resources::Import] The import resource to execute
18
26
  # @param database [Archsight::Database] The database instance
@@ -26,6 +34,15 @@ class Archsight::Import::Handler
26
34
  @progress = progress || Archsight::Import::Progress.new
27
35
  @shared_writer = shared_writer
28
36
  @tracked_resources = []
37
+ @owned_outputs = []
38
+ end
39
+
40
+ # The directory the git-based handlers clone repositories into (`import/config/cacheRoot`, else `import.cache_root`
41
+ # of the user configuration or ARCHSIGHT_IMPORT_CACHE_ROOT, else ~/.cache/archsight/git), so an importer of the
42
+ # resources repository finds the same clones
43
+ # @return [String] absolute path
44
+ def cache_root
45
+ File.expand_path(Archsight::Import.cache_root(config("cacheRoot")))
29
46
  end
30
47
 
31
48
  # Execute the import. Must be implemented by subclasses.
@@ -74,23 +91,55 @@ class Archsight::Import::Handler
74
91
  end
75
92
 
76
93
  # Generate a marker Import for this handler with generated/at timestamp and config hash
77
- # Used for caching - call at end of execute() to persist the execution timestamp
94
+ # Used for caching - call at end of execute() to persist the execution timestamp. It also records the hash of the
95
+ # files in `import/config/inputs` and the files written with #write_file, so call it after writing them.
78
96
  # @return [Hash] Import resource hash ready for YAML serialization
79
97
  def self_marker
98
+ annotations = {
99
+ "generated/at" => Time.now.utc.iso8601,
100
+ "generated/configHash" => compute_config_hash
101
+ }
102
+ inputs_hash = Archsight::Import.inputs_hash(import_resource, resources_dir)
103
+ annotations["generated/inputsHash"] = inputs_hash if inputs_hash
104
+ annotations["generated/outputs"] = owned_outputs.uniq.sort.join(",") if owned_outputs.any?
80
105
  {
81
106
  "apiVersion" => "architecture/v1alpha1",
82
107
  "kind" => "Import",
83
108
  "metadata" => {
84
109
  "name" => import_resource.name,
85
- "annotations" => {
86
- "generated/at" => Time.now.utc.iso8601,
87
- "generated/configHash" => compute_config_hash
88
- }
110
+ "annotations" => annotations
89
111
  },
90
112
  "spec" => {}
91
113
  }
92
114
  end
93
115
 
116
+ # Write a whole file that belongs to this import alone, such as a page (Markdown) next to the generated YAML.
117
+ # The file is recorded in the self marker (`generated/outputs`): the cache is invalid while it is missing, and a
118
+ # file of an earlier run that is not written again is deleted.
119
+ # @param rel_path [String] path relative to the resources directory
120
+ # @param content [String]
121
+ # @return [String] the absolute path
122
+ # @raise [ArgumentError] for a path outside the resources directory
123
+ def write_file(rel_path, content)
124
+ full_path = Archsight::Import.resources_path(resources_dir, rel_path)
125
+ raise ArgumentError, "#{rel_path} is outside the resources directory" unless full_path
126
+
127
+ FileUtils.mkdir_p(File.dirname(full_path))
128
+ File.write(full_path, content)
129
+ record_output(rel_path)
130
+ full_path
131
+ end
132
+
133
+ # Record a file this import wrote by other means (a command, a library) as its own output (see #write_file)
134
+ # @param rel_path [String] path relative to the resources directory
135
+ # @raise [ArgumentError] for a path outside the resources directory
136
+ def record_output(rel_path)
137
+ full_path = Archsight::Import.resources_path(resources_dir, rel_path)
138
+ raise ArgumentError, "#{rel_path} is outside the resources directory" unless full_path
139
+
140
+ @owned_outputs << Pathname.new(full_path).relative_path_from(File.expand_path(resources_dir)).to_s
141
+ end
142
+
94
143
  # Write YAML content to the output path
95
144
  # @param content [String] YAML content to write
96
145
  # @param filename [String, nil] Output filename (overrides import/outputPath filename)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "archsight/import"
4
+
5
+ # Loads import handlers: the built-in ones shipped with the gem, and the handlers of the resources repository that an
6
+ # Import declares with `import/handlerPath` (a Ruby file relative to the resources directory, like
7
+ # `import/outputPath`). Used by `archsight import`, `lint` and `web`, so every command knows the same handler names.
8
+ module Archsight::Import::HandlerLoader
9
+ BUILTIN_DIR = File.expand_path("handlers", __dir__)
10
+
11
+ # A problem with a local handler file, reported by the linter and by `archsight import`
12
+ Problem = Struct.new(:import, :message) do
13
+ def to_s
14
+ "#{import.path_ref}: Import '#{import.name}' #{message}"
15
+ end
16
+ end
17
+
18
+ class << self
19
+ # Require the built-in handlers. Idempotent; registers them again when a test cleared the registry.
20
+ def load_builtin!
21
+ Dir.glob(File.join(BUILTIN_DIR, "*.rb")).each { |file| require file }
22
+ Archsight::Import::Registry.restore_builtin!
23
+ end
24
+
25
+ # Load the built-in handlers and every handler file the Imports of the database declare
26
+ # @param database [Archsight::Database]
27
+ # @param resources_dir [String] directory `import/handlerPath` is relative to
28
+ # @return [Array<Problem>] files that are missing, outside the resources directory, fail to load or do not register
29
+ # the handler named in `import/handler`
30
+ def load(database, resources_dir: database.path)
31
+ load_builtin!
32
+ imports = database.instances_by_kind("Import")&.values || []
33
+ imports.select { |imp| imp.annotations["import/handlerPath"] }.flat_map { |imp| load_for(imp, resources_dir) }
34
+ end
35
+
36
+ # Absolute path of the handler file of an Import, nil when it points outside the resources directory
37
+ def handler_file(import, resources_dir)
38
+ Archsight::Import.resources_path(resources_dir, import.annotations["import/handlerPath"])
39
+ end
40
+
41
+ private
42
+
43
+ def load_for(import, resources_dir)
44
+ file = handler_file(import, resources_dir)
45
+ return [Problem.new(import, "has import/handlerPath '#{import.annotations["import/handlerPath"]}' outside the resources directory")] unless file
46
+ return [Problem.new(import, "has import/handlerPath '#{import.annotations["import/handlerPath"]}' that does not exist")] unless File.file?(file)
47
+
48
+ problem = require_file(import, file)
49
+ return [problem] if problem
50
+
51
+ name = import.annotations["import/handler"]
52
+ return [] if name.nil? || Archsight::Import::Registry[name]
53
+
54
+ [Problem.new(import, "loads #{import.annotations["import/handlerPath"]}, which does not register the handler '#{name}'")]
55
+ end
56
+
57
+ def require_file(import, file)
58
+ require file
59
+ nil
60
+ rescue ScriptError, StandardError => e
61
+ Problem.new(import, "cannot load #{import.annotations["import/handlerPath"]}: #{e.class}: #{e.message.lines.first&.strip}")
62
+ end
63
+ end
64
+ end
@@ -6,18 +6,41 @@
6
6
  # the current API listing, so a renamed, moved, or deleted repo doesn't
7
7
  # leave a stale clone behind that a later importer could still resolve
8
8
  # against under its old name.
9
+ #
10
+ # A listing can be incomplete (throttled or empty API pages) and clones may be
11
+ # in progress, so pruning is conservative: entries touched within MIN_AGE are
12
+ # kept, and nothing is removed when it would wipe out most of the cache.
9
13
  module Archsight::Import::Handlers::CachePruner
14
+ MIN_AGE = 3600 # seconds; younger directories may be a clone in progress
15
+ MAX_STALE_ABSOLUTE = 10 # always allowed, whatever the cache size
16
+ MAX_STALE_FRACTION = 0.2 # larger share of the cache is treated as a bad listing
17
+
10
18
  def prune_stale_cache_entries(target_dir, current_names)
11
19
  return unless File.directory?(target_dir)
12
20
 
13
- Dir.children(target_dir).each do |entry|
14
- next if current_names.include?(entry)
15
-
16
- stale_path = File.join(target_dir, entry)
17
- next unless File.directory?(stale_path)
21
+ known = current_names.to_set
22
+ dirs = Dir.children(target_dir).select { |entry| File.directory?(File.join(target_dir, entry)) }
23
+ stale = dirs.reject { |entry| known.include?(entry) }
24
+ return if stale.empty?
18
25
 
19
- progress.warn("Pruning stale cache entry: #{entry} (repo renamed, moved, or removed)")
20
- FileUtils.rm_rf(stale_path)
26
+ limit = [MAX_STALE_ABSOLUTE, (dirs.size * MAX_STALE_FRACTION).floor].max
27
+ if stale.size > limit
28
+ progress.warn("Not pruning #{stale.size} of #{dirs.size} cache entries in #{target_dir}: " \
29
+ "the project listing looks incomplete")
30
+ return
21
31
  end
32
+
33
+ stale.each { |entry| prune_cache_entry(File.join(target_dir, entry), entry) }
34
+ end
35
+
36
+ private
37
+
38
+ def prune_cache_entry(path, name)
39
+ return if Time.now - File.mtime(path) < MIN_AGE
40
+
41
+ progress.warn("Pruning stale cache entry: #{name} (repo renamed, moved, or removed)")
42
+ FileUtils.rm_rf(path)
43
+ rescue Errno::ENOENT
44
+ nil
22
45
  end
23
46
  end
@@ -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