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,8 +3,7 @@
3
3
  require "erb"
4
4
  require "uri"
5
5
  require "digest"
6
- require "kramdown"
7
- require "kramdown-parser-gfm"
6
+ require_relative "../../markdown"
8
7
  require_relative "../../assets"
9
8
  require_relative "../../diagram"
10
9
  require_relative "../../helpers/macros"
@@ -17,6 +16,7 @@ require_relative "drawio"
17
16
  require_relative "page_url"
18
17
  require_relative "rasterizer"
19
18
  require_relative "tables"
19
+ require_relative "analysis_output"
20
20
 
21
21
  module Archsight
22
22
  module Export
@@ -79,8 +79,9 @@ module Archsight
79
79
 
80
80
  MXFILE = "application/vnd.jgraph.mxfile"
81
81
 
82
- BANNER = "This page is generated by Archsight from <code>%<source>s</code>. " \
83
- "Changes made here are overwritten by the next export: edit the source instead."
82
+ HOMEPAGE = "https://github.com/ionos-cloud/archsight"
83
+ BANNER = "This page is generated by <a href=\"#{HOMEPAGE}\">Archsight</a> from %<source>s. " \
84
+ "Changes made here are overwritten by the next export: edit the source instead.".freeze
84
85
 
85
86
  LANGUAGES = {
86
87
  "sh" => "bash", "shell" => "bash", "zsh" => "bash", "yml" => "yaml", "json" => "javascript", "js" => "javascript",
@@ -94,6 +95,8 @@ module Archsight
94
95
 
95
96
  # @param page_name [String] used to name generated attachments
96
97
  # @param source [String] the markdown file, shown in the banner
98
+ # @param git [Archsight::GitSource::Location, nil] where the file is in its git repository: the banner then
99
+ # links it to the file in the web UI of the git host and names the repository
97
100
  # @param base_dir [String] directory of the markdown file relative to the resources directory
98
101
  # @param resources_dir [String]
99
102
  # @param wiki [Archsight::Helpers::WikiLinks] resolves [[Targets]]
@@ -102,8 +105,10 @@ module Archsight
102
105
  # @param database [Archsight::Database, nil] lets macros that name pages (pagetree) find them
103
106
  # @param diagram_links [#call, nil] resolves a diagram's `resource` references (DiagramLinks); none leaves them inert
104
107
  # @param drawio [Boolean] the Confluence has the draw.io app: diagrams go in as draw.io macros, else as images
105
- def initialize(page_name:, source:, base_dir:, resources_dir:, wiki:, page_id: nil, base: nil, drawio: false, diagram_links: nil, database: nil)
108
+ def initialize(page_name:, source:, base_dir:, resources_dir:, wiki:, page_id: nil, base: nil, drawio: false, diagram_links: nil, database: nil,
109
+ git: nil)
106
110
  @database = database
111
+ @git = git
107
112
  @page_name = page_name
108
113
  @source = source
109
114
  @base_dir = base_dir
@@ -124,7 +129,7 @@ module Archsight
124
129
  # @param header [String] storage format of the page properties, shown top left next to the banner
125
130
  # @return [Converted]
126
131
  def convert(markdown, toc: false, header: "")
127
- document = Kramdown::Document.new(protect(markdown), input: "GFM", auto_ids: false, entity_output: :as_char, smart_quotes: %w[apos apos quot quot])
132
+ document = Archsight::Markdown.unwrapped(protect(markdown), auto_ids: false, entity_output: :as_char, smart_quotes: %w[apos apos quot quot])
128
133
  html, = Converter.convert(document.root, document.options.merge(storage: self))
129
134
  Converted.new(body: layout(header, banner, (toc ? %(<ac:structured-macro ac:name="toc" />\n) : "") + html), attachments: @attachments, problems: @problems)
130
135
  end
@@ -225,13 +230,45 @@ module Archsight
225
230
  end
226
231
 
227
232
  def banner
228
- %(<ac:structured-macro ac:name="info"><ac:rich-text-body><p>#{format(BANNER, source: h(@source))}</p></ac:rich-text-body></ac:structured-macro>\n)
233
+ %(<ac:structured-macro ac:name="info"><ac:rich-text-body><p>#{format(BANNER, source: banner_source)}</p></ac:rich-text-body></ac:structured-macro>\n)
234
+ end
235
+
236
+ # `<code>path</code>`, in a git repository the path from its root, linked to the file on the git host, and
237
+ # the repository
238
+ def banner_source
239
+ return "<code>#{h(@source)}</code>" unless @git
240
+
241
+ file = "<code>#{h(@git.path)}</code>"
242
+ file = %(<a href="#{h(@git.url)}">#{file}</a>) if @git.url
243
+ %(#{file} in the repository <a href="#{h(@git.repository_url)}">#{h(@git.repository)}</a>)
229
244
  end
230
245
 
231
246
  # {macros}, [[Target|label]] and ![[View/Name]] become placeholders before kramdown sees them (it would split the
232
- # brackets), except in code.
247
+ # brackets), except in code; embedded descriptions are written in first.
233
248
  def protect(markdown)
234
- markdown.split(CODE).each_with_index.map { |part, i| i.odd? ? part : protect_text(part) }.join
249
+ outside_code(embed_descriptions(markdown)) { |text| protect_text(text) }
250
+ end
251
+
252
+ def outside_code(markdown, &)
253
+ markdown.split(CODE).each_with_index.map { |part, i| i.odd? ? part : yield(part) }.join
254
+ end
255
+
256
+ # The description of a resource embedded on a line of its own (`![[MotivationGoal/Name]]`) is written into the
257
+ # page: it is text, not live content. Embeds in that description stay notes (they are not expanded again).
258
+ def embed_descriptions(markdown)
259
+ outside_code(markdown) do |text|
260
+ text.gsub(/^[ \t]*!\[\[([^\]|]+)\]\][ \t]*$/) { embedded_description(Regexp.last_match(1).strip) || Regexp.last_match(0) }
261
+ end
262
+ end
263
+
264
+ def embedded_description(reference)
265
+ kind, name = reference.split("/", 2)
266
+ return if name.nil? || Archsight::Helpers::Embeds::LIVE_KINDS.include?(kind) ||
267
+ !Archsight::Helpers::Embeds.embeddable_kind?(kind)
268
+
269
+ instance = @database&.instances_by_kind(kind)&.[](name)
270
+ description = instance&.annotations.to_h["architecture/description"].to_s.strip
271
+ description.empty? ? nil : description
235
272
  end
236
273
 
237
274
  def protect_text(text)
@@ -240,6 +277,7 @@ module Archsight
240
277
  xml && placeholder("LINK", xml, plain: (macro.plain(value) if macro.respond_to?(:plain)))
241
278
  end
242
279
  text = text.gsub(%r{^[ \t]*!\[\[View/([^\]|]+)\]\][ \t]*$}) { view_embed(Regexp.last_match(1).strip) || Regexp.last_match(0) }
280
+ text = text.gsub(%r{^[ \t]*!\[\[Analysis/([^\]|]+)\]\][ \t]*$}) { analysis_embed(Regexp.last_match(1).strip) || Regexp.last_match(0) }
243
281
  text = text.gsub(/!\[\[([^\]|]+)\]\]/) { placeholder("EMBED", embed_note(Regexp.last_match(1).strip)) }
244
282
  text.gsub(Archsight::Helpers::WikiLinks::PATTERN) do
245
283
  placeholder("LINK", wiki_link(Regexp.last_match(1).strip, Regexp.last_match(2)&.strip))
@@ -256,6 +294,18 @@ module Archsight
256
294
  placeholder("TABLE", xml, plain: embed_note("View/#{name}"))
257
295
  end
258
296
 
297
+ # An Analysis on a line of its own is run and its result written into the page (the data of the moment, like a
298
+ # view); an unknown Analysis stays an embed (a note), the linter reports it.
299
+ def analysis_embed(name)
300
+ analysis = @database&.instances_by_kind("Analysis")&.[](name)
301
+ return nil unless analysis
302
+
303
+ require_relative "../../analysis"
304
+ result = Archsight::Analysis::Executor.new(@database).execute(analysis)
305
+ output = AnalysisOutput.new(link: ->(target, label) { wiki_link(target, label) }, code: ->(source, language) { code(source, language) })
306
+ placeholder("TABLE", output.xml(name, result), plain: embed_note("Analysis/#{name}"))
307
+ end
308
+
259
309
  # The XML of a table, or nothing and a problem (the page is not exported) if it cannot be built
260
310
  def table_xml(what)
261
311
  yield
@@ -358,13 +408,12 @@ module Archsight
358
408
  %(<p><ac:image><ri:attachment ri:filename="#{h(filename)}" /></ac:image></p>\n)
359
409
  end
360
410
 
361
- # The diagram file of a draw.io macro and its `.png` preview, which must share the name
411
+ # The diagram file of a draw.io macro and its `.png` preview, which must share the name. The name carries a hash
412
+ # of the content: the macro shows revision 1 of the attachment it names, so a changed diagram uploaded under the
413
+ # old name (as a new version of it) would not be shown. A new name is a new attachment, the old one is removed.
362
414
  # @return [String] the name the macro refers to
363
415
  def attach_diagram(stem, xml, png)
364
- sha = Digest::SHA256.hexdigest(xml)
365
- name = stem
366
- taken = ->(file, data) { @attachments.key?(file) && @attachments[file].data != data }
367
- name = "#{stem}-#{sha[0, 8]}" if taken.call(stem, xml) || taken.call("#{stem}.png", png)
416
+ name = "#{stem}-#{Digest::SHA256.hexdigest(xml)[0, 8]}"
368
417
  @attachments[name] = Attachment.new(filename: name, data: xml, content_type: MXFILE)
369
418
  @attachments["#{name}.png"] = Attachment.new(filename: "#{name}.png", data: png, content_type: "image/png")
370
419
  name
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
- require "kramdown"
5
- require "kramdown-parser-gfm"
4
+ require_relative "../../markdown"
6
5
  require_relative "../../view_table"
7
6
  require_relative "../../helpers/macros"
8
7
  require_relative "diagram_links"
@@ -67,7 +66,7 @@ module Archsight
67
66
  return "" if text.to_s.strip.empty?
68
67
 
69
68
  safe = text.gsub(%r{<(?!(?:https?://|mailto:))}, "&lt;")
70
- html = Kramdown::Document.new(safe, input: "GFM", entity_output: :as_char, auto_ids: false).to_html.strip
69
+ html = Archsight::Markdown.unwrapped(safe, entity_output: :as_char, auto_ids: false).to_html.strip
71
70
  html.match(%r{\A<p>((?:(?!</?p>).)*)</p>\z}m) ? Regexp.last_match(1) : html
72
71
  end
73
72
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "open3"
5
+ require "pathname"
6
+ require "uri"
7
+
8
+ module Archsight
9
+ # Where a file lives in its git repository and its page in the web UI of the git host, for links to the source of
10
+ # generated content (the banner of an exported Confluence page). Only local git is asked (the repository of the
11
+ # file, its `origin` remote, the checked out branch), nothing goes over the network.
12
+ #
13
+ # The web URL is `https://<host>/<owner>/<repo>` of the remote, without the user, password or token a remote URL
14
+ # may carry. GitHub files are `/blob/<ref>/<path>`, GitLab files `/-/blob/<ref>/<path>`; on other hosts, and for a
15
+ # file git does not track, only the repository is linked. The ref is the checked out branch, the commit when the
16
+ # HEAD is detached (CI).
17
+ module GitSource
18
+ # repository: "owner/repo"; repository_url: its web page; path: the file relative to the repository root;
19
+ # url: the web page of the file, nil if the host is not known
20
+ Location = Struct.new(:repository, :repository_url, :path, :url, keyword_init: true)
21
+
22
+ module_function
23
+
24
+ # @param file [String] path of a file
25
+ # @return [Location, nil] nil if the file is not in a git repository with an `origin` remote
26
+ def locate(file)
27
+ dir = File.dirname(File.expand_path(file))
28
+ root = git(dir, "rev-parse", "--show-toplevel") or return nil
29
+ remote = git(dir, "remote", "get-url", "origin") or return nil
30
+ host, repository = host_and_repository(remote)
31
+ return nil unless host && repository
32
+
33
+ path = Pathname.new(File.realpath(file)).relative_path_from(Pathname.new(File.realpath(root))).to_s
34
+ ref = git(dir, "symbolic-ref", "--short", "-q", "HEAD") || git(dir, "rev-parse", "HEAD")
35
+ repository_url = "https://#{host}/#{repository}"
36
+ url = tracked?(root, path) ? file_url(host, repository_url, ref, path) : nil
37
+ Location.new(repository: repository, repository_url: repository_url, path: path, url: url)
38
+ rescue SystemCallError, ArgumentError
39
+ nil
40
+ end
41
+
42
+ # `git@host:owner/repo.git`, `ssh://git@host:22/owner/repo.git`, `https://user:token@host/owner/repo` ->
43
+ # ["host", "owner/repo"]; the port of an ssh remote is not the port of the web UI and is left out
44
+ def host_and_repository(remote)
45
+ if (scp = remote.match(%r{\A(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)\z}))
46
+ host, path = scp.captures
47
+ else
48
+ uri = URI.parse(remote)
49
+ return [nil, nil] unless uri.host && %w[http https ssh git].include?(uri.scheme)
50
+
51
+ host = uri.scheme.start_with?("http") && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
52
+ path = uri.path
53
+ end
54
+ repository = path.delete_prefix("/").delete_suffix("/").delete_suffix(".git")
55
+ repository.empty? ? [nil, nil] : [host, repository]
56
+ rescue URI::InvalidURIError
57
+ [nil, nil]
58
+ end
59
+
60
+ def file_url(host, repository_url, ref, path)
61
+ blob = if host.match?(/(\A|\.)github\./) then "blob"
62
+ elsif host.match?(/(\A|\.)gitlab\./) then "-/blob"
63
+ end
64
+ return nil unless blob && ref
65
+
66
+ "#{repository_url}/#{blob}/#{escape(ref)}/#{escape(path)}"
67
+ end
68
+
69
+ # An untracked or ignored file is not on the git host: no link that leads nowhere
70
+ def tracked?(root, path) = !git(root, "ls-files", "--", path).nil?
71
+
72
+ # each segment escaped, the slashes kept
73
+ def escape(path) = path.split("/").map { |segment| ERB::Util.url_encode(segment) }.join("/")
74
+
75
+ def git(dir, *)
76
+ out, _err, status = Open3.capture3("git", "-C", dir, *)
77
+ value = out.strip
78
+ status.success? && !value.empty? ? value : nil
79
+ end
80
+ end
81
+ end
@@ -2,8 +2,7 @@
2
2
 
3
3
  require "cgi"
4
4
  require "erb"
5
- require "kramdown"
6
- require "kramdown-parser-gfm"
5
+ require_relative "../markdown"
7
6
  require_relative "../assets"
8
7
 
9
8
  module Archsight
@@ -52,7 +51,7 @@ module Archsight
52
51
  # Problems with the image references of a markdown text.
53
52
  # @return [Array<Hash>] `{ reference:, status: :outside|:type|:missing, path: }`
54
53
  def audit(markdown, base_dir:, resources_dir:)
55
- html = Kramdown::Document.new(markdown.to_s, input: "GFM").to_html
54
+ html = Archsight::Markdown.document(markdown).to_html
56
55
  html.scan(IMG).filter_map do |tag|
57
56
  reference = attribute(tag, SRC)
58
57
  next if reference.nil? || Assets.external?(reference)
@@ -65,7 +64,7 @@ module Archsight
65
64
  # The existing .asd files a markdown text embeds (for the linter)
66
65
  # @return [Array<Array(String, String)>] normalized asset path and real file
67
66
  def asd_files(markdown, base_dir:, resources_dir:)
68
- html = Kramdown::Document.new(markdown.to_s, input: "GFM").to_html
67
+ html = Archsight::Markdown.document(markdown).to_html
69
68
  html.scan(IMG).filter_map do |tag|
70
69
  reference = attribute(tag, SRC)
71
70
  next if reference.nil? || Assets.external?(reference)
@@ -4,26 +4,37 @@ require "erb"
4
4
 
5
5
  module Archsight
6
6
  module Helpers
7
- # Replaces `![[View/Name]]` and `![[Analysis/Name]]` in rendered HTML by a placeholder for the live content of
8
- # that view or analysis. The frontend turns each placeholder into the result list or the analysis result (it
9
- # loads and runs them itself, so rendering a page never executes a query or a script):
7
+ # Replaces `![[Kind/Name]]` in rendered HTML by a placeholder for the content of that resource: the result list of
8
+ # a View, the result of an Analysis, the description of any other resource (a goal, a capability, ...). The
9
+ # frontend turns each placeholder into that content (it loads and runs it itself, so rendering a page never
10
+ # executes a query or a script):
10
11
  #
11
12
  # <div class="kind-embed" data-kind="View" data-name="View:Services"><a href="/kinds/View/instances/...">...</a></div>
12
13
  #
13
14
  # The link inside is what API and MCP consumers (and a browser without script) get. A reference that does not
14
- # name an existing View or Analysis renders as a `broken-link` marker. Code (`<pre>`, `<code>`) is left alone.
15
+ # name an existing resource (or names a page) renders as a `broken-link` marker. Code (`<pre>`, `<code>`) is
16
+ # left alone.
15
17
  class Embeds
16
- KINDS = %w[View Analysis].freeze
18
+ # Embeds with live content; every other kind shows its description
19
+ LIVE_KINDS = %w[View Analysis].freeze
20
+ # Pages and menus are not resources with a description
21
+ NOT_EMBEDDABLE = %w[Page PageMenu].freeze
17
22
  PATTERN = /!\[\[([^\]|]+)\]\]/
18
23
  PARAGRAPH = %r{<p>\s*(#{PATTERN.source})\s*</p>}
19
24
  CODE = %r{(<pre\b.*?</pre>|<code\b.*?</code>)}m
20
25
 
21
26
  REASONS = {
22
- kind: "only #{KINDS.join(" and ")} can be embedded, written View/Name or Analysis/Name",
27
+ kind: "embed a resource as Kind/Name (a View, an Analysis or the description of any other resource); " \
28
+ "pages cannot be embedded",
23
29
  missing: "no such resource",
24
30
  ambiguous: "ambiguous reference"
25
31
  }.freeze
26
32
 
33
+ # Whether `![[kind/...]]` can be embedded: a known kind that is not a page
34
+ def self.embeddable_kind?(kind)
35
+ !Archsight::Resources[kind].nil? && !NOT_EMBEDDABLE.include?(Archsight::Resources.canonical(kind))
36
+ end
37
+
27
38
  def initialize(database, resolver: ResourceResolver.new(database))
28
39
  @database = database
29
40
  @resolver = resolver
@@ -66,7 +77,7 @@ module Archsight
66
77
  # @return [Array(Array(String, String), Symbol)] kind and name, and the problem (nil if it is embeddable)
67
78
  def check(reference)
68
79
  kind, name = reference.split("/", 2)
69
- return [[kind, name], :kind] unless name && KINDS.include?(kind)
80
+ return [[kind, name], :kind] unless name && self.class.embeddable_kind?(kind)
70
81
 
71
82
  problem = @resolver.call(reference)
72
83
  [[kind, name], problem.is_a?(Symbol) ? problem : nil]
@@ -32,6 +32,31 @@ module Archsight
32
32
  matches.first
33
33
  end
34
34
 
35
+ # What an annotation that names another resource (`lifecycle/replacedBy`) refers to. A bare `Name` is a
36
+ # resource of the same kind as the annotated one, another kind needs `Kind/Name`.
37
+ # @param value [String] the annotation value
38
+ # @param kind [String] the kind of the annotated resource
39
+ # @return [Array(String, String), Symbol] `[kind, name]`, or `:missing`
40
+ def find_annotated(value, kind)
41
+ reference = value.to_s.strip
42
+ found = find(reference.include?("/") ? reference : "#{kind}/#{reference}")
43
+ found.is_a?(Array) ? found : :missing
44
+ end
45
+
46
+ # Where the annotations of a resource that name another resource point to:
47
+ # `{ key => page path }`, `nil` for a reference that does not resolve.
48
+ # @param kind [String] the kind of the annotated resource, which knows the annotation formats
49
+ # @param annotations [Hash]
50
+ def annotation_links(kind, annotations)
51
+ klass = Archsight::Resources[kind]
52
+ annotations.each_with_object({}) do |(key, value), links|
53
+ next unless klass&.annotation_format(key) == :resource
54
+
55
+ found = find_annotated(value, kind)
56
+ links[key] = found.is_a?(Array) ? path(*found) : nil
57
+ end
58
+ end
59
+
35
60
  private
36
61
 
37
62
  def in_kind(kind, name)
@@ -38,7 +38,9 @@ class Archsight::Import::Executor
38
38
  # @param output [IO] Output stream for progress (default: $stdout)
39
39
  # @param filter [String, nil] Regex pattern to match import names
40
40
  # @param force [Boolean] Whether to bypass cache and re-run all imports
41
- def initialize(database:, resources_dir:, verbose: false, max_concurrent: MAX_CONCURRENT, output: $stdout, filter: nil, force: false)
41
+ # @param deps [Boolean] With a filter, also run the imports the matched ones depend on (false: only the matched ones)
42
+ def initialize(database:, resources_dir:, verbose: false, max_concurrent: MAX_CONCURRENT, output: $stdout, filter: nil, force: false,
43
+ deps: true)
42
44
  @database = database
43
45
  @resources_dir = resources_dir
44
46
  @verbose = verbose
@@ -47,7 +49,10 @@ class Archsight::Import::Executor
47
49
  @filter = filter
48
50
  @filter_regex = Regexp.new(filter, Regexp::IGNORECASE) if filter
49
51
  @force = force
52
+ @deps = deps
50
53
  @executed_this_run = Set.new
54
+ @ran_this_run = Set.new
55
+ @handler_problems = {}
51
56
  @iteration = 0
52
57
  @mutex = Mutex.new
53
58
  end
@@ -154,18 +159,24 @@ class Archsight::Import::Executor
154
159
  sorted = topological_sort(filtered_imports)
155
160
 
156
161
  @output.puts "Filter: #{@filter}" if @filter
162
+ @output.puts "Dependencies: not run (--no-deps)" if only_matches?
157
163
 
158
164
  sorted.each_with_index do |imp, idx|
159
165
  enabled = import_enabled?(imp)
160
166
  deps = import_dependency_names(imp)
161
167
  deps_str = deps.empty? ? "(no dependencies)" : "depends on: #{deps.join(", ")}"
162
168
  enabled_str = enabled ? "" : " [DISABLED]"
163
- @output.puts " #{idx + 1}. #{imp.name}#{enabled_str} #{deps_str}"
169
+ @output.puts " #{idx + 1}. #{imp.name}#{enabled_str} #{deps_str}#{unmatched_patterns_note(imp)}"
164
170
  end
165
171
 
166
172
  sorted
167
173
  end
168
174
 
175
+ # Names of the imports that executed in this run (not served from the cache)
176
+ def ran_imports
177
+ @mutex.synchronize { @ran_this_run.to_a.sort }
178
+ end
179
+
169
180
  private
170
181
 
171
182
  # Set up signal handlers for graceful shutdown
@@ -222,7 +233,13 @@ class Archsight::Import::Executor
222
233
  @imports_to_run.include?(import.name)
223
234
  end
224
235
 
225
- # Compute the full set of imports to run: filtered imports + all their dependencies
236
+ # Whether only the imports matching the filter run (`-f` with `--no-deps`)
237
+ def only_matches?
238
+ !@deps && !@filter_regex.nil?
239
+ end
240
+
241
+ # Compute the full set of imports to run: filtered imports + all their dependencies (only the filtered ones with
242
+ # `--no-deps`)
226
243
  def compute_imports_to_run
227
244
  all_imports = database.instances_by_kind("Import")&.values || []
228
245
  imports_by_name = all_imports.to_h { |imp| [imp.name, imp] }
@@ -230,6 +247,7 @@ class Archsight::Import::Executor
230
247
  # Start with imports matching the filter
231
248
  matching = all_imports.select { |imp| @filter_regex.match?(imp.name) }.map(&:name)
232
249
  to_run = Set.new(matching)
250
+ return to_run unless @deps
233
251
 
234
252
  # Recursively add dependencies
235
253
  queue = matching.dup
@@ -249,9 +267,12 @@ class Archsight::Import::Executor
249
267
  to_run
250
268
  end
251
269
 
252
- # Check if all dependencies of an import are satisfied (executed successfully this run)
270
+ # Check if all dependencies of an import are satisfied (executed successfully this run).
271
+ # A parent that can never run (no handler or disabled, e.g. the repository was
272
+ # deleted upstream and only its generated `generates` entry is left) is not
273
+ # waited for, otherwise its children would deadlock the run.
253
274
  def dependencies_satisfied?(import)
254
- dep_names = import_dependency_names(import)
275
+ dep_names = runnable_dependency_names(import)
255
276
  return true if dep_names.empty?
256
277
 
257
278
  @mutex.synchronize do
@@ -261,30 +282,81 @@ class Archsight::Import::Executor
261
282
  end
262
283
  end
263
284
 
264
- # Get dependency names for an import by finding parents that generate this import
265
- # Dependencies are derived from the inverse of `generates` - if Import A generates Import B,
266
- # then B depends on A
285
+ # With `--no-deps` a dependency outside the run is not waited for: it does not run, so the import uses the model
286
+ # as it is
287
+ def runnable_dependency_names(import)
288
+ imports_by_name = (database.instances_by_kind("Import")&.values || []).to_h { |imp| [imp.name, imp] }
289
+ import_dependency_names(import).select do |dep_name|
290
+ parent = imports_by_name[dep_name]
291
+ parent && import_enabled?(parent) && (!only_matches? || import_matches_filter?(parent))
292
+ end
293
+ end
294
+
295
+ # Get dependency names for an import: the parents that generate it (the inverse of `generates` - if Import A
296
+ # generates Import B, then B depends on A) and the imports it declares in `import/dependsOn`
267
297
  def import_dependency_names(import)
268
- # Find all imports that have this import in their generates.imports
269
298
  all_imports = database.instances_by_kind("Import")&.values || []
270
299
  parent_imports = all_imports.select do |parent|
271
- generated = parent.relations(:generates, :imports)
272
- generated&.any? { |gen| (gen.is_a?(String) ? gen : gen.name) == import.name }
300
+ generated_import_names(parent).include?(import.name)
301
+ end
302
+ (parent_imports.map(&:name) + declared_dependency_names(import, all_imports)).uniq
303
+ end
304
+
305
+ # The imports `import/dependsOn` names or matches. For a pattern this also includes the imports that may still
306
+ # generate a matching import in this run: one whose last run generated a match, and one that never ran but whose
307
+ # handler generates imports. So `Import:Repo:*` waits for the GitHub import and then for the repositories it found.
308
+ def declared_dependency_names(import, all_imports = database.instances_by_kind("Import")&.values || [])
309
+ entries = Archsight::Import.declared_dependencies(import)
310
+ return [] if entries.empty?
311
+
312
+ others = all_imports.reject { |imp| imp.name == import.name }
313
+ entries.flat_map do |entry|
314
+ matches = others.select { |imp| Archsight::Import.dependency_match?(entry, imp.name) }
315
+ matches += others.select { |imp| may_generate_match?(imp, entry) } if Archsight::Import.pattern?(entry)
316
+ matches.map(&:name)
317
+ end.uniq
318
+ end
319
+
320
+ # Whether an import generated a match of the pattern in its last run, or may do so on its first one
321
+ def may_generate_match?(import, pattern)
322
+ generated = generated_import_names(import)
323
+ return generated.any? { |name| Archsight::Import.dependency_match?(pattern, name) } if generated.any?
324
+
325
+ handler = Archsight::Import::Registry[import.annotations["import/handler"]]
326
+ !import.annotations["generated/at"] && handler.respond_to?(:generates_imports?) && handler.generates_imports?
327
+ end
328
+
329
+ def generated_import_names(import)
330
+ (import.relations(:generates, :imports) || []).map { |gen| gen.is_a?(String) ? gen : gen.name }
331
+ end
332
+
333
+ # For the plan: patterns of `import/dependsOn` that match no import yet
334
+ def unmatched_patterns_note(import)
335
+ all_names = (database.instances_by_kind("Import")&.keys || []) - [import.name]
336
+ unmatched = Archsight::Import.declared_dependencies(import).select do |entry|
337
+ Archsight::Import.pattern?(entry) && all_names.none? { |name| Archsight::Import.dependency_match?(entry, name) }
273
338
  end
274
- parent_imports.map(&:name)
339
+ unmatched.empty? ? "" : " (#{unmatched.join(", ")} matches no import yet)"
275
340
  end
276
341
 
277
- # Reload database without verbose output
342
+ # Reload database without verbose output, and load the handlers the imports declare (`import/handlerPath`)
278
343
  def reload_database_quietly!
279
344
  original_verbose = database.verbose
280
345
  database.verbose = false
281
346
  database.reload!
347
+ load_local_handlers
282
348
  # Reset filter cache since new imports may have been discovered
283
349
  @imports_to_run = nil if @filter_regex
284
350
  ensure
285
351
  database.verbose = original_verbose
286
352
  end
287
353
 
354
+ # A handler file that does not load fails the imports that use it, not the whole run
355
+ def load_local_handlers
356
+ problems = Archsight::Import::HandlerLoader.load(database, resources_dir: resources_dir)
357
+ @handler_problems = problems.to_h { |problem| [problem.import.name, problem.message] }
358
+ end
359
+
288
360
  # Reload database and update progress total for newly discovered imports
289
361
  def reload_and_update_total!
290
362
  reload_database_quietly!
@@ -315,6 +387,8 @@ class Archsight::Import::Executor
315
387
  result = execute_single_import(imp, slot_progress)
316
388
  if result == :cached
317
389
  slot_progress.complete("Cached")
390
+ elsif result.is_a?(String)
391
+ slot_progress.complete(result)
318
392
  else
319
393
  @mutex.synchronize { @total_executed += 1 }
320
394
  slot_progress.complete("Done")
@@ -339,7 +413,7 @@ class Archsight::Import::Executor
339
413
  end
340
414
 
341
415
  # Execute a single import (called from thread)
342
- # @return [Symbol] :executed, :cached, or raises on error
416
+ # @return [Symbol, String] :executed, :cached, a message for a skipped import, or raises on error
343
417
  def execute_single_import(import, import_progress)
344
418
  # Check if import is still fresh based on generated/at and import/cacheTime
345
419
  if import_fresh?(import)
@@ -347,6 +421,18 @@ class Archsight::Import::Executor
347
421
  return :cached
348
422
  end
349
423
 
424
+ problem = @handler_problems[import.name]
425
+ raise Archsight::Import::UnknownHandlerError, problem if problem
426
+
427
+ missing = missing_environment(import)
428
+ if missing.any?
429
+ message = "missing environment variable#{"s" if missing.length > 1} #{missing.join(", ")} (import/config/requiredEnv)"
430
+ raise Archsight::Import::ImportError, message unless import.annotations["import/config/optional"] == "true"
431
+
432
+ import_progress.warn("Skipped: #{message}")
433
+ return "Skipped"
434
+ end
435
+
350
436
  # Execute the import
351
437
  handler_class = Archsight::Import::Registry.handler_for(import)
352
438
  handler = handler_class.new(
@@ -356,13 +442,32 @@ class Archsight::Import::Executor
356
442
  progress: import_progress,
357
443
  shared_writer: @shared_writer
358
444
  )
445
+ Archsight::Import.never_index(handler.cache_root)
359
446
  handler.execute
447
+ remove_stale_outputs(import, handler.owned_outputs)
448
+ @mutex.synchronize { @ran_this_run.add(import.name) }
360
449
  :executed
361
450
  end
362
451
 
452
+ # Variables of `import/config/requiredEnv` that are not set (or blank)
453
+ def missing_environment(import)
454
+ names = import.annotations["import/config/requiredEnv"].to_s.split(",").map(&:strip).reject(&:empty?)
455
+ names.select { |name| ENV[name].to_s.strip.empty? }
456
+ end
457
+
458
+ # Delete the files an earlier run of the import wrote (`generated/outputs`) that this run did not write again
459
+ def remove_stale_outputs(import, written)
460
+ previous = import.annotations["generated/outputs"].to_s.split(",").map(&:strip).reject(&:empty?)
461
+ (previous - written).each do |rel_path|
462
+ path = Archsight::Import.resources_path(resources_dir, rel_path)
463
+ FileUtils.rm_f(path) if path && File.file?(path)
464
+ end
465
+ end
466
+
363
467
  # Check if import is still fresh (cached)
364
- # Uses generated/at annotation and import/cacheTime to determine freshness
365
- # Also validates that output file exists (if specified) and source file hasn't changed
468
+ # Uses generated/at annotation and import/cacheTime to determine freshness. The cache is also invalid when the
469
+ # output file or a recorded output (`generated/outputs`) is missing, the configuration or the files of
470
+ # `import/config/inputs` changed, or an import it declares in `import/dependsOn` executed in this run.
366
471
  def import_fresh?(import)
367
472
  # Honor force flag - always re-run when forced
368
473
  return false if @force
@@ -377,6 +482,11 @@ class Archsight::Import::Executor
377
482
  return false unless File.exist?(full_path)
378
483
  end
379
484
 
485
+ return false unless recorded_outputs_exist?(import)
486
+ return false if dependency_ran?(import)
487
+ return false if import.annotations["import/config/inputs"] &&
488
+ import.annotations["generated/inputsHash"] != Archsight::Import.inputs_hash(import, resources_dir)
489
+
380
490
  generated_at = import.annotations["generated/at"]
381
491
  return false if generated_at.nil? || generated_at.empty?
382
492
 
@@ -399,6 +509,20 @@ class Archsight::Import::Executor
399
509
  false
400
510
  end
401
511
 
512
+ def recorded_outputs_exist?(import)
513
+ import.annotations["generated/outputs"].to_s.split(",").map(&:strip).reject(&:empty?).all? do |rel_path|
514
+ path = Archsight::Import.resources_path(resources_dir, rel_path)
515
+ path && File.exist?(path)
516
+ end
517
+ end
518
+
519
+ # Whether an import declared in `import/dependsOn` executed (not just came from the cache) in this run. The
520
+ # generated imports of a parent are not affected: they keep their own cache time when the parent runs.
521
+ def dependency_ran?(import)
522
+ names = declared_dependency_names(import)
523
+ @mutex.synchronize { names.any? { |name| @ran_this_run.include?(name) } }
524
+ end
525
+
402
526
  def parse_duration(str)
403
527
  return nil if str.nil?
404
528