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
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "kramdown"
3
+ require_relative "markdown"
4
4
  require_relative "helpers"
5
+ require_relative "linter/structure"
5
6
 
6
7
  module Archsight
7
8
  class Linter
@@ -10,9 +11,15 @@ module Archsight
10
11
 
11
12
  # Relations the cycle check leaves out. Relations are followed from the dependent to what it relies on, so
12
13
  # the declared ones form a DAG; `dependsOn` is the exception because components may depend on each other at
13
- # runtime. The derived relations (`mentions`, `depicts`) are sideways views and are not declared, so they
14
+ # runtime, and `connectedTo` because cabling between devices is cyclic by nature (a ring, a mesh, both ends of
15
+ # a cable). The derived relations (`mentions`, `depicts`) are sideways views and are not declared, so they
14
16
  # never enter the check.
15
- CYCLE_EXEMPT_VERBS = %i[dependsOn].freeze
17
+ CYCLE_EXEMPT_VERBS = %i[dependsOn connectedTo].freeze
18
+
19
+ # Structure relations between elements of one kind (specialization, composition, aggregation). Each verb is
20
+ # checked on its own: a chain of one of them must not lead back to where it started. They stay out of the
21
+ # general check above so that a cycle is reported once.
22
+ HIERARCHY_VERBS = %i[specializes composedOf aggregates].freeze
16
23
 
17
24
  def initialize(database)
18
25
  @database = database
@@ -20,6 +27,7 @@ module Archsight
20
27
  end
21
28
 
22
29
  def validate
30
+ validate_imports
23
31
  @database.instances.each_value do |instances_hash|
24
32
  instances_hash.each_value do |instance|
25
33
  validate_instance_annotations(instance)
@@ -29,18 +37,72 @@ module Archsight
29
37
  end
30
38
  end
31
39
  validate_relation_cycles
40
+ validate_hierarchy_cycles
41
+ validate_unique_annotations
32
42
 
33
43
  @errors
34
44
  end
35
45
 
36
- # Things that still work but should be changed: renamed kinds and relation keys that the files still use.
37
- # They are not errors, so they do not make `archsight lint` fail.
46
+ # Things that still work but should be changed: renamed kinds and relation keys that the files still use, and
47
+ # annotations their kind marks `deprecated:`. They are not errors, so they do not make `archsight lint` fail.
38
48
  def warnings
39
- @database.respond_to?(:deprecations) ? @database.deprecations.map(&:to_s) : []
49
+ renamed = @database.respond_to?(:deprecations) ? @database.deprecations.map(&:to_s) : []
50
+ renamed + deprecated_annotations
51
+ end
52
+
53
+ # Structural modeling checks (rules of "Choosing the Kind", see Linter::Structure). Warnings, never errors.
54
+ def structural_warnings
55
+ Structure.new(@database).warnings
40
56
  end
41
57
 
42
58
  private
43
59
 
