archsight 0.3.3 → 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 (173) 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 +637 -34
  11. data/docs/pages.md +44 -13
  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 +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/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 +101 -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 +40 -13
  36. data/lib/archsight/export/confluence/labels.rb +25 -0
  37. data/lib/archsight/export/confluence/page_properties.rb +4 -6
  38. data/lib/archsight/export/confluence/storage.rb +38 -2
  39. data/lib/archsight/helpers/embeds.rb +18 -7
  40. data/lib/archsight/helpers/resource_resolver.rb +25 -0
  41. data/lib/archsight/import/executor.rb +139 -16
  42. data/lib/archsight/import/handler.rb +55 -6
  43. data/lib/archsight/import/handler_loader.rb +64 -0
  44. data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
  45. data/lib/archsight/import/handlers/command.rb +120 -0
  46. data/lib/archsight/import/handlers/github.rb +4 -1
  47. data/lib/archsight/import/handlers/gitlab.rb +4 -1
  48. data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
  49. data/lib/archsight/import/handlers/go_grapher.rb +102 -5
  50. data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
  51. data/lib/archsight/import/handlers/grapher.rb +40 -1
  52. data/lib/archsight/import/handlers/repository.rb +3 -0
  53. data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
  54. data/lib/archsight/import/registry.rb +22 -1
  55. data/lib/archsight/import.rb +64 -1
  56. data/lib/archsight/linter/structure.rb +239 -0
  57. data/lib/archsight/linter.rb +121 -14
  58. data/lib/archsight/page_loader.rb +12 -1
  59. data/lib/archsight/query/evaluator.rb +13 -6
  60. data/lib/archsight/query/parser.rb +73 -6
  61. data/lib/archsight/query.rb +1 -1
  62. data/lib/archsight/references.rb +3 -3
  63. data/lib/archsight/renderer.rb +80 -23
  64. data/lib/archsight/resources/analysis.rb +3 -2
  65. data/lib/archsight/resources/application_component.rb +75 -7
  66. data/lib/archsight/resources/application_service.rb +12 -1
  67. data/lib/archsight/resources/base.rb +58 -5
  68. data/lib/archsight/resources/business_actor.rb +5 -0
  69. data/lib/archsight/resources/business_contract.rb +72 -0
  70. data/lib/archsight/resources/business_product.rb +12 -0
  71. data/lib/archsight/resources/business_role.rb +1 -0
  72. data/lib/archsight/resources/implementation_deliverable.rb +1 -0
  73. data/lib/archsight/resources/implementation_plateau.rb +8 -2
  74. data/lib/archsight/resources/implementation_work_package.rb +2 -1
  75. data/lib/archsight/resources/import.rb +37 -11
  76. data/lib/archsight/resources/location.rb +56 -0
  77. data/lib/archsight/resources/motivation_requirement.rb +6 -0
  78. data/lib/archsight/resources/motivation_stakeholder.rb +14 -0
  79. data/lib/archsight/resources/page.rb +4 -7
  80. data/lib/archsight/resources/strategy_capability.rb +9 -0
  81. data/lib/archsight/resources/technology_artifact.rb +11 -4
  82. data/lib/archsight/resources/technology_communication_network.rb +89 -0
  83. data/lib/archsight/resources/technology_device.rb +88 -0
  84. data/lib/archsight/resources/technology_equipment.rb +75 -0
  85. data/lib/archsight/resources/technology_facility.rb +70 -0
  86. data/lib/archsight/resources/technology_node.rb +50 -0
  87. data/lib/archsight/resources/technology_service.rb +33 -0
  88. data/lib/archsight/resources/technology_system_software.rb +28 -0
  89. data/lib/archsight/resources.rb +12 -0
  90. data/lib/archsight/version.rb +1 -1
  91. data/lib/archsight/web/api/analysis_helpers.rb +55 -0
  92. data/lib/archsight/web/api/json_helpers.rb +3 -37
  93. data/lib/archsight/web/api/openapi/spec.yaml +10 -8
  94. data/lib/archsight/web/api/page_helpers.rb +1 -8
  95. data/lib/archsight/web/api/routes.rb +7 -2
  96. data/lib/archsight/web/application.rb +38 -2
  97. data/lib/archsight/web/editor/routes.rb +2 -1
  98. data/lib/archsight/web/public/vue/ApiDocsPage-COp4onwn.js +1 -0
  99. data/lib/archsight/web/public/vue/DocPage-D52OwaKc.js +1 -0
  100. data/lib/archsight/web/public/vue/EditorPage-3aqL7yVf.js +35 -0
  101. data/lib/archsight/web/public/vue/ErrorPage--b9XeSYa.js +2 -0
  102. data/lib/archsight/web/public/vue/GraphView-qh_EtRrQ.js +1 -0
  103. data/lib/archsight/web/public/vue/HomePage-C2TNh2U-.js +2 -0
  104. data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
  105. data/lib/archsight/web/public/vue/InstanceRouter-uzb_OMG0.js +1 -0
  106. data/lib/archsight/web/public/vue/KindList-CyJuP_uq.js +1 -0
  107. data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
  108. data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
  109. data/lib/archsight/web/public/vue/PageView-FyygrS-b.js +1 -0
  110. data/lib/archsight/web/public/vue/QueryError-B4Ex_csZ.js +1 -0
  111. data/lib/archsight/web/public/vue/ResourceList-BY6Z1Kcb.js +2 -0
  112. data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
  113. data/lib/archsight/web/public/vue/{SearchResults-BewvsfOc.css → SearchResults-BV6UHriJ.css} +1 -1
  114. data/lib/archsight/web/public/vue/SearchResults-CkFpHF8m.js +1 -0
  115. data/lib/archsight/web/public/vue/WikiPage-BoHNewCX.js +3 -0
  116. data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
  117. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BvzKZE4e.js +1 -0
  118. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-D-xs_Hxh.js +1 -0
  119. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-nbjxG01S.js +1 -0
  120. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-CW6mw2ab.js +1 -0
  121. data/lib/archsight/web/public/vue/index-BGlYCbi7.js +3 -0
  122. data/lib/archsight/web/public/vue/index-eUHmJ7K2.css +1 -0
  123. data/lib/archsight/web/public/vue/info-A6RAGUB7-C12sjlHe.js +1 -0
  124. data/lib/archsight/web/public/vue/{mermaid-BjEi5URd.js → mermaid-DjsXQ_XN.js} +35 -35
  125. data/lib/archsight/web/public/vue/packet-AYTQ26CC-wMwKwHmA.js +1 -0
  126. data/lib/archsight/web/public/vue/pie-WAS4IAKB-w1-sAU_K.js +1 -0
  127. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-DpwqIFoy.js +1 -0
  128. data/lib/archsight/web/public/vue/railroad-74A4TZTK-Ce5XC24d.js +1 -0
  129. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-BSQO2pds.js +1 -0
  130. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-Dq1c2YtD.js +1 -0
  131. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DKj6dli0.js +1 -0
  132. data/lib/archsight/web/public/vue/rolldown-runtime-hePW80VL.js +1 -0
  133. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-BmHNTWxU.js +1 -0
  134. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DsEI7sSD.js +1 -0
  135. data/lib/archsight/web/public/vue/{useGraphviz-DlSnFeCL.js → useGraphviz-4evusK9i.js} +1 -1
  136. data/lib/archsight/web/public/vue/{useHighlight-D5Gf8DLl.js → useHighlight-DPguaxfm.js} +8 -8
  137. data/lib/archsight/web/public/vue/usePanZoom-seQE1TPk.js +12 -0
  138. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-CSCW83-M.js +1 -0
  139. data/lib/archsight/web/public/vue.html +5 -5
  140. metadata +58 -38
  141. data/lib/archsight/web/public/vue/ApiDocsPage-BgqnQgwa.js +0 -1
  142. data/lib/archsight/web/public/vue/DocPage-CNO71nBH.js +0 -1
  143. data/lib/archsight/web/public/vue/EditorPage-DR0FCNTz.js +0 -35
  144. data/lib/archsight/web/public/vue/ErrorPage-BfYArv6s.js +0 -2
  145. data/lib/archsight/web/public/vue/GraphView-BVTW30Ak.js +0 -1
  146. data/lib/archsight/web/public/vue/HomePage-Wk9P4Pma.js +0 -2
  147. data/lib/archsight/web/public/vue/InstanceRouter-BV8ycydz.js +0 -1
  148. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
  149. data/lib/archsight/web/public/vue/KindList-C8yMsN5J.js +0 -1
  150. data/lib/archsight/web/public/vue/PageView-BaN6TyJB.js +0 -1
  151. data/lib/archsight/web/public/vue/QueryError-D790wH-a.js +0 -1
  152. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
  153. data/lib/archsight/web/public/vue/ResourceList-D-66nas2.js +0 -2
  154. data/lib/archsight/web/public/vue/SearchResults-CHyqIerQ.js +0 -1
  155. data/lib/archsight/web/public/vue/WikiPage-D2svpk6B.js +0 -13
  156. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
  157. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
  158. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
  159. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
  160. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
  161. data/lib/archsight/web/public/vue/index-BXXkUK1V.js +0 -3
  162. data/lib/archsight/web/public/vue/index-Cov1SnzY.css +0 -1
  163. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
  164. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
  165. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
  166. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
  167. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
  168. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
  169. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
  170. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
  171. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
  172. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
  173. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
