archsight 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -1
  3. data/docs/archimate.md +21 -1
  4. data/docs/configuration.md +1 -0
  5. data/docs/diagram.md +15 -3
  6. data/docs/docker.md +4 -1
  7. data/docs/icons.md +32 -0
  8. data/docs/import.md +178 -55
  9. data/docs/index.md.erb +31 -8
  10. data/docs/modeling.md +772 -24
  11. data/docs/pages.md +44 -13
  12. data/docs/search.md +21 -1
  13. data/docs/togaf.md +5 -1
  14. data/lib/archsight/analysis/result.rb +8 -2
  15. data/lib/archsight/analysis/sandbox.rb +8 -0
  16. data/lib/archsight/analysis/typst_renderer.rb +246 -0
  17. data/lib/archsight/analysis.rb +1 -0
  18. data/lib/archsight/annotations/annotation.rb +30 -3
  19. data/lib/archsight/annotations/architecture_annotations.rb +47 -0
  20. data/lib/archsight/annotations/asset_annotations.rb +34 -0
  21. data/lib/archsight/annotations/link_annotations.rb +15 -0
  22. data/lib/archsight/annotations/resource_reference.rb +12 -0
  23. data/lib/archsight/annotations/risk_annotations.rb +21 -0
  24. data/lib/archsight/cli.rb +124 -23
  25. data/lib/archsight/database.rb +40 -0
  26. data/lib/archsight/diagram/cli.rb +50 -2
  27. data/lib/archsight/diagram/formatter.rb +112 -0
  28. data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
  29. data/lib/archsight/diagram/routing/edge_router.rb +29 -4
  30. data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
  31. data/lib/archsight/diagram/style/theme.rb +7 -2
  32. data/lib/archsight/documentation.rb +3 -2
  33. data/lib/archsight/editor/page_source.rb +13 -1
  34. data/lib/archsight/export/confluence/analysis_output.rb +101 -0
  35. data/lib/archsight/export/confluence/client.rb +13 -0
  36. data/lib/archsight/export/confluence/diagram_links.rb +1 -1
  37. data/lib/archsight/export/confluence/exporter.rb +40 -13
  38. data/lib/archsight/export/confluence/labels.rb +25 -0
  39. data/lib/archsight/export/confluence/page_properties.rb +4 -6
  40. data/lib/archsight/export/confluence/storage.rb +38 -2
  41. data/lib/archsight/helpers/embeds.rb +18 -7
  42. data/lib/archsight/helpers/resource_resolver.rb +25 -0
  43. data/lib/archsight/import/executor.rb +139 -16
  44. data/lib/archsight/import/handler.rb +55 -6
  45. data/lib/archsight/import/handler_loader.rb +64 -0
  46. data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
  47. data/lib/archsight/import/handlers/command.rb +120 -0
  48. data/lib/archsight/import/handlers/github.rb +4 -1
  49. data/lib/archsight/import/handlers/gitlab.rb +4 -1
  50. data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
  51. data/lib/archsight/import/handlers/go_grapher.rb +102 -5
  52. data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
  53. data/lib/archsight/import/handlers/grapher.rb +40 -1
  54. data/lib/archsight/import/handlers/repository.rb +3 -0
  55. data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
  56. data/lib/archsight/import/registry.rb +22 -1
  57. data/lib/archsight/import.rb +64 -1
  58. data/lib/archsight/linter/structure.rb +239 -0
  59. data/lib/archsight/linter.rb +217 -6
  60. data/lib/archsight/page_loader.rb +12 -1
  61. data/lib/archsight/query/evaluator.rb +13 -6
  62. data/lib/archsight/query/parser.rb +73 -6
  63. data/lib/archsight/query.rb +1 -1
  64. data/lib/archsight/references.rb +3 -3
  65. data/lib/archsight/renderer.rb +80 -23
  66. data/lib/archsight/resources/analysis.rb +3 -2
  67. data/lib/archsight/resources/application_component.rb +83 -8
  68. data/lib/archsight/resources/application_event.rb +79 -0
  69. data/lib/archsight/resources/application_service.rb +18 -2
  70. data/lib/archsight/resources/base.rb +58 -5
  71. data/lib/archsight/resources/business_actor.rb +14 -1
  72. data/lib/archsight/resources/business_contract.rb +72 -0
  73. data/lib/archsight/resources/business_control.rb +10 -2
  74. data/lib/archsight/resources/business_event.rb +79 -0
  75. data/lib/archsight/resources/business_process.rb +8 -1
  76. data/lib/archsight/resources/business_product.rb +12 -0
  77. data/lib/archsight/resources/business_role.rb +70 -0
  78. data/lib/archsight/resources/compliance_evidence.rb +8 -2
  79. data/lib/archsight/resources/data_object.rb +6 -1
  80. data/lib/archsight/resources/implementation_deliverable.rb +63 -0
  81. data/lib/archsight/resources/implementation_event.rb +52 -0
  82. data/lib/archsight/resources/implementation_gap.rb +49 -0
  83. data/lib/archsight/resources/implementation_plateau.rb +67 -0
  84. data/lib/archsight/resources/implementation_work_package.rb +84 -0
  85. data/lib/archsight/resources/import.rb +37 -11
  86. data/lib/archsight/resources/location.rb +56 -0
  87. data/lib/archsight/resources/motivation_assessment.rb +121 -0
  88. data/lib/archsight/resources/motivation_constraint.rb +7 -1
  89. data/lib/archsight/resources/motivation_driver.rb +50 -0
  90. data/lib/archsight/resources/motivation_goal.rb +13 -1
  91. data/lib/archsight/resources/motivation_principle.rb +75 -0
  92. data/lib/archsight/resources/motivation_requirement.rb +18 -2
  93. data/lib/archsight/resources/motivation_stakeholder.rb +21 -0
  94. data/lib/archsight/resources/page.rb +9 -7
  95. data/lib/archsight/resources/strategy_capability.rb +9 -0
  96. data/lib/archsight/resources/technology_artifact.rb +11 -4
  97. data/lib/archsight/resources/technology_communication_network.rb +89 -0
  98. data/lib/archsight/resources/technology_device.rb +88 -0
  99. data/lib/archsight/resources/technology_equipment.rb +75 -0
  100. data/lib/archsight/resources/technology_event.rb +80 -0
  101. data/lib/archsight/resources/technology_facility.rb +70 -0
  102. data/lib/archsight/resources/technology_node.rb +57 -1
  103. data/lib/archsight/resources/technology_service.rb +33 -0
  104. data/lib/archsight/resources/technology_system_software.rb +28 -0
  105. data/lib/archsight/resources.rb +12 -0
  106. data/lib/archsight/version.rb +1 -1
  107. data/lib/archsight/web/api/analysis_helpers.rb +55 -0
  108. data/lib/archsight/web/api/json_helpers.rb +3 -37
  109. data/lib/archsight/web/api/openapi/spec.yaml +10 -8
  110. data/lib/archsight/web/api/page_helpers.rb +1 -8
  111. data/lib/archsight/web/api/routes.rb +7 -2
  112. data/lib/archsight/web/application.rb +38 -2
  113. data/lib/archsight/web/editor/routes.rb +2 -1
  114. data/lib/archsight/web/public/vue/ApiDocsPage-COp4onwn.js +1 -0
  115. data/lib/archsight/web/public/vue/DocPage-D52OwaKc.js +1 -0
  116. data/lib/archsight/web/public/vue/EditorPage-3aqL7yVf.js +35 -0
  117. data/lib/archsight/web/public/vue/ErrorPage--b9XeSYa.js +2 -0
  118. data/lib/archsight/web/public/vue/GraphView-qh_EtRrQ.js +1 -0
  119. data/lib/archsight/web/public/vue/HomePage-C2TNh2U-.js +2 -0
  120. data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
  121. data/lib/archsight/web/public/vue/InstanceRouter-uzb_OMG0.js +1 -0
  122. data/lib/archsight/web/public/vue/KindList-CyJuP_uq.js +1 -0
  123. data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
  124. data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
  125. data/lib/archsight/web/public/vue/PageView-FyygrS-b.js +1 -0
  126. data/lib/archsight/web/public/vue/QueryError-B4Ex_csZ.js +1 -0
  127. data/lib/archsight/web/public/vue/ResourceList-BY6Z1Kcb.js +2 -0
  128. data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
  129. data/lib/archsight/web/public/vue/{SearchResults-CyPUZZSC.css → SearchResults-BV6UHriJ.css} +1 -1
  130. data/lib/archsight/web/public/vue/SearchResults-CkFpHF8m.js +1 -0
  131. data/lib/archsight/web/public/vue/WikiPage-BoHNewCX.js +3 -0
  132. data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
  133. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BvzKZE4e.js +1 -0
  134. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-D-xs_Hxh.js +1 -0
  135. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-nbjxG01S.js +1 -0
  136. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-CW6mw2ab.js +1 -0
  137. data/lib/archsight/web/public/vue/index-BGlYCbi7.js +3 -0
  138. data/lib/archsight/web/public/vue/index-eUHmJ7K2.css +1 -0
  139. data/lib/archsight/web/public/vue/info-A6RAGUB7-C12sjlHe.js +1 -0
  140. data/lib/archsight/web/public/vue/{mermaid-BjEi5URd.js → mermaid-DjsXQ_XN.js} +35 -35
  141. data/lib/archsight/web/public/vue/packet-AYTQ26CC-wMwKwHmA.js +1 -0
  142. data/lib/archsight/web/public/vue/pie-WAS4IAKB-w1-sAU_K.js +1 -0
  143. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-DpwqIFoy.js +1 -0
  144. data/lib/archsight/web/public/vue/railroad-74A4TZTK-Ce5XC24d.js +1 -0
  145. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-BSQO2pds.js +1 -0
  146. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-Dq1c2YtD.js +1 -0
  147. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DKj6dli0.js +1 -0
  148. data/lib/archsight/web/public/vue/rolldown-runtime-hePW80VL.js +1 -0
  149. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-BmHNTWxU.js +1 -0
  150. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DsEI7sSD.js +1 -0
  151. data/lib/archsight/web/public/vue/{useGraphviz-DweKV7Kg.js → useGraphviz-4evusK9i.js} +13 -1
  152. data/lib/archsight/web/public/vue/{useHighlight-D5Gf8DLl.js → useHighlight-DPguaxfm.js} +8 -8
  153. data/lib/archsight/web/public/vue/usePanZoom-seQE1TPk.js +12 -0
  154. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-CSCW83-M.js +1 -0
  155. data/lib/archsight/web/public/vue.html +5 -5
  156. metadata +72 -38
  157. data/lib/archsight/web/public/vue/ApiDocsPage-DoOxKjG0.js +0 -1
  158. data/lib/archsight/web/public/vue/DocPage-CfsC3CeQ.js +0 -1
  159. data/lib/archsight/web/public/vue/EditorPage-CsJA0q8n.js +0 -35
  160. data/lib/archsight/web/public/vue/ErrorPage-OJpJz9df.js +0 -2
  161. data/lib/archsight/web/public/vue/GraphView-CBq6oFRV.js +0 -1
  162. data/lib/archsight/web/public/vue/HomePage-BwgMLgVC.js +0 -2
  163. data/lib/archsight/web/public/vue/InstanceRouter-DQz-SsQq.js +0 -1
  164. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
  165. data/lib/archsight/web/public/vue/KindList-4q0K9Cll.js +0 -1
  166. data/lib/archsight/web/public/vue/PageView-DzeGnvoS.js +0 -1
  167. data/lib/archsight/web/public/vue/QueryError-kg6Yf-pW.js +0 -1
  168. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
  169. data/lib/archsight/web/public/vue/ResourceList-Df_0e0qt.js +0 -2
  170. data/lib/archsight/web/public/vue/SearchResults-CKgY6_wH.js +0 -1
  171. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
  172. data/lib/archsight/web/public/vue/WikiPage-DrvCHwJ7.js +0 -13
  173. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
  174. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
  175. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
  176. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
  177. data/lib/archsight/web/public/vue/index-D0Q5GZRs.js +0 -3
  178. data/lib/archsight/web/public/vue/index-oF-iyZvk.css +0 -1
  179. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
  180. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
  181. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
  182. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
  183. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
  184. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
  185. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
  186. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
  187. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
  188. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
  189. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ class Linter