60
+ # Each use of an annotation the kind marks `deprecated:`, with what to use instead
61
+ def deprecated_annotations
62
+ @database.instances.each_value.flat_map do |instances_hash|
63
+ instances_hash.each_value.flat_map do |instance|
64
+ instance.annotations.keys.filter_map do |key|
65
+ annotation = instance.class.annotation_matching(key)
66
+ "#{instance.path_ref}: #{instance.klass} '#{instance.name}' uses deprecated annotation '#{key}': #{annotation.deprecated}" if annotation&.deprecated?
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # Imports: the handlers of the resources repository load (before the `import/handler` names are checked against
73
+ # the registry), and every `import/dependsOn` entry names an import or is a pattern
74
+ def validate_imports
75
+ return unless @database.respond_to?(:instances_by_kind)
76
+
77
+ imports = @database.instances_by_kind("Import")&.values || []
78
+ return if imports.empty?
79
+
80
+ require "archsight/import/handler_loader"
81
+ Archsight::Import::HandlerLoader.load(@database).each { |problem| @errors << problem.to_s }
82
+ names = imports.to_set(&:name)
83
+ imports.each do |import|
84
+ Archsight::Import.declared_dependencies(import).each do |entry|
85
+ next if Archsight::Import.pattern?(entry) || names.include?(entry)
86
+
87
+ @errors << "#{import.path_ref}: Import '#{import.name}' depends on unknown import '#{entry}'"
88
+ end
89
+ end
90
+ validate_command_outputs(imports)
91
+ end
92
+
93
+ # A command import replaces its whole output file on every run, so no other import may write to it
94
+ def validate_command_outputs(imports)
95
+ file_of = ->(import) { File.expand_path(Archsight::Import.output_path(import), "/") }
96
+ imports.select { |import| import.annotations["import/handler"] == "command" }.each do |command|
97
+ others = imports.reject { |import| import.equal?(command) }.select { |import| file_of.call(import) == file_of.call(command) }
98
+ next if others.empty?
99
+
100
+ @errors << "#{command.path_ref}: Import '#{command.name}' runs a command, which replaces its output file " \
101
+ "#{Archsight::Import.output_path(command)}, but #{others.map { |o| "'#{o.name}'" }.join(", ")} " \
102
+ "also write#{"s" if others.one?} to it; give it a file of its own"
103
+ end
104
+ end
105
+
44
106
  # Pages need exactly one menu and their [[links]] must resolve
45
107
  def validate_page(page)
46
108
  menus = page.references.map { |r| r[:instance] }.select { |i| i.klass == "PageMenu" }.uniq
@@ -86,25 +148,62 @@ module Archsight
86
148
  nil
87
149
  end
88
150
 
151
+ # The values of a unique annotation (aliases) must not be shared by two resources of a kind
152
+ def validate_unique_annotations
153
+ @database.instances.each do |klass, instances_hash|
154
+ next unless klass.respond_to?(:annotations)
155
+
156
+ klass.annotations.select(&:unique?).reject(&:pattern?).each do |annotation|
157
+ owners = {}
158
+ instances_hash.each_value { |instance| check_unique_values(annotation, instance, owners) }
159
+ end
160
+ end
161
+ end
162
+
163
+ def check_unique_values(annotation, instance, owners)
164
+ Array(annotation.value_for(instance)).each do |value|
165
+ owner = owners[value]
166
+ if owner.nil?
167
+ owners[value] = instance
168
+ elsif !owner.equal?(instance)
169
+ @errors << "#{instance.path_ref}: #{instance.klass} '#{instance.name}': #{annotation.key} '#{value}' " \
170
+ "is already used by '#{owner.name}' (#{owner.path_ref})"
171
+ end
172
+ end
173
+ end
174
+
89
175
  # Declared relations must not lead back to where they started (see the "Direction of Relations" modeling guide)
90
176
  def validate_relation_cycles
91
- graph = relation_graph
177
+ graph = relation_graph { |verb| !CYCLE_EXEMPT_VERBS.include?(verb) && !HIERARCHY_VERBS.include?(verb) }
178
+ report_cycles(graph) { |start, path| "#{start.path_ref}: #{start.klass} '#{start.name}' is part of a relation cycle (#{path})" }
179
+ end
180
+
181
+ # `specializes`, `composedOf` and `aggregates` (one chain per verb) must not lead back to where they started
182
+ def validate_hierarchy_cycles
183
+ HIERARCHY_VERBS.each do |hierarchy_verb|
184
+ graph = relation_graph { |verb| verb == hierarchy_verb }
185
+ report_cycles(graph) { |start, path| "#{start.path_ref}: #{start.klass} '#{start.name}' is part of a cycle of #{hierarchy_verb} relations (#{path})" }
186
+ end
187
+ end
188
+
189
+ # Adds one error per group of instances that reach each other; the block builds the message from the first
190
+ # instance of the group and the path of one concrete cycle
191
+ def report_cycles(graph)
92
192
  strongly_connected_groups(graph).each do |group|
93
193
  cycle = cycle_through(group.first, group, graph)