data/lib/archsight/cli.rb CHANGED
@@ -52,8 +52,8 @@ module Archsight
52
52
  private
53
53
 
54
54
  def load_handlers
55
- handlers_dir = File.expand_path("import/handlers", __dir__)
56
- Dir.glob(File.join(handlers_dir, "*.rb")).each { |f| require f }
55
+ require "archsight/import"
56
+ Archsight::Import::HandlerLoader.load_builtin!
57
57
  end
58
58
 
59
59
  def resolve_handler(path, language)
@@ -133,6 +133,12 @@ module Archsight
133
133
  warnings.each { |warning| puts " #{warning}" }
134
134
  end
135
135
 
136
+ structure = linter.structural_warnings
137
+ if structure.any?
138
+ puts "Modeling warnings (#{structure.count}):"
139
+ structure.each { |warning| puts " #{warning}" }
140
+ end
141
+
136
142
  if errors.any?
137
143
  puts "Validation Errors (#{errors.count}):"
138
144
  errors.each { |error| display_error_with_context(error) }
@@ -172,15 +178,18 @@ module Archsight
172
178
  option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "Show execution plan without running"
173
179
  option :filter, aliases: "-f", type: :string, desc: "Filter imports by name (regex pattern)"
174
180
  option :force, aliases: "-F", type: :boolean, default: false, desc: "Ignore cache and re-run all imports"