5
+ # Structural checks over the declared relations, the rules of "Choosing the Kind" and "Choosing the Kind:
6
+ # Infrastructure" in the modeling guide. All findings are warnings: they point at modeling decisions to review and
7
+ # have legitimate exceptions (internal services, capabilities that no product carries yet).
8
+ class Structure
9
+ # Network types that run over another network and need one that serves them
10
+ OVERLAY_TYPES = %w[overlay vpn].freeze
11
+ NETWORK_DEVICE_TYPES = %w[switch router].freeze
12
+
13
+ def initialize(database)
14
+ @database = database
15
+ end
16
+
17
+ # Findings in a stable order, each starting with the path reference of the resource
18
+ def warnings
19
+ [
20
+ component_in_several_services,
21
+ artifact_in_several_components,
22
+ service_with_same_named_component,
23
+ product_without_service,
24
+ service_reached_from_nothing,
25
+ capability_without_product,
26
+ generated_component_in_several_repositories,
27
+ link_not_between_two_facilities,
28
+ overlay_without_underlay,
29
+ facility_without_location,
30
+ equipment_without_facility,
31
+ device_without_facility,
32
+ device_connected_to_itself,
33
+ network_device_realizing_no_network,
34
+ device_served_apart_from_its_equipment
35
+ ].flatten.sort
36
+ end
37
+
38
+ private
39
+
40
+ def instances(kind)
41
+ @database.instances_by_kind(kind).values
42
+ end
43
+
44
+ def targets(instance, verb, key)
45
+ instance.relations(verb, key)
46
+ end
47
+
48
+ def warning(instance, message)
49
+ "#{instance.path_ref}: #{instance.klass} '#{instance.name}' #{message}"
50
+ end
51
+
52
+ def names(list)
53
+ list.map { |item| "'#{item.name}'" }.join(", ")
54
+ end
55
+
56
+ # { target => [owners that list it under verb/key] } for the instances of `kind`
57
+ def owners_by_target(kind, verb, key)
58
+ owners = Hash.new { |hash, target| hash[target] = [] }
59
+ instances(kind).each { |owner| targets(owner, verb, key).each { |target| owners[target] << owner } }
60
+ owners
61
+ end
62
+
63
+ def component_in_several_services
64
+ shared("ApplicationService", :realizedThrough, :applicationComponents, "services")
65
+ end
66
+
67
+ def artifact_in_several_components
68
+ shared("ApplicationComponent", :realizedThrough, :technologyArtifacts, "components")
69
+ end
70
+
71
+ def shared(kind, verb, key, plural)
72
+ owners_by_target(kind, verb, key).filter_map do |target, owners|
73
+ next if owners.length < 2
74
+
75
+ warning(target, "is realized by #{owners.length} #{plural}: #{names(owners.sort_by(&:name))}")
76
+ end
77
+ end
78
+
79
+ def service_with_same_named_component
80
+ instances("ApplicationService").filter_map do |service|
81
+ components = targets(service, :realizedThrough, :applicationComponents)
82
+ next unless components.length == 1 && components.first.name == service.name
83
+
84
+ warning(service, "has one component with the same name")
85
+ end
86
+ end
87
+
88
+ def product_without_service
89
+ instances("BusinessProduct").filter_map do |product|
90
+ next unless targets(product, :servedBy, :applicationServices).empty?
91
+
92
+ warning(product, "is served by no ApplicationService")
93
+ end
94
+ end
95
+
96
+ # A service is reached when a product or process is served by it, directly or through services that depend on it
97
+ def service_reached_from_nothing
98
+ reached = reached_services
99
+ instances("ApplicationService").filter_map do |service|
100
+ next if reached.include?(service)
101
+
102
+ warning(service, "is not served to any product or process")
103
+ end
104
+ end
105
+
106
+ def reached_services
107
+ reached = []
108
+ queue = (instances("BusinessProduct") + instances("BusinessProcess")).flat_map { |item| targets(item, :servedBy, :applicationServices) }
109
+ until queue.empty?
110
+ service = queue.shift
111
+ next if reached.include?(service)
112
+
113
+ reached << service
114
+ queue.concat(targets(service, :dependsOn, :applicationServices))
115
+ end
116
+ reached
117
+ end
118
+
119
+ def capability_without_product
120
+ realized = owners_by_target("BusinessProduct", :realizes, :strategyCapabilities)
121
+ instances("StrategyCapability").filter_map do |capability|
122
+ next if realized.key?(capability)
123
+
124
+ warning(capability, "is realized by no product")
125
+ end
126
+ end
127
+
128
+ # A generated component realized by several repositories is two components merged into one: the repositories
129
+ # declare the same module path. A re-import gives each its own name (GoModuleParser#owned_component_name).
130
+ def generated_component_in_several_repositories
131
+ instances("ApplicationComponent").filter_map do |component|
132
+ next unless component.annotations.key?("generated/script")
133
+
134
+ repositories = targets(component, :realizedThrough, :technologyArtifacts).select { |a| a.name.start_with?("Repo:") }
135
+ next if repositories.length < 2
136
+
137
+ warning(component, "is generated from #{repositories.length} repositories with the same module path: " \
138
+ "#{names(repositories.sort_by(&:name))}")
139
+ end
140
+ end
141
+
142
+ # ---- infrastructure
143
+
144
+ def link_not_between_two_facilities
145
+ instances("TechnologyEquipment").filter_map do |equipment|
146
+ next unless equipment.annotations["equipment/type"] == "link"
147
+
148
+ count = targets(equipment, :locatedAt, :technologyFacilities).length
149
+ next if count == 2
150
+
151
+ warning(equipment, "is a link located at #{count} #{count == 1 ? "facility" : "facilities"}, not 2")
152
+ end
153
+ end
154
+
155
+ def overlay_without_underlay
156
+ instances("TechnologyCommunicationNetwork").filter_map do |network|
157
+ type = network.annotations["network/type"]
158
+ next unless OVERLAY_TYPES.include?(type)
159
+ next unless targets(network, :servedBy, :technologyCommunicationNetworks).empty?
160
+
161
+ warning(network, "is #{type == "overlay" ? "an" : "a"} #{type} network served by no network")
162
+ end
163
+ end
164
+
165
+ # Only the outermost facility needs a location; nested halls inherit it from their campus
166
+ def facility_without_location
167
+ nested = owners_by_target("TechnologyFacility", :composedOf, :technologyFacilities)
168
+ instances("TechnologyFacility").filter_map do |facility|
169
+ next if nested.key?(facility) || targets(facility, :locatedAt, :locations).any?
170
+
171
+ warning(facility, "is located at no Location")
172
+ end
173
+ end
174
+
175
+ # TechnologyEquipment that is part of other equipment is placed through its whole, and a whole (a backbone) through its
176
+ # parts (the links)
177
+ def equipment_without_facility
178
+ parts = owners_by_target("TechnologyEquipment", :composedOf, :technologyEquipment)
179
+ instances("TechnologyEquipment").filter_map do |equipment|
180
+ next if parts.key?(equipment) || targets(equipment, :composedOf, :technologyEquipment).any?
181
+ next if targets(equipment, :locatedAt, :technologyFacilities).any?
182
+
183
+ warning(equipment, "is located at no TechnologyFacility")
184
+ end
185
+ end
186
+
187
+ # A device that is part of equipment (a rack) or of another device (a virtual chassis) is placed through its
188
+ # whole, and a chassis through its members
189
+ def device_without_facility
190
+ parts = owners_by_target("TechnologyDevice", :composedOf, :technologyDevices)
191
+ racked = owners_by_target("TechnologyEquipment", :composedOf, :technologyDevices)
192
+ instances("TechnologyDevice").filter_map do |device|
193
+ next if parts.key?(device) || racked.key?(device) || targets(device, :composedOf, :technologyDevices).any?
194
+ next if targets(device, :locatedAt, :technologyFacilities).any?
195
+
196
+ warning(device, "is located at no TechnologyFacility")
197
+ end
198
+ end
199
+
200
+ def device_connected_to_itself
201
+ instances("TechnologyDevice").filter_map do |device|
202
+ next unless targets(device, :connectedTo, :technologyDevices).include?(device)
203
+
204
+ warning(device, "is connectedTo itself")
205
+ end
206
+ end
207
+
208
+ # The members of a virtual chassis or stack realize the network through the chassis
209
+ def network_device_realizing_no_network
210
+ realized = owners_by_target("TechnologyCommunicationNetwork", :realizedBy, :technologyDevices)
211
+ members = owners_by_target("TechnologyDevice", :composedOf, :technologyDevices)
212
+ instances("TechnologyDevice").filter_map do |device|
213
+ type = device.annotations["device/type"]
214
+ next unless NETWORK_DEVICE_TYPES.include?(type)
215
+ next if realized.key?(device) || members.key?(device)
216
+
217
+ warning(device, "is a #{type} that realizes no TechnologyCommunicationNetwork")
218
+ end
219
+ end
220
+
221
+ # A device in a partition (equipment servedBy a team) serves that team; one that names another team has been
222
+ # handed over without moving it. A device that names no team is served through its equipment.
223
+ def device_served_apart_from_its_equipment
224
+ holders = owners_by_target("TechnologyEquipment", :composedOf, :technologyDevices)
225
+ instances("TechnologyDevice").filter_map do |device|
226
+ teams = targets(device, :servedBy, :businessActors)
227
+ equipment_teams = holders.fetch(device, []).flat_map { |equipment| targets(equipment, :servedBy, :businessActors) }
228
+ next if teams.empty? || equipment_teams.empty?
229
+
230
+ others = teams - equipment_teams
231
+ next if others.empty?
232
+
233
+ warning(device, "is servedBy #{names(others.sort_by(&:name))}, not by the team of its equipment " \
234
+ "(#{names(equipment_teams.uniq.sort_by(&:name))})")
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -2,18 +2,32 @@
2
2
 