94
- names = cycle.map { |step| step[:to].name }
95
194
  start = group.first
96
- path = ([start.name] + names).zip(cycle.map { |step| step[:verb] }).flat_map { |name, verb| [name, verb && "-#{verb}->"] }.compact
97
- @errors << "#{start.path_ref}: #{start.klass} '#{start.name}' is part of a relation cycle (#{path.join(" ")})"
195
+ path = ([start.name] + cycle.map { |step| step[:to].name }).zip(cycle.map { |step| step[:verb] }).flat_map { |name, verb| [name, verb && "-#{verb}->"] }.compact
196
+ @errors << yield(start, path.join(" "))
98
197
  end
99
198
  end
100
199
 
101
- # { instance => [{ verb:, to: }] } over the declared relations
200
+ # { instance => [{ verb:, to: }] } over the declared relations whose verb the block accepts
102
201
  def relation_graph
103
202
  graph = {}
104
203
  @database.instances.each_value do |instances_hash|
105
204
  instances_hash.each_value do |instance|
106
205
  graph[instance] = instance.class.declared_relations.flat_map do |verb, key, _kind|
107
- next [] if CYCLE_EXEMPT_VERBS.include?(verb)
206
+ next [] unless yield(verb)
108
207
 
109
208
  instance.relations(verb, key).map { |target| { verb: verb, to: target } }
110
209
  end
@@ -201,18 +300,26 @@ module Archsight
201
300
  next unless annotation.has_validation?
202
301
 
203
302
  # Validate value against annotation schema (type and enum constraints)
204
- annotation.validate(value).each do |error|
205
- @errors << "#{instance.path_ref}: Annotation '#{key}' #{error}"
206
- end
303
+ errors = annotation.validate(value)
304
+ errors.each { |error| @errors << "#{instance.path_ref}: Annotation '#{key}' #{error}" }
305
+ validate_resource_reference(instance, key, value) if annotation.resource? && errors.empty?
207
306
  end
208
307
  end
209
308
 
309
+ # The resource a `Name` / `Kind/Name` annotation points to must exist; a bare `Name` means the same kind
310
+ def validate_resource_reference(instance, key, value)
311
+ return if Archsight::Helpers::ResourceResolver.new(@database).find_annotated(value, instance.klass).is_a?(Array)
312
+
313
+ hint = value.to_s.include?("/") ? "" : " (a name without Kind/ is a #{instance.klass}, use Kind/Name for another kind)"
314
+ @errors << "#{instance.path_ref}: Annotation '#{key}' resource '#{value}' not found#{hint}"
315
+ end
316
+
210
317
  def validate_markdown(instance, key, value)
211
318
  # Skip markdown validation for generated files
212
319
  return if instance.annotations.key?("generated/script")
213
320
 
214
321
  begin
215
- Kramdown::Document.new(value, input: "GFM")
322
+ Archsight::Markdown.document(value)
216
323
  rescue StandardError => e
217
324
  @errors << "#{instance.path_ref}: Markdown syntax error in annotation '#{key}': #{e.message}"