181
+ option :deps, type: :boolean, default: true,
182
+ desc: "With -f, also run the imports the matched ones depend on (--no-deps: only the matched ones)"
183
+ option :lint, type: :boolean, default: false, desc: "Validate the resources after the run (like archsight lint)"
175
184
  def import
176
185
  configure_resources
177
186
  require "archsight/database"
178
- require "archsight/import/executor"
187
+ require "archsight/import"
179
188
 
180
189
  resources_dir = Archsight.resources_dir
181
190
 
182
- # Load all handlers
183
- require_import_handlers
191
+ # Built-in handlers; the executor loads the handlers of the resources repository (import/handlerPath)
192
+ Archsight::Import::HandlerLoader.load_builtin!
184
193
 
185
194
  # Create database that loads from resources directory
186
195
  # Only load the kinds that import handlers inspect at runtime (ApplicationComponent
@@ -199,7 +208,8 @@ module Archsight
199
208
  resources_dir: resources_dir,
200
209
  verbose: true,
201
210
  filter: options[:filter],
202
- force: options[:force]
211
+ force: options[:force],
212
+ deps: options[:deps]
203
213
  )
204
214
  executor.execution_plan
205
215
  else
@@ -208,10 +218,12 @@ module Archsight
208
218
  resources_dir: resources_dir,
209
219
  verbose: options[:verbose],
210
220
  filter: options[:filter],
211
- force: options[:force]
221
+ force: options[:force],
222
+ deps: options[:deps]
212
223
  )
213
224
  executor.run!
214
225
  puts "All imports completed successfully."
226
+ lint_after_import if options[:lint]
215
227
  end
216
228
  rescue Archsight::Import::InterruptedError
217
229
  # Graceful shutdown already handled by executor
@@ -231,6 +243,11 @@ module Archsight
231
243
  option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Verbose output"
232
244
  option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "List analyses without running"
233
245
  option :filter, aliases: "-f", type: :string, desc: "Filter analyses by name (regex pattern)"
246
+ option :format, type: :string, default: "terminal", enum: %w[terminal typst],
247
+ desc: "Output format: terminal rendering or Typst source (https://typst.app)"
248
+ option :output, aliases: "-o", type: :string, desc: "Write the Typst source to this file instead of stdout"
249
+ option :template, type: :string,
250
+ desc: "Typst file to import for the layout; it must define archsight-report and analysis-result"
234
251
  def analyze
235
252
  configure_resources
236
253
  require "archsight/database"
@@ -243,7 +260,7 @@ module Archsight
243
260
  return print_analysis_dry_run(analyses) if options[:dry_run]
244
261
 
245
262
  results = execute_analyses(db, analyses)
246
- print_analysis_results(results)
263
+ options[:format] == "typst" ? write_analysis_typst(results) : print_analysis_results(results)
247
264
  exit 1 if results.any?(&:failed?)
248
265
  end
249
266
 
@@ -253,7 +270,8 @@ module Archsight
253
270
  end
254
271
 
255
272
  desc "diagram INPUT...", "Render .asd diagram files to SVG"
256
- long_desc "Renders each INPUT.asd to an SVG next to it (or to --output for a single input)."
273
+ long_desc "Renders each INPUT.asd to an SVG next to it (or to --output for a single input). " \
274
+ "With --format, re-indents the inputs in place instead (--check: only report the ones that are not formatted)."
257
275
  option :output, aliases: "-o", type: :string, desc: "SVG output path (single input file only)"
258
276
  option :watch, aliases: "-w", type: :boolean, desc: "Re-render whenever an input file changes"
259
277
  option :relation, type: :string, desc: "Edge relations to draw: comma-separated list, or 'all' (default: dependency,implements)"
@@ -261,6 +279,8 @@ module Archsight
261
279
  option :theme, type: :string, desc: "Spacing/font-size theme (default: the file's own `theme` statement)"
262
280
  option :legend, type: :string, desc: "Legend placement (default: the file's own `legend` statement, else auto)"
263
281
  option :profile, aliases: "-p", type: :boolean, desc: "Print stage timing stats after each render"