3
3
  require "kramdown"
4
4
  require_relative "helpers"
5
+ require_relative "linter/structure"
5
6
 
6
7
  module Archsight
7
8
  class Linter
8
9
  # Valid @component references in View annotations
9
10
  VALID_COMPONENTS = %w[activity git jira languages owner repositories status].freeze
10
11
 
12
+ # Relations the cycle check leaves out. Relations are followed from the dependent to what it relies on, so
13
+ # the declared ones form a DAG; `dependsOn` is the exception because components may depend on each other at
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
16
+ # never enter the check.
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
23
+
11
24
  def initialize(database)
12
25
  @database = database
13
26
  @errors = []
14
27
  end
15
28
 
16
29
  def validate
30
+ validate_imports
17
31
  @database.instances.each_value do |instances_hash|
18
32
  instances_hash.each_value do |instance|
19
33
  validate_instance_annotations(instance)
@@ -22,18 +36,73 @@ module Archsight
22
36
  validate_menu(instance) if instance.klass == "PageMenu"
23
37
  end
24
38
  end
39
+ validate_relation_cycles
40
+ validate_hierarchy_cycles
41
+ validate_unique_annotations
25
42
 
26
43
  @errors
27
44
  end
28
45
 
29
- # Things that still work but should be changed: renamed kinds and relation keys that the files still use.
30
- # 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.
31
48
  def warnings