218
325
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+ require "kramdown-parser-gfm"
5
+
6
+ module Archsight
7
+ # The one way markdown is parsed, for the web, the export, the linter and the analysis output alike. GFM, but a
8
+ # single newline inside a paragraph is a space, as in CommonMark and GitHub's rendering of `.md` files (the GFM
9
+ # parser of kramdown would make it a `<br />`, as GitHub does in comments). A line break is two trailing spaces,
10
+ # a trailing backslash or `<br>`.
11
+ module Markdown
12
+ OPTIONS = { input: "GFM", hard_wrap: false }.freeze
13
+
14
+ # Raw HTML whose text is kept as it is
15
+ VERBATIM = %w[pre code script style textarea].freeze
16
+
17
+ def self.document(text, **)
18
+ Kramdown::Document.new(text.to_s, **OPTIONS, **)
19
+ end
20
+
21
+ # The document without the newlines of the source in its text, for a target that keeps a newline instead of
22
+ # reading it as a space (Confluence): a soft line break becomes a space, the newline after a line break goes.
23
+ # Inline code over two lines is one line (as in CommonMark). Paragraphs, breaks and code blocks are unchanged.
24
+ def self.unwrapped(text, **)
25
+ document(text, **).tap { |doc| unwrap(doc.root) }
26
+ end
27
+
28
+ def self.unwrap(element)
29
+ element.children.each_with_index do |child, index|
30
+ if child.type == :text
31
+ previous = index.positive? ? element.children[index - 1] : nil
32
+ joined = child.value.gsub(/[ \t]*\r?\n[ \t]*/, " ")
33
+ child.value = line_break?(previous) ? joined.lstrip : joined
34
+ elsif child.type == :codespan
35
+ child.value = child.value.gsub(/\r?\n/, " ")
36
+ elsif !(child.type == :html_element && VERBATIM.include?(child.value))
37
+ unwrap(child)
38
+ end
39
+ end
40
+ end
41
+ private_class_method :unwrap
42
+
43
+ def self.line_break?(element)
44
+ element && (element.type == :br || (element.type == :html_element && element.value == "br"))
45
+ end
46
+ private_class_method :line_break?
47
+ end
48
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require_relative "resources"
6
+
7
+ module Archsight
8
+ # The OpenAPI document of the REST API: the hand-written base (`web/api/openapi/spec.yaml`: the fixed endpoints such
9
+ # as pages, assets, search and the editor, with their prose, examples and error shapes) plus what the model knows and
10
+ # nobody should type twice: one schema per kind with its annotations (type, enum, description, default), its `spec`
11
+ # relations and its relations, the list of kinds, and the instance detail schema that is one of them. A new kind, a
12
+ # changed enum or another relation shows up in the documentation without touching the YAML.
13
+ #
14
+ # Archsight::OpenAPI.document # => Hash, `.to_yaml` is what GET /api/v1/openapi.yaml serves
15
+ module OpenAPI
16
+ BASE = File.join(__dir__, "web", "api", "openapi", "spec.yaml")
17
+
18
+ # The operations that answer with the detail of one instance, whose kind the path names
19
+ DETAIL_PATHS = ["/api/v1/kinds/{kind}/instances/{name}", "/kinds/{kind}/instances/{name}.json"].freeze
20
+
21
+ # What an annotation of a type is in the JSON. The value is the one of the YAML file, so a number may also be its
22
+ # string ("100"); only a computed annotation is always a number. Everything else is a string.
23
+ TYPES = {
24
+ Integer => { "type" => %w[integer string] },
25
+ Float => { "type" => %w[number string] }
26
+ }.freeze
27
+
28
+ NAMES = { "type" => "array", "items" => { "type" => "string" } }.freeze
29
+
30
+ class << self
31
+ # @return [Hash] the complete document; built once, the model does not change while the server runs
32
+ def document
33
+ @document ||= build
34
+ end
35
+
36
+ # Forget the built document (the model was changed, in a test)
37
+ def reset!
38
+ @document = nil
39
+ end
40
+
41
+ # The name of the schema of a kind
42
+ def schema_name(kind) = "#{kind}Instance"
43
+
44
+ # The schema of one kind: the detail of an instance as GET /api/v1/kinds/{kind}/instances/{name} returns it
45
+ # @param kind [String] name of a kind
46
+ # @return [Hash]
47
+ def kind_schema(kind)
48
+ klass = Archsight::Resources[kind]
49
+ schema = {
50
+ "type" => "object",
51
+ "required" => %w[kind name metadata spec relations references],
52
+ "x-layer" => klass.layer,
53
+ "properties" => {
54
+ "kind" => { "type" => "string", "const" => kind },
55
+ "name" => { "type" => "string", "description" => "Name of the instance" },
56
+ "metadata" => { "type" => "object", "properties" => { "annotations" => annotations_schema(klass) } },
57
+ "annotation_formats" => { "type" => "object", "additionalProperties" => { "type" => "string" },
58
+ "description" => "How the UI shows each annotation that is set (markdown, link, tag_list, ...)" },
59
+ "annotation_links" => { "type" => "object", "additionalProperties" => true,
60
+ "description" => "Annotations whose value names resources, with the resources they resolve to" },
61
+ "spec" => spec_schema(klass),
62
+ "relations" => relations_schema(klass),
63
+ "references" => references_schema,
64
+ "diagram" => { "type" => %w[string null], "description" => "The `architecture/diagram` annotation as inline SVG" }
65
+ }
66
+ }
67
+ schema["description"] = klass.description.strip if klass.description
68
+ schema
69
+ end
70
+
71
+ private
72
+
73
+ def build
74
+ document = YAML.safe_load_file(BASE, permitted_classes: [], aliases: true)
75
+ kinds = Archsight::Resources.resource_classes.keys.sort
76
+ schemas = document.fetch("components").fetch("schemas")
77
+ add_schemas(schemas, kinds)
78
+ document.dig("components", "parameters", "kind")["schema"] = { "$ref" => "#/components/schemas/KindName" }
79
+ DETAIL_PATHS.each { |path| use_detail_schema(document.fetch("paths").fetch(path)) }
80
+ # the schemas share small hashes: a copy of each keeps YAML from writing anchors and aliases for them
81
+ JSON.parse(JSON.generate(document))
82
+ end
83
+
84
+ def add_schemas(schemas, kinds)
85
+ kinds.each do |kind|
86
+ name = schema_name(kind)
87
+ raise ArgumentError, "OpenAPI: the schema #{name} of #{kind} clashes with a schema of the base spec" if schemas.key?(name)
88
+
89
+ schemas[name] = kind_schema(kind)
90
+ end
91
+ schemas["KindName"] = {
92
+ "type" => "string", "enum" => kinds,
93
+ "description" => "Name of a resource kind. A kind that was renamed is still accepted under its old name."
94
+ }
95
+ schemas["InstanceDetail"] = {
96
+ "description" => "An instance with its relations and references: the schema depends on the kind",
97
+ "oneOf" => kinds.map { |kind| { "$ref" => "#/components/schemas/#{schema_name(kind)}" } },
98
+ "discriminator" => { "propertyName" => "kind", "mapping" => kinds.to_h { |kind| [kind, "#/components/schemas/#{schema_name(kind)}"] } }
99
+ }
100
+ end
101
+
102
+ def use_detail_schema(path_item)
103
+ path_item.dig("get", "responses", "200", "content", "application/json")["schema"] = { "$ref" => "#/components/schemas/InstanceDetail" }
104
+ end
105
+
106
+ # The annotations of the kind that are set; every annotation is optional. Other annotations are allowed.
107
+ def annotations_schema(klass)
108
+ fixed, patterns = klass.annotations.partition { |annotation| !annotation.pattern? }
109
+ schema = {
110
+ "type" => "object", "additionalProperties" => true,
111
+ "properties" => fixed.to_h { |annotation| [annotation.key, annotation_schema(annotation)] }
112
+ }
113
+ schema["patternProperties"] = patterns.to_h { |annotation| [pattern_of(annotation.key), annotation_schema(annotation)] } unless patterns.empty?
114
+ schema
115
+ end
116
+
117
+ def annotation_schema(annotation)
118
+ schema = (TYPES[annotation.type] || { "type" => "string" }).dup
119
+ description = annotation.description.to_s.strip
120
+ description = "#{description}\n\nRendered to HTML in the response." if annotation.format == :markdown && !description.empty?
121
+ schema["description"] = description unless description.empty?
122
+ if annotation.enum && annotation.list?
123
+ # a list is one string of comma separated values, each of them one of the enum
124
+ schema["description"] = "#{description}\n\nComma separated list of: #{annotation.enum.join(", ")}.".strip
125
+ schema["x-items-enum"] = annotation.enum
126
+ elsif annotation.enum
127
+ schema["enum"] = annotation.enum
128
+ end
129
+ schema["default"] = annotation.default unless annotation.default.nil?
130
+ schema["deprecated"] = true if annotation.deprecated
131
+ schema
132
+ end
133
+
134
+ # `link/*` -> a regular expression for the keys it matches
135
+ def pattern_of(key) = "^#{Regexp.escape(key).gsub('\*', ".+")}$"
136
+
137
+ # What the file says: spec.<verb>.<key>: [names]
138
+ def spec_schema(klass)
139
+ properties = {}
140
+ klass.declared_relations.each do |verb, key, target|
141
+ verb_schema = (properties[verb.to_s] ||= { "type" => "object", "properties" => {} })
142
+ verb_schema["properties"][key.to_s] = NAMES.merge("description" => "Names of #{target} resources")
143
+ end
144
+ { "type" => "object", "additionalProperties" => true, "properties" => properties,
145
+ "description" => "The relations as written in the resource: verb, then the kind's collection, then the names" }
146
+ end
147
+
148
+ # The outgoing relations that exist: verb, then the kind of the target, then the names
149
+ def relations_schema(klass)
150
+ properties = {}
151
+ klass.relations.each do |verb, _key, target|
152
+ verb_schema = (properties[verb.to_s] ||= { "type" => "object" })
153
+ if Archsight::Resources::DERIVED_VERBS.include?(verb)
154
+ verb_schema["additionalProperties"] = NAMES # targets of any kind, grouped by the kind they are
155
+ else
156
+ (verb_schema["properties"] ||= {})[target.to_s] = NAMES
157
+ end
158
+ end
159
+ { "type" => "object", "additionalProperties" => false, "properties" => properties,
160
+ "description" => "Outgoing relations that have targets, grouped by verb and the kind of the target" }
161
+ end
162
+
163
+ def references_schema
164
+ { "type" => "object", "additionalProperties" => { "type" => "object", "additionalProperties" => NAMES },
165
+ "description" => "Incoming relations grouped by the kind of the source and the verb" }
166
+ end
167
+ end
168
+ end
169
+ end
@@ -62,12 +62,23 @@ module Archsight
62
62
  meta.each do |key, value|