282
+ option :format, type: :boolean, desc: "Re-indent the inputs in place instead of rendering them (-o: write a single input there)"
283
+ option :check, type: :boolean, desc: "With --format: only list the inputs that are not formatted, exit 1 if any"
264
284
  def diagram(*inputs)
265
285
  require "archsight/diagram/cli"
266
286
 
@@ -275,6 +295,7 @@ module Archsight
275
295
 
276
296
  status = diagram::CLI.new.run_with(
277
297
  inputs: inputs, output: options[:output], watch: options[:watch], profile: options[:profile],
298
+ format: options[:format], check: options[:check],
278
299
  style: options[:style], theme: options[:theme], legend: options[:legend],
279
300
  relation_filter: if relation.nil?
280
301
  diagram::Relations::DEFAULT_FILTER
@@ -288,7 +309,7 @@ module Archsight
288
309
  desc "export [PAGE...]", "Export wiki pages to another system"
289
310
  long_desc <<~DESC
290
311
  Publishes wiki pages to the system named by --to. With no PAGE, every page that links to a target
291
- page (for confluence: `confluence: <page URL>` in its frontmatter) is exported. --tag TAG limits the export to pages carrying one of the given tags.
312
+ page (for confluence: `links: { confluence: <page URL> }` in its frontmatter) is exported. --tag TAG limits the export to pages carrying one of the given tags.
292
313
 
293
314
  confluence: a page is only overwritten when Confluence still holds what the last export wrote. Pages
294
315
  never exported before, or edited in Confluence since, are reported as blocked and not exported until
@@ -348,18 +369,78 @@ module Archsight
348
369
  results.each { |r| puts "#{labels.fetch(r.status).ljust(width)} #{r.page}: #{r.message}" }
349
370
  counts = results.group_by(&:status).transform_values(&:count)
350
371
  puts counts.map { |status, count| "#{count} #{labels.fetch(status)}" }.join(", ") unless results.empty?
351
- puts "Nothing to export: no page has a `confluence:` link." if results.empty?
372
+ puts "Nothing to export: no page has a `confluence` link." if results.empty?
352
373
  end
353
374
 
354
- def configure_resources
355
- Archsight.resources_dir = options[:resources] if options[:resources]
375
+ # `archsight import --lint`: validate the whole model after the run. A resource with an error that an import
376
+ # generated names that import, so a dangling reference shows up with the import that caused it.
377
+ def lint_after_import
378
+ require "archsight/linter"
379
+ require "archsight/helpers"
380
+
381
+ db = Archsight::Database.new(Archsight.resources_dir, compute_annotations: false)
382
+ begin
383
+ db.reload!
384
+ rescue Archsight::ResourceError => e
385
+ # a relation to a resource that does not exist stops the load: name the import of the resource it is in
386
+ display_error_with_context("#{e}#{generated_by_note(producer_at(e.ref))}")
387
+ exit 1
388
+ end
389
+ errors = Archsight::Linter.new(db).validate
390
+ if errors.empty?
391
+ puts "All validations passed!"
392
+ return
393
+ end
394
+
395
+ puts "Validation Errors (#{errors.count}):"
396
+ errors.each { |error| display_error_with_context(with_generating_import(db, error)) }
397
+ exit 1
398
+ end
399
+
400
+ # Add "(generated by Import:X)" to a lint error at a location an import wrote
401
+ def with_generating_import(db, error)
402
+ path, line_no = error.match(/\A(.+?):(\d+): /)&.captures
403
+ return error unless path
404
+
405
+ "#{error}#{generated_by_note(producer_of(db, path, line_no.to_i))}"
356
406
  end
357
407
 
358
- def require_import_handlers
359
- handlers_dir = File.expand_path("import/handlers", __dir__)
360
- Dir.glob(File.join(handlers_dir, "*.rb")).each do |handler_file|
361
- require handler_file
408
+ # The import that a load error (a relation to a resource that does not exist) comes from, read without resolving
409
+ # relations
410
+ def producer_at(ref)
411
+ return nil unless ref.respond_to?(:path)
412
+
413
+ db = Archsight::Database.new(Archsight.resources_dir, compute_annotations: false, verify: false)
414
+ db.reload!
415
+ producer_of(db, ref.path, ref.line_no)
416
+ rescue Archsight::ResourceError
417
+ nil
418
+ end
419
+
420
+ # The import a resource at a file location comes from: its `generated/script`, else the import whose output file
421
+ # it is in (a command writes its documents unmarked)
422
+ def producer_of(db, path, line_no)
423
+ full_path = File.expand_path(path)
424
+ instance = db.instances.values.flat_map(&:values).find do |inst|
425
+ File.expand_path(inst.path_ref.path) == full_path && inst.path_ref.line_no == line_no
362
426
  end
427
+ instance&.annotations&.[]("generated/script") || import_writing(db, full_path)
428
+ end
429
+
430
+ def import_writing(db, full_path)
431
+ db.instances_by_kind("Import").values.find do |import|
432
+ Archsight::Import.output_files(import).any? do |rel_path|
433
+ Archsight::Import.resources_path(Archsight.resources_dir, rel_path) == full_path
434
+ end
435
+ end&.name
436
+ end
437
+
438
+ def generated_by_note(producer)
439
+ producer.to_s.empty? ? "" : " (generated by #{producer})"
440
+ end
441
+
442
+ def configure_resources
443
+ Archsight.resources_dir = options[:resources] if options[:resources]
363
444
  end
364
445
 
365
446
  def load_database_for_analysis
@@ -374,12 +455,12 @@ module Archsight
374
455
  def filter_analyses(db)
375
456
  analyses = db.instances_by_kind("Analysis").values
376
457
  analyses = analyses.select { |a| Regexp.new(options[:filter], Regexp::IGNORECASE).match?(a.name) } if options[:filter]
377
- analyses
458
+ analyses.sort_by(&:name) # same order as the dry run, independent of file layout
378
459
  end
379
460
 
380
461
  def print_analysis_dry_run(analyses)
381
462
  puts "Analyses to run#{" (filter: #{options[:filter]})" if options[:filter]}:"
382
- analyses.sort_by(&:name).each_with_index do |analysis, idx|
463
+ analyses.each_with_index do |analysis, idx|
383
464
  timeout = analysis.annotations["analysis/timeout"] || "30s"
384
465
  desc = analysis.annotations["analysis/description"] || "(no description)"
385
466
  puts " #{idx + 1}. #{analysis.name} [#{timeout}]"
@@ -392,6 +473,26 @@ module Archsight
392
473
  analyses.map { |analysis| executor.execute(analysis) }
393
474
  end
394
475
 
476
+ def write_analysis_typst(results)
477
+ typst = Archsight::Analysis::TypstRenderer.new(results, verbose: options[:verbose],
478
+ template: typst_template_path).render
479
+ if options[:output]
480
+ File.write(options[:output], typst)
481
+ warn "Wrote #{options[:output]}"
482
+ else
483
+ puts typst
484
+ end
485
+ end
486
+
487
+ # Typst resolves imports relative to the importing file, so point from the output file to the template
488
+ def typst_template_path
489
+ return unless options[:template]
490
+
491
+ require "pathname"
492
+ base = Pathname.new(File.expand_path(File.dirname(options[:output] || ".")))
493
+ Pathname.new(File.expand_path(options[:template])).relative_path_from(base).to_s
494
+ end
495
+
395
496
  def print_analysis_results(results)
396
497
  require "tty-markdown"
397
498
 
@@ -419,14 +520,14 @@ module Archsight
419
520
  end
420
521
 
421
522
  def build_analysis_summary_markdown(results)
422
- passed = results.count(&:success?)
523
+ completed = results.count(&:success?)
423
524
  failed = results.count(&:failed?)
424
- with_findings = results.count(&:has_findings?)
525
+ with_warnings = results.count { |result| result.success? && result.warnings? }
425
526
 
426
527
  lines = ["---", "", "# Summary", ""]
427
- lines << "- ✅ **#{passed}** passed"
528
+ lines << "- ✅ **#{completed}** completed"
428
529
  lines << "- ❌ **#{failed}** failed" if failed.positive?
429
- lines << "- ⚠️ **#{with_findings}** with findings" if with_findings.positive?
530
+ lines << "- ⚠️ **#{with_warnings}** with warnings" if with_warnings.positive?
430
531
  lines.join("\n")
431
532
  end
432
533
 
@@ -75,6 +75,7 @@ module Archsight
75
75
  puts "parsing #{path}..." if @verbose
76
76
  File.extname(path) == ".md" ? load_page(path) : load_file(path)
77
77
  end
78
+ @instances.each_value { |instances| instances.each_value(&:apply_annotation_defaults!) }
78
79
 
79
80
  verify! if @verify
80
81
  derive_references! if @verify
@@ -93,6 +94,21 @@ module Archsight
93
94
  @instances[Archsight::Resources[kind]][instance]
94
95
  end
95
96
 
97
+ # The resource of a kind that has `identifier` as one of its aliases (a value of a unique annotation), when exactly
98
+ # one has; nil otherwise. A name always wins, so callers try instance_by_kind first.
99
+ def instance_by_alias(kind, identifier)
100
+ klass = Archsight::Resources[kind]
101
+ return nil unless klass
102
+
103
+ unique = klass.annotations.select(&:unique?).reject(&:pattern?)
104
+ return nil if unique.empty?
105
+
106
+ matches = instances_by_kind(kind).values.select do |instance|
107
+ unique.any? { |annotation| Array(annotation.value_for(instance)).map(&:to_s).include?(identifier) }
108
+ end
109
+ matches.first if matches.one?
110
+ end
111
+
96
112
  # Collect unique annotation values across all instances of a kind
97
113
  def annotation_values(kind, annotation)
98
114
  instances = instances_by_kind(kind).values
@@ -147,6 +163,8 @@ module Archsight
147
163
  obj["kind"] = current
148
164
  end
149
165
 
166
+ accept_old_annotations(obj)
167
+
150
168
  return unless obj["spec"].is_a?(Hash)
151
169
 
152
170
  obj["spec"].each do |verb, keys|
@@ -161,6 +179,28 @@ module Archsight
161
179
  end
162
180
  end
163
181
 
182
+ # A renamed annotation key (page/confluence is now link/confluence) is moved to its new key
183
+ def accept_old_annotations(obj)
184
+ annotations = obj.dig("metadata", "annotations")
185
+ return unless annotations.is_a?(Hash)
186
+
187
+ Archsight::Resources::ANNOTATION_ALIASES.each do |old_key, new_key|
188
+ next unless annotations.key?(old_key)
189
+
190
+ deprecate("annotation '#{old_key}' is renamed to '#{new_key}'#{legacy_frontmatter_hint(old_key)}")
191
+ value = annotations.delete(old_key)
192
+ annotations[new_key] ||= value
193
+ end
194
+ end
195
+
196
+ # In a page the annotation is a frontmatter key: `confluence:` is now `links: { confluence: ... }`
197
+ def legacy_frontmatter_hint(old_key)
198
+ return "" unless old_key.start_with?("page/")
199
+
200
+ name = old_key.delete_prefix("page/")
201
+ " (frontmatter `#{name}:` is now `links: { #{name}: <url> }`)"
202
+ end
203
+
164
204
  def deprecate(message)
165
205
  @deprecations << Deprecation.new(@current_ref, "#{message} (the old name will be removed in a future release)")
166
206
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "optparse"
4
4
  require_relative "."
5
+ require_relative "formatter"
5
6
 
6
7
  module Archsight
7
8
  module Diagram
@@ -18,7 +19,7 @@ module Archsight
18
19
  end
19
20
 
20
21
  # Runs with already-parsed options (:inputs, :output, :watch, :relation_filter,
21
- # :style, :theme, :legend, :profile), e.g. from the `archsight diagram` Thor command.
22
+ # :style, :theme, :legend, :profile, :format, :check), e.g. from the `archsight diagram` Thor command.
22
23
  def run_with(options)
23
24
  if options[:inputs].empty?
24
25
  warn "archsight-diagram: missing input file\n\n#{usage}"
@@ -30,6 +31,8 @@ module Archsight
30
31
  return 1
31
32
  end
32
33
 
34
+ return format_files(options) if options[:format]
35
+
33
36
  if options[:watch]
34
37
  watch(options)
35
38
  0
@@ -76,6 +79,12 @@ module Archsight
76
79
  opts.on("-p", "--profile", "Print stage timing stats after each render") do
77
80
  options[:profile] = true
78
81
  end
82
+ opts.on("--format", "Re-indent the inputs in place instead of rendering them (-o: write a single input there)") do
83
+ options[:format] = true
84
+ end
85
+ opts.on("--check", "With --format: only list the inputs that are not formatted, exit 1 if any") do
86
+ options[:check] = true
87
+ end
79
88
  opts.on("-h", "--help", "Show this help") do
80
89
  puts opts
81
90
  exit 0
@@ -90,7 +99,46 @@ module Archsight
90
99
  end
91
100
 
92
101
  def usage
93
- "Usage: archsight-diagram INPUT.asd [INPUT2.asd ...] [-o OUTPUT.svg] [--watch] [--relation=LIST] [--style=STYLE] [--theme=NAME] [--legend=MODE] [-p]"
102
+ "Usage: archsight-diagram INPUT.asd [INPUT2.asd ...] [-o OUTPUT.svg] [--watch] [--relation=LIST] [--style=STYLE] [--theme=NAME] [--legend=MODE] [-p] [--format [--check]]"
103
+ end
104
+
105
+ # `--format`: each input re-indented (see `Formatter`) and written back,
106
+ # or to `--output`; with `--check` nothing is written, the inputs that
107
+ # would change are listed and the status is 1 if there are any.
108
+ def format_files(options)
109
+ if options[:watch]
110
+ warn "archsight-diagram: --format cannot be combined with --watch"
111
+ return 1
112
+ end
113
+
114
+ statuses = options[:inputs].map { |input| format_once(input, options) }
115
+ statuses.all?(&:zero?) ? 0 : 1
116
+ end
117
+
118
+ def format_once(input, options)
119
+ source = File.read(input)
120
+ formatted = Formatter.format(source)
121
+ if options[:check]
122
+ return 0 if formatted == source
123
+
124
+ puts "archsight-diagram: #{input} is not formatted"
125
+ return 1
126
+ end
127
+
128
+ output = options[:output] || input
129
+ if output == input && formatted == source
130
+ puts "archsight-diagram: #{input} already formatted"
131
+ else
132
+ File.write(output, formatted)
133
+ puts "archsight-diagram: formatted #{output}"
134
+ end
135
+ 0
136
+ rescue Archsight::Diagram::Error => e
137
+ warn "archsight-diagram: #{input}: #{e.message}"
138
+ 1
139
+ rescue Errno::ENOENT => e
140
+ warn "archsight-diagram: #{e.message}"
141
+ 1
94
142
  end
95
143
 
96
144
  def output_for(input, explicit_output)
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require_relative "errors"
5
+ require_relative "parser"
6
+
7
+ module Archsight
8
+ module Diagram
9
+ # Tidies `.asd` source without rewriting it: every line is indented by
10
+ # its brace depth (`INDENT` per level, a line opening with `}` one level
11
+ # less), trailing whitespace is dropped, runs of blank lines become one
12
+ # and the file ends in exactly one newline. Comments, line breaks and
13
+ # one-line blocks (`component "a" { label "x"; tint "y" }`) stay as
14
+ # written. Works on the text, not the parse tree, because the lexer
15
+ # drops comments.
16
+ #
17
+ # A string spanning several lines keeps its blank lines; its
18
+ # continuation lines are indented one level below the line that opens
19
+ # it (the lexer strips that indentation, so it is not content).
20
+ #
21
+ # `format` checks that the result parses to the same diagram as the
22
+ # source (line numbers aside) and raises `Error` if not, so a bug here
23
+ # can never change a diagram.
24
+ module Formatter
25
+ INDENT = " "
26
+
27
+ module_function
28
+
29
+ # @raise [Archsight::Diagram::Error] if `source` does not parse
30
+ def format(source)
31
+ before = comparable(Parser.parse(source))
32
+ formatted = reindent(source)
33
+ raise Error, "formatting would change the diagram" unless comparable(Parser.parse(formatted)) == before
34
+
35
+ formatted
36
+ end
37
+
38
+ def reindent(source)
39
+ lines = []
40
+ depth = 0
41
+ in_string = false
42
+ source.each_line(chomp: true) do |raw|
43
+ text = raw.rstrip
44
+ continuation = in_string
45
+ closers, delta, in_string = scan(text, in_string)
46
+ text = text.lstrip unless continuation && text.empty?
47
+ level = continuation ? depth + 1 : [depth - closers, 0].max
48
+ lines << (text.empty? ? "" : (INDENT * level) + text) << continuation
49
+ depth = [depth + delta, 0].max
50
+ end
51
+ tidy(lines.each_slice(2).to_a)
52
+ end
53
+
54
+ # `[leading closing braces, brace depth change, still inside a string
55
+ # at the end]` of one line, ignoring braces in strings and comments.
56
+ def scan(text, in_string)
57
+ scanner = StringScanner.new(text)
58
+ delta = 0
59
+ closers = 0
60
+ leading = !in_string
61
+ until scanner.eos?
62
+ if in_string
63
+ in_string = false if scanner.scan(/(?:[^"\\]|\\.)*"/)
64
+ scanner.terminate if in_string
65
+ leading = false
66
+ elsif scanner.skip(/\s+/)
67
+ next
68
+ elsif scanner.skip(/#.*/)
69
+ break
70
+ elsif scanner.skip(/"/)
71
+ in_string = true
72
+ leading = false
73
+ elsif scanner.skip(/\{/)
74
+ delta += 1
75
+ leading = false
76
+ elsif scanner.skip(/\}/)
77
+ delta -= 1
78
+ closers += 1 if leading
79
+ else
80
+ scanner.skip(/[^\s"{}#]+/)
81
+ leading = false
82
+ end
83
+ end
84
+ [closers, delta, in_string]
85
+ end
86
+
87
+ # Joins `[line, inside a string]` pairs: one blank line at most between
88
+ # statements (blank lines inside a string are content), none at the
89
+ # start or end.
90
+ def tidy(pairs)
91
+ out = []
92
+ pairs.each do |line, continuation|
93
+ next if line.empty? && !continuation && (out.empty? || out.last.empty?)
94
+
95
+ out << line
96
+ end
97
+ out.pop while out.last&.empty?
98
+ out.empty? ? "" : "#{out.join("\n")}\n"
99
+ end
100
+
101
+ # The parse tree without line numbers, which formatting may change.
102
+ def comparable(node)
103
+ case node
104
+ when Array then node.map { |child| comparable(child) }
105
+ when Hash then node.transform_values { |value| comparable(value) }
106
+ when Struct then [node.class, node.to_h.except(:line).transform_values { |value| comparable(value) }]
107
+ else node
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -24,6 +24,14 @@ module Archsight
24
24
  # resorting to a two-turn mid-jog.
25
25
  SINGLE_TURN_RATIOS = [0.5, 0.1, 0.9].freeze
26
26
 
27
+ # How far a single turn's exit may also move off `a`'s center, away
28
+ # from `b`, as a fraction of `a`'s extent: a source whose center
29
+ # lies just past `b`'s side would otherwise turn right along that
30
+ # side, in a stub too short to read (see `EdgeRouter::MIN_END_LEG`).
31
+ # Only offered for such a source: anywhere else the centered exit
32
+ # stays the only one.
33
+ EXIT_SHIFT = 0.25
34
+
27
35
  # A single turn entering the target off its center, remembering by
28
36
  # how far. Entering nearer the source is always exactly that much
29
37
  # shorter, so `EdgeRouter.score_candidates` charges it back: the
@@ -57,8 +65,10 @@ module Archsight
57
65
  dx = b.x - a.x
58
66
  dy = b.y - a.y
59
67
 
60
- horizontal = SINGLE_TURN_RATIOS.map { |ratio| single_turn_horizontal_first(a, b, dx, dy, ratio) }
61
- vertical = SINGLE_TURN_RATIOS.map { |ratio| single_turn_vertical_first(a, b, dx, dy, ratio) }
68
+ # Centered exits first, the ones shifted away from `b` after them.
69
+ exits = [0.0, EXIT_SHIFT]
70
+ horizontal = exits.product(SINGLE_TURN_RATIOS).map { |shift, ratio| single_turn_horizontal_first(a, b, dx, dy, ratio, shift) }
71
+ vertical = exits.product(SINGLE_TURN_RATIOS).map { |shift, ratio| single_turn_vertical_first(a, b, dx, dy, ratio, shift) }
62
72
  # Centered entries first (dominant axis leading), off-center ones after.
63
73
  singles = if dx.abs >= dy.abs
64
74
  [horizontal.first, vertical.first, *horizontal.drop(1), *vertical.drop(1)]
@@ -125,15 +135,21 @@ module Archsight
125
135
  # also guarantees the corner-to-entry run (at x = entry_x) clears `a`
126
136
  # entirely; and (2) `a`'s own y sits outside `b`'s vertical span, so
127
137
  # the exit-to-corner run (at y = a.y) clears `b` entirely.
128
- def single_turn_horizontal_first(a, b, dx, dy, ratio = 0.5)
138
+ # `exit_shift` moves the exit that fraction of `a`'s height away from
139
+ # `b` (see `EXIT_SHIFT`).
140
+ def single_turn_horizontal_first(a, b, dx, dy, ratio = 0.5, exit_shift = 0.0)
129
141
  return nil if dx.zero?
130
142
 
131
143
  corner_x = ratio == 0.5 ? b.x : b.left + (b.width * ratio)
132
144
  return nil if dx.positive? ? (corner_x <= a.right) : (corner_x >= a.left)
133
- return nil unless a.y < b.top || a.y > b.bottom
134
145
 
135
- exit_point = [dx.positive? ? a.right : a.left, a.y]
136
- corner = [corner_x, a.y]
146
+ return nil unless exit_shift.zero? || short_run?(a.y, b.top, b.bottom)
147
+
148
+ exit_y = a.y - ((dy <=> 0) * a.height * exit_shift)
149
+ return nil unless exit_y < b.top || exit_y > b.bottom
150
+
151
+ exit_point = [dx.positive? ? a.right : a.left, exit_y]
152
+ corner = [corner_x, exit_y]
137
153
  entry_point = [corner_x, dy.positive? ? b.top : b.bottom]
138
154
  off_center(ratio, corner_x - b.x, [exit_point, corner, entry_point])
139
155
  end
@@ -141,19 +157,31 @@ module Archsight
141
157
  # The transpose of `single_turn_horizontal_first`: exit `a` from
142
158
  # whichever top/bottom edge faces `b`, corner at `(a.x, b.y)`, then
143
159
  # straight into `b`'s facing left/right edge.
144
- def single_turn_vertical_first(a, b, dx, dy, ratio = 0.5)
160
+ def single_turn_vertical_first(a, b, dx, dy, ratio = 0.5, exit_shift = 0.0)
145
161
  return nil if dy.zero?
146
162
 
147
163
  corner_y = ratio == 0.5 ? b.y : b.top + (b.height * ratio)
148
164
  return nil if dy.positive? ? (corner_y <= a.bottom) : (corner_y >= a.top)
149
- return nil unless a.x < b.left || a.x > b.right
150
165
 
151
- exit_point = [a.x, dy.positive? ? a.bottom : a.top]
152
- corner = [a.x, corner_y]
166
+ return nil unless exit_shift.zero? || short_run?(a.x, b.left, b.right)
167
+
168
+ exit_x = a.x - ((dx <=> 0) * a.width * exit_shift)
169
+ return nil unless exit_x < b.left || exit_x > b.right
170
+
171
+ exit_point = [exit_x, dy.positive? ? a.bottom : a.top]
172
+ corner = [exit_x, corner_y]
153
173
  entry_point = [dx.positive? ? b.left : b.right, corner_y]
154
174
  off_center(ratio, corner_y - b.y, [exit_point, corner, entry_point])
155
175
  end
156
176
 
177
+ # Whether a centered exit at `coord` lies just outside `b`'s span
178
+ # `low..high`, so its run into `b` would be shorter than
179
+ # `EdgeRouter::MIN_END_LEG`.
180
+ def short_run?(coord, low, high)
181
+ gap = coord < low ? low - coord : coord - high
182
+ gap.positive? && gap < EdgeRouter::MIN_END_LEG
183
+ end
184
+
157
185
  def off_center(ratio, offset, points)
158
186
  ratio == SINGLE_TURN_RATIOS.first ? points : OffCenterPath.new(points, offset.abs)
159
187
  end