32
- @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
33
56
  end
34
57
 
35
58
  private
36
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
+
37
106
  # Pages need exactly one menu and their [[links]] must resolve
38
107
  def validate_page(page)
39
108
  menus = page.references.map { |r| r[:instance] }.select { |i| i.klass == "PageMenu" }.uniq
@@ -79,6 +148,140 @@ module Archsight
79
148
  nil
80
149
  end
81
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
+
175
+ # Declared relations must not lead back to where they started (see the "Direction of Relations" modeling guide)
176
+ def validate_relation_cycles
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)
192
+ strongly_connected_groups(graph).each do |group|
193
+ cycle = cycle_through(group.first, group, graph)
194
+ start = group.first
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(" "))
197
+ end
198
+ end
199
+
200
+ # { instance => [{ verb:, to: }] } over the declared relations whose verb the block accepts
201
+ def relation_graph
202
+ graph = {}
203
+ @database.instances.each_value do |instances_hash|
204
+ instances_hash.each_value do |instance|
205
+ graph[instance] = instance.class.declared_relations.flat_map do |verb, key, _kind|
206
+ next [] unless yield(verb)
207
+
208
+ instance.relations(verb, key).map { |target| { verb: verb, to: target } }
209
+ end
210
+ end
211
+ end
212
+ graph
213
+ end
214
+
215
+ # Groups of instances that reach each other (iterative Tarjan, the graph can be deep); a single instance counts
216
+ # only when it points at itself
217
+ def strongly_connected_groups(graph)
218
+ index = {}
219
+ lowlink = {}
220
+ on_stack = {}
221
+ stack = []
222
+ groups = []
223
+ counter = 0
224
+
225
+ graph.each_key do |root|
226
+ next if index.key?(root)
227
+
228
+ work = [[root, 0]]
229
+ until work.empty?
230
+ node, edge = work.last
231
+ if edge.zero?
232
+ index[node] = lowlink[node] = counter
233
+ counter += 1
234
+ stack << node
235
+ on_stack[node] = true
236
+ end
237
+
238
+ edges = graph[node] || []
239
+ if edge < edges.length
240
+ work.last[1] += 1
241
+ target = edges[edge][:to]
242
+ if !index.key?(target)
243
+ work << [target, 0]
244
+ elsif on_stack[target]
245
+ lowlink[node] = [lowlink[node], index[target]].min
246
+ end
247
+ else
248
+ work.pop
249
+ lowlink[work.last.first] = [lowlink[work.last.first], lowlink[node]].min unless work.empty?
250
+ next unless lowlink[node] == index[node]
251
+
252
+ group = []
253
+ loop do
254
+ member = stack.pop
255
+ on_stack[member] = false
256
+ group << member
257
+ break if member.equal?(node)
258
+ end
259
+ groups << group if group.length > 1 || edges.any? { |e| e[:to].equal?(node) }
260
+ end
261
+ end
262
+ end
263
+ groups
264
+ end
265
+
266
+ # One concrete cycle through `start` inside its group: [{ verb:, to: }, ...] ending at `start`
267
+ def cycle_through(start, group, graph)
268
+ members = group.to_h { |member| [member, true] }
269
+ queue = [[start, []]]
270
+ seen = { start => true }
271
+ until queue.empty?
272
+ node, path = queue.shift
273
+ graph[node].each do |edge|
274
+ next unless members[edge[:to]]
275
+ return path + [edge] if edge[:to].equal?(start)
276
+ next if seen[edge[:to]]
277
+
278
+ seen[edge[:to]] = true
279
+ queue << [edge[:to], path + [edge]]
280
+ end
281
+ end
282
+ []
283
+ end
284
+
82
285
  def validate_instance_annotations(instance)
83
286
  instance.annotations.each do |key, value|
84
287
  # Find matching annotation definition (handles both exact and pattern matches)
@@ -97,12 +300,20 @@ module Archsight
97
300
  next unless annotation.has_validation?
98
301
 
99
302
  # Validate value against annotation schema (type and enum constraints)
100
- annotation.validate(value).each do |error|
101
- @errors << "#{instance.path_ref}: Annotation '#{key}' #{error}"
102
- 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?
103
306
  end
104
307
  end
105
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
+
106
317
  def validate_markdown(instance, key, value)
107
318
  # Skip markdown validation for generated files
108
319
  return if instance.annotations.key?("generated/script")
@@ -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")
@@ -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