63
63
  next if value.nil?
64
64
 
65
- result["page/#{key}"] = format_value(key, value)
65
+ if key == "links" && value.is_a?(Hash)
66
+ links(value).each { |name, url| result["link/#{name}"] = url }
67
+ else
68
+ result["page/#{key}"] = format_value(key, value)
69
+ end
66
70
  end
67
71
  result["page/content"] = body
68
72
  result
69
73
  end
70
74
 
75
+ # `links: { confluence: <url> }` of the frontmatter: one link/<name> annotation per entry
76
+ def links(mapping)
77
+ mapping.each_with_object({}) do |(name, url), result|
78
+ result[name.to_s] = url.to_s unless url.nil?
79
+ end
80
+ end
81
+
71
82
  def format_value(key, value)
72
83
  case value
73
84
  when Hash then value.map { |k, v| "#{k}: #{v.to_s.gsub(/\s*\n\s*/, " ")}" }.join("\n")
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "kramdown"
4
- require "kramdown-parser-gfm"
3
+ require_relative "markdown"
5
4
 
6
5
  module Archsight
7
6
  # PageTree builds the sidebar tree, breadcrumbs and tables of contents for Page resources
@@ -63,7 +62,7 @@ module Archsight
63
62
 
64
63
  # Flat table of contents [{level:, id:, text:}] of a markdown body
65
64
  def self.toc(markdown)
66
- doc = Kramdown::Document.new(markdown.to_s, input: "GFM")
65
+ doc = Archsight::Markdown.document(markdown)
67
66
  entries = [] #: Array[Hash[String, untyped]]
68
67
  collect_toc(doc.to_toc, entries)
69
68
  entries
@@ -307,17 +307,24 @@ class Archsight::Query::Evaluator
307
307
  query_values.include?(instance_kind)
308
308
  end
309
309
 
310
+ # The name of a resource and its aliases (architecture/aliases): the name shortcut finds a resource by either
311
+ def names_of(instance)
312
+ aliases = instance.respond_to?(:annotations) ? instance.annotations&.[]("architecture/aliases") : nil
313
+ [instance.name, *aliases.to_s.split(",").map(&:strip).reject(&:empty?)].compact
314
+ end
315
+
310
316
  def evaluate_name_condition(node, instance)
311
- name = instance.name
312
- return false unless name
317
+ return false unless instance.name
313
318
 
319
+ names = names_of(instance)
314
320
  case node.operator
315
321
  when "=="
316
- name == node.value.value.to_s
322
+ names.include?(node.value.value.to_s)
317
323
  when "!="
318
- name != node.value.value.to_s
324
+ names.none?(node.value.value.to_s)
319
325
  when "=~"
320
- !!(name =~ build_regex_from_value(node.value))
326
+ regex = build_regex_from_value(node.value)
327
+ names.any? { |name| name.match?(regex) }
321
328
  else
322
329
  false
323
330
  end
@@ -328,7 +335,7 @@ class Archsight::Query::Evaluator
328
335
  return false unless name
329
336
 
330
337
  query_values = node.values.map { |v| v.value.to_s }
331
- query_values.include?(name)
338
+ names_of(instance).intersect?(query_values)
332
339
  end
333
340
 
334
341
  # Outgoing relations: what does this resource point to?
@@ -5,16 +5,21 @@ require_relative "ast"
5
5
 
6
6
  # Recursive descent parser for the architecture query language.
7
7
  class Archsight::Query::Parser
8
- def initialize(tokens)
8
+ # Characters a resource name is made of, as the lexer reads an identifier
9
+ NAME_CHAR = %r{[a-zA-Z0-9_\-/.]}
10
+
11
+ # @param source [String, nil] the query text; with it, names that contain colons (`Archsight:CLI`, `ITGS:A001`)
12
+ # are read as one name instead of a kind prefix and a condition
13
+ def initialize(tokens, source: nil)
9
14
  @tokens = tokens
15
+ @source = source
10
16
  @position = 0
11
17
  end
12
18
 
13
19
  def parse
14
20
  # Check for kind filter prefix: "Kind: expression"
15
21
  kind_filter = nil
16
- # Check it's a valid Kind (starts with capital letter)
17
- if current_token.type == :IDENTIFIER && peek_token&.type == :COLON && (current_token.value =~ /^[A-Z]/)
22
+ if kind_prefix?
18
23
  kind_filter = current_token.value
19
24
  advance # consume identifier
20
25
  advance # consume colon
@@ -256,8 +261,7 @@ class Archsight::Query::Parser
256
261
 
257
262
  # Parse inner query: optional kind filter + optional expression
258
263
  kind_filter = nil
259
- # Check it's a valid Kind (starts with capital letter)
260
- if current_token.type == :IDENTIFIER && peek_token&.type == :COLON && (current_token.value =~ /^[A-Z]/)
264
+ if kind_prefix?
261
265
  kind_filter = current_token.value
262
266
  advance # consume identifier
263
267
  advance # consume colon
@@ -395,8 +399,71 @@ class Archsight::Query::Parser
395
399
  Archsight::Query::AST::AnnotationExistsCondition.new(path)
396
400
  else
397
401
  # Bare identifier - treat as name =~ "identifier"
398
- Archsight::Query::AST::NameCondition.new("=~", Archsight::Query::AST::StringValue.new(path))
402
+ Archsight::Query::AST::NameCondition.new("=~", Archsight::Query::AST::StringValue.new(glue_name(path)))
403
+ end
404
+ end
405
+
406
+ # Names contain colons (`Archsight:CLI`, `ITGS:A001`). A bare name written without spaces around its colons is
407
+ # one word: `ApplicationService:ITGS:A001` is the kind and the name `ITGS:A001`.
408
+ def glue_name(first)
409
+ name = first
410
+ while (word = word_after_colon)
411
+ name += ":#{word}"
412
+ advance # consume the colon
413
+ advance while current_token.type != :EOF && current_token.position < @colon_end
414
+ end
415
+ name
416
+ end
417
+
418
+ # The name word right after the current colon token, when the colon touches a name character on both sides
419
+ def word_after_colon
420
+ return nil unless @source && current_token.type == :COLON
421
+
422
+ colon = current_token.position
423
+ return nil unless colon.positive? && @source[colon - 1]&.match?(NAME_CHAR)
424
+
425
+ word = @source[(colon + 1)..].to_s[/\A#{NAME_CHAR}+/]
426
+ @colon_end = colon + 1 + word.length if word
427
+ word
428
+ end
429
+
430
+ # `Kind: expression` or `Kind:`. The prefix is a kind when it starts with a capital letter and the colon is followed
431
+ # by a space or nothing; `Name:Word` without a space, where the prefix is no kind, is a name with a colon
432
+ # (`ITGS:A001`) that the bare word search handles.
433
+ def kind_prefix?
434
+ return false unless current_token.type == :IDENTIFIER && peek_token&.type == :COLON && current_token.value =~ /^[A-Z]/
435
+ return true if known_kind?(current_token.value)
436
+
437
+ !colon_glued?
438
+ end
439
+
440
+ def known_kind?(name)
441
+ defined?(Archsight::Resources) && !Archsight::Resources[name].nil?
442
+ end
443
+
444
+ # Whether the current token starts a name with colons (`ITGS:A001`) that is a bare word, not the start of a
445
+ # condition: the colons touch name characters, and no operator or `?` follows the name
446
+ def colon_glued?
447
+ return false unless @source
448
+
449
+ stop = name_run_end(peek_token.position)
450
+ return false unless stop
451
+
452
+ following = @tokens.find { |token| token.position >= stop }
453
+ !(following && %i[EQ NEQ MATCH GT LT GTE LTE IN QUESTION].include?(following.type))
454
+ end
455
+
456
+ # Where `a:b:c` ends, starting at its first colon; nil when that colon does not touch a name character on both sides
457
+ def name_run_end(colon)
458
+ stop = nil
459
+ while @source[colon] == ":" && colon.positive? && @source[colon - 1].match?(NAME_CHAR)
460
+ word = @source[(colon + 1)..].to_s[/\A#{NAME_CHAR}+/]
461
+ break unless word
462
+
463
+ stop = colon + 1 + word.length
464
+ colon = stop
399
465
  end
466
+ stop
400
467
  end
401
468
 
402
469
  def parse_quoted_annotation_path
@@ -52,7 +52,7 @@ module Archsight
52
52
  def parse(source)
53
53
  lexer = Lexer.new(source)
54
54
  tokens = lexer.tokenize
55
- parser = Parser.new(tokens)
55
+ parser = Parser.new(tokens, source: source)
56
56
  parser.parse
57
57
  rescue LexerError, ParseError, InvalidRegexError => e
58
58
  # Re-raise with source context
@@ -32,7 +32,7 @@ module Archsight
32
32
  end
33
33
  end
34
34
 
35
- # The resources a markdown text mentions with `[[...]]` links and `![[View/...]]` embeds, outside code.
35
+ # The resources a markdown text mentions with `[[...]]` links and `![[Kind/...]]` embeds, outside code.
36
36
  # @return [Array<Base>]
37
37
  def mentions(markdown, finder)
38
38
  visible = markdown.to_s.gsub(FENCE, "").gsub(INLINE_CODE, "")
@@ -111,10 +111,10 @@ module Archsight
111
111
  @links.exact_target_for(target)
112
112
  end
113
113
 
114
- # An `![[Kind/Name]]` embed: only views and analyses can be embedded
114
+ # An `![[Kind/Name]]` embed: any resource but a page
115
115
  def embed(reference)
116
116
  kind = reference.split("/", 2).first
117
- return unless Helpers::Embeds::KINDS.include?(kind)
117
+ return unless Helpers::Embeds.embeddable_kind?(kind)
118
118
 
119
119
  reference(reference)
120
120
  end