archsight 0.3.0 → 0.3.2

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 (153) hide show
  1. checksums.yaml +4 -4
  2. data/CONTRIBUTING.md +1 -1
  3. data/README.md +7 -2
  4. data/docs/computed_annotations.md +7 -5
  5. data/docs/icons.md +3 -2
  6. data/docs/index.md.erb +5 -4
  7. data/docs/licenses.md +1 -2
  8. data/docs/modeling.md +127 -6
  9. data/docs/pages.md +72 -6
  10. data/docs/search.md +26 -2
  11. data/docs/togaf.md +4 -0
  12. data/lib/archsight/annotations/annotation.rb +5 -4
  13. data/lib/archsight/annotations/computed.rb +5 -1
  14. data/lib/archsight/annotations/relation_resolver.rb +109 -83
  15. data/lib/archsight/cli.rb +9 -2
  16. data/lib/archsight/database.rb +59 -4
  17. data/lib/archsight/diagram.rb +7 -0
  18. data/lib/archsight/documentation.rb +9 -5
  19. data/lib/archsight/editor.rb +2 -2
  20. data/lib/archsight/export/confluence/exporter.rb +11 -2
  21. data/lib/archsight/export/confluence/storage.rb +99 -8
  22. data/lib/archsight/export/confluence/tables.rb +78 -0
  23. data/lib/archsight/helpers/fenced_blocks.rb +61 -0
  24. data/lib/archsight/helpers/requirements_blocks.rb +90 -0
  25. data/lib/archsight/helpers/resource_resolver.rb +1 -1
  26. data/lib/archsight/helpers/view_blocks.rb +108 -0
  27. data/lib/archsight/helpers/wiki_links.rb +50 -5
  28. data/lib/archsight/helpers.rb +3 -0
  29. data/lib/archsight/import/handlers/go_grapher.rb +4 -1
  30. data/lib/archsight/import/handlers/go_module_parser.rb +4 -1
  31. data/lib/archsight/linter.rb +31 -3
  32. data/lib/archsight/mcp/analyze_resource_tool.rb +2 -2
  33. data/lib/archsight/mcp/base.rb +38 -0
  34. data/lib/archsight/mcp/resource_doc_tool.rb +2 -2
  35. data/lib/archsight/query/ast.rb +2 -1
  36. data/lib/archsight/query/evaluator.rb +2 -2
  37. data/lib/archsight/references.rb +129 -0
  38. data/lib/archsight/requirements.rb +70 -0
  39. data/lib/archsight/resources/analysis.rb +2 -1
  40. data/lib/archsight/resources/application_component.rb +8 -3
  41. data/lib/archsight/resources/application_interface.rb +5 -3
  42. data/lib/archsight/resources/application_service.rb +8 -7
  43. data/lib/archsight/resources/base.rb +67 -17
  44. data/lib/archsight/resources/business_actor.rb +5 -3
  45. data/lib/archsight/resources/business_control.rb +80 -0
  46. data/lib/archsight/resources/business_process.rb +3 -2
  47. data/lib/archsight/resources/business_product.rb +6 -5
  48. data/lib/archsight/resources/compliance_evidence.rb +40 -3
  49. data/lib/archsight/resources/data_object.rb +3 -2
  50. data/lib/archsight/resources/import.rb +5 -2
  51. data/lib/archsight/resources/{business_constraint.rb → motivation_constraint.rb} +4 -4
  52. data/lib/archsight/resources/motivation_goal.rb +1 -1
  53. data/lib/archsight/resources/{business_requirement.rb → motivation_requirement.rb} +19 -8
  54. data/lib/archsight/resources/motivation_stakeholder.rb +2 -2
  55. data/lib/archsight/resources/page.rb +4 -2
  56. data/lib/archsight/resources/strategy_capability.rb +2 -2
  57. data/lib/archsight/resources/technology_artifact.rb +4 -3
  58. data/lib/archsight/resources/technology_node.rb +2 -2
  59. data/lib/archsight/resources/technology_service.rb +4 -0
  60. data/lib/archsight/resources/technology_system_software.rb +4 -0
  61. data/lib/archsight/resources/view.rb +2 -1
  62. data/lib/archsight/resources.rb +34 -3
  63. data/lib/archsight/template.rb +2 -2
  64. data/lib/archsight/version.rb +1 -1
  65. data/lib/archsight/view_table.rb +102 -0
  66. data/lib/archsight/web/api/docs.rb +1 -1
  67. data/lib/archsight/web/api/json_helpers.rb +21 -15
  68. data/lib/archsight/web/api/openapi/spec.yaml +135 -2
  69. data/lib/archsight/web/api/page_helpers.rb +8 -7
  70. data/lib/archsight/web/api/requirements_helpers.rb +26 -0
  71. data/lib/archsight/web/api/routes.rb +19 -0
  72. data/lib/archsight/web/application.rb +12 -2
  73. data/lib/archsight/web/public/vue/ApiDocsPage-DZ0fa5-h.css +1 -0
  74. data/lib/archsight/web/public/vue/ApiDocsPage-DoOxKjG0.js +1 -0
  75. data/lib/archsight/web/public/vue/{DocPage-uaT8CdFm.js → DocPage-CfsC3CeQ.js} +1 -1
  76. data/lib/archsight/web/public/vue/EditorPage-CbG4mc9T.css +1 -0
  77. data/lib/archsight/web/public/vue/EditorPage-CsJA0q8n.js +35 -0
  78. data/lib/archsight/web/public/vue/ErrorPage-Ck9izEUS.css +1 -0
  79. data/lib/archsight/web/public/vue/ErrorPage-OJpJz9df.js +2 -0
  80. data/lib/archsight/web/public/vue/GraphView-BvWAbUAl.css +1 -0
  81. data/lib/archsight/web/public/vue/GraphView-CBq6oFRV.js +1 -0
  82. data/lib/archsight/web/public/vue/HomePage-BwgMLgVC.js +2 -0
  83. data/lib/archsight/web/public/vue/InstanceRouter-DQz-SsQq.js +1 -0
  84. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +1 -0
  85. data/lib/archsight/web/public/vue/KindList-4q0K9Cll.js +1 -0
  86. data/lib/archsight/web/public/vue/PageView-DzeGnvoS.js +1 -0
  87. data/lib/archsight/web/public/vue/QueryError-3EqjpbyV.css +1 -0
  88. data/lib/archsight/web/public/vue/QueryError-kg6Yf-pW.js +1 -0
  89. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +1 -0
  90. data/lib/archsight/web/public/vue/ResourceList-Df_0e0qt.js +2 -0
  91. data/lib/archsight/web/public/vue/SearchResults-CKgY6_wH.js +1 -0
  92. data/lib/archsight/web/public/vue/SearchResults-CyPUZZSC.css +1 -0
  93. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +1 -0
  94. data/lib/archsight/web/public/vue/WikiPage-DrvCHwJ7.js +13 -0
  95. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +1 -0
  96. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +1 -0
  97. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +1 -0
  98. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +1 -0
  99. data/lib/archsight/web/public/vue/index-D0Q5GZRs.js +3 -0
  100. data/lib/archsight/web/public/vue/index-oF-iyZvk.css +1 -0
  101. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +1 -0
  102. data/lib/archsight/web/public/vue/mermaid-BjEi5URd.js +3334 -0
  103. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +1 -0
  104. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +1 -0
  105. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +1 -0
  106. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +1 -0
  107. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +1 -0
  108. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +1 -0
  109. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +1 -0
  110. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +1 -0
  111. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +1 -0
  112. data/lib/archsight/web/public/vue/{useGraphviz-C71SdG-N.js → useGraphviz-DweKV7Kg.js} +1 -1
  113. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +1 -0
  114. data/lib/archsight/web/public/vue.html +3 -3
  115. data/lib/archsight.rb +2 -0
  116. metadata +53 -42
  117. data/lib/archsight/web/public/vue/ApiDocsPage-C0y953v0.css +0 -1
  118. data/lib/archsight/web/public/vue/ApiDocsPage-C_4tAWis.js +0 -1
  119. data/lib/archsight/web/public/vue/EditorPage-C557BJC-.js +0 -35
  120. data/lib/archsight/web/public/vue/EditorPage-df5N-p21.css +0 -1
  121. data/lib/archsight/web/public/vue/ErrorPage-Vdebmife.js +0 -2
  122. data/lib/archsight/web/public/vue/ErrorPage-uMDnfY5_.css +0 -1
  123. data/lib/archsight/web/public/vue/GraphView-BduUql2N.js +0 -1
  124. data/lib/archsight/web/public/vue/GraphView-Cj2V2stN.css +0 -1
  125. data/lib/archsight/web/public/vue/HomePage-BHUTg8Ap.js +0 -2
  126. data/lib/archsight/web/public/vue/InstanceRouter-1lSigA58.js +0 -1
  127. data/lib/archsight/web/public/vue/InstanceRouter-Di7f3Rya.css +0 -1
  128. data/lib/archsight/web/public/vue/KindList-BDZs0j6d.js +0 -1
  129. data/lib/archsight/web/public/vue/PageView-BYzJDwof.js +0 -1
  130. data/lib/archsight/web/public/vue/ResourceList-CnbhU9wm.js +0 -1
  131. data/lib/archsight/web/public/vue/ResourceList-xyBwu7fh.css +0 -1
  132. data/lib/archsight/web/public/vue/SearchResults-CNhf6VOx.js +0 -1
  133. data/lib/archsight/web/public/vue/SearchResults-DOHzqAy3.css +0 -1
  134. data/lib/archsight/web/public/vue/WikiPage-DbmWkM7W.js +0 -13
  135. data/lib/archsight/web/public/vue/WikiPage-GV67QmNJ.css +0 -1
  136. data/lib/archsight/web/public/vue/architecture-TIHT7OUA-Bf-MWFmn.js +0 -1
  137. data/lib/archsight/web/public/vue/cynefin-VYW2F7L2-CkKt8qqs.js +0 -1
  138. data/lib/archsight/web/public/vue/eventmodeling-45OFAUF4-NwSTYPHj.js +0 -1
  139. data/lib/archsight/web/public/vue/gitGraph-TEB2WS4Q-DLRegrRG.js +0 -1
  140. data/lib/archsight/web/public/vue/index-CyVWObLU.js +0 -3
  141. data/lib/archsight/web/public/vue/index-DtKeHT3S.css +0 -1
  142. data/lib/archsight/web/public/vue/info-DKCQHKI2-EV39NzoN.js +0 -1
  143. data/lib/archsight/web/public/vue/mermaid-BMkGfnhm.js +0 -3279
  144. data/lib/archsight/web/public/vue/packet-7NZHBO7P-USljV3MZ.js +0 -1
  145. data/lib/archsight/web/public/vue/pie-RZYD4A2V-DChciBW2.js +0 -1
  146. data/lib/archsight/web/public/vue/radar-I7S5WNFK-CgGJdW1t.js +0 -1
  147. data/lib/archsight/web/public/vue/railroad-3IZDKUUU-Dy43vF5c.js +0 -1
  148. data/lib/archsight/web/public/vue/railroad-abnf-AHOZXSZD-BhsTH-sE.js +0 -1
  149. data/lib/archsight/web/public/vue/railroad-ebnf-EBAXGLYW-BBTd_u0u.js +0 -1
  150. data/lib/archsight/web/public/vue/railroad-peg-LSFZ7HO6-BiNTEzf0.js +0 -1
  151. data/lib/archsight/web/public/vue/treeView-QDETBFTQ-BfnCcf-q.js +0 -1
  152. data/lib/archsight/web/public/vue/treemap-6X3UGDF4-BsAORhvk.js +0 -1
  153. data/lib/archsight/web/public/vue/wardley-OPB4EBWU-DQvnEhRj.js +0 -1
@@ -133,9 +133,12 @@ module Archsight::Import::Handlers::GoModuleParser
133
133
  # Convert a Go module path to an ApplicationComponent name.
134
134
  # Strips the SCM host segment and joins remaining path segments with ":".
135
135
  # "github.com/example-org/billing-service/pkg" → "example-org:billing-service:pkg"
136
+ # A module path without a host ("module foo") has nothing left after stripping
137
+ # it and gets no component, rather than one with an empty name.
138
+ # @return [String, nil]
136
139
  def component_name(mod_name)
137
140
  parts = mod_name.split("/")
138
141
  parts.shift
139
- parts.join(":")
142
+ parts.join(":") unless parts.empty?
140
143
  end
141
144
  end
@@ -26,6 +26,12 @@ module Archsight
26
26
  @errors
27
27
  end
28
28
 
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.
31
+ def warnings
32
+ @database.respond_to?(:deprecations) ? @database.deprecations.map(&:to_s) : []
33
+ end
34
+
29
35
  private
30
36
 
31
37
  # Pages need exactly one menu and their [[links]] must resolve
@@ -107,6 +113,8 @@ module Archsight
107
113
  @errors << "#{instance.path_ref}: Markdown syntax error in annotation '#{key}': #{e.message}"
108
114
  end
109
115
  validate_diagram_blocks(instance, key, value)
116
+ validate_view_blocks(instance, key, value)
117
+ validate_requirements_blocks(instance, key, value)
110
118
  validate_asset_images(instance, key, value)
111
119
  validate_embeds(instance, key, value)
112
120
  end
@@ -161,6 +169,25 @@ module Archsight
161
169
  end
162
170
  end
163
171
 
172
+ # Every ```view block must be a valid View, or the page shows an error box instead of the view
173
+ def validate_view_blocks(instance, key, value)
174
+ Helpers::ViewBlocks.sources(value).each_with_index do |source, index|
175
+ spec = Helpers::ViewBlocks.parse(source)
176
+ check_view_components(instance, spec[:fields])
177
+ rescue Helpers::ViewBlocks::Error => e
178
+ @errors << "#{instance.path_ref}: View error in annotation '#{key}' (view block #{index + 1}): #{e.message}"
179
+ end
180
+ end
181
+
182
+ # Every ```requirements block must be a valid filter, or the page shows an error box instead of the table
183
+ def validate_requirements_blocks(instance, key, value)
184
+ Helpers::RequirementsBlocks.sources(value).each_with_index do |source, index|
185
+ Helpers::RequirementsBlocks.parse(source)
186
+ rescue Helpers::RequirementsBlocks::Error => e
187
+ @errors << "#{instance.path_ref}: Requirements error in annotation '#{key}' (requirements block #{index + 1}): #{e.message}"
188
+ end
189
+ end
190
+
164
191
  # Renders with the same resolver the web UI uses, so a `resource` reference that would show as a broken link there is reported here
165
192
  def render_diagram_source(instance, where, source)
166
193
  unresolved = []
@@ -173,10 +200,11 @@ module Archsight
173
200
  end
174
201
 
175
202
  def validate_view_fields(instance)
176
- fields = instance.annotations["view/fields"]
177
- return unless fields
203
+ check_view_components(instance, instance.annotations["view/fields"].to_s.split(",").map(&:strip))
204
+ end
178
205
 
179
- fields.split(",").map(&:strip).each do |field|
206
+ def check_view_components(instance, fields)
207
+ fields.each do |field|
180
208
  next unless field.start_with?("@")
181
209
 
182
210
  component_name = field[1..]
@@ -24,13 +24,13 @@ class Archsight::MCP::AnalyzeResourceTool < FastMcp::Tool
24
24
  Example: kind="ApplicationInterface", name="Kubernetes:RestAPI", impact=true
25
25
 
26
26
  RESOURCE KINDS: TechnologyArtifact, ApplicationComponent, ApplicationInterface,
27
- ApplicationService, BusinessRequirement, ComplianceEvidence, and more.
27
+ ApplicationService, MotivationRequirement, ComplianceEvidence, and more.
28
28
  DESC
29
29
  arguments do
30
30
  required(:kind).filled(:string).description(
31
31
  "Resource type to analyze. Common kinds: TechnologyArtifact (repos, code), " \
32
32
  "ApplicationComponent (services), ApplicationInterface (APIs), " \
33
- "BusinessRequirement (compliance controls), ComplianceEvidence (compliance proof)"
33
+ "MotivationRequirement (compliance and legal requirements), ComplianceEvidence (compliance proof)"
34
34
  )
35
35
  required(:name).filled(:string).description(
36
36
  "Exact name of the resource (case-sensitive). Use QueryTool first if unsure of exact name."
@@ -38,6 +38,20 @@ module Archsight::MCP
38
38
  result
39
39
  end
40
40
 
41
+ # The summary attributes of a resource's kind (annotations marked `summary: true`) that have a value,
42
+ # as [{key:, title:, value:, format:, type:}]. Computed annotations are included; they are precomputed
43
+ # at load and absent when empty. Lists (filter: :list) come back as arrays, Integer/Float typed values
44
+ # as numbers, `type` is the short type name ("Integer", "Time", "Person", ...) or nil.
45
+ def highlights(resource)
46
+ resource.class.summary_annotations.filter_map do |annotation|
47
+ value = highlight_value(annotation, resource)
48
+ next if value.nil? || (value.respond_to?(:empty?) && value.empty?)
49
+
50
+ { key: annotation.key, title: annotation.title, value: value, format: annotation.format,
51
+ type: short_type_name(annotation.type) }
52
+ end
53
+ end
54
+
41
55
  def extract_description(resource)
42
56
  description = resource.annotations["architecture/description"]
43
57
  return "No description" if description.nil?
@@ -45,6 +59,30 @@ module Archsight::MCP
45
59
  description.split("\n").first
46
60
  end
47
61
 
62
+ def short_type_name(type)
63
+ type&.name.to_s.split("::").last
64
+ end
65
+
66
+ # The hit's kind name ("TechnologyArtifact") and the number of hits per kind
67
+ def kind_of(resource)
68
+ resource.class.to_s.split("::").last
69
+ end
70
+
71
+ def count_by_kind(results)
72
+ results.group_by { |r| kind_of(r) }.transform_values(&:length)
73
+ end
74
+
75
+ def highlight_value(annotation, resource)
76
+ value = annotation.value_for(resource)
77
+ return value unless value.is_a?(String)
78
+
79
+ case annotation.type&.name
80
+ when "Integer" then value.match?(/\A-?\d+\z/) ? value.to_i : value
81
+ when "Float" then value.match?(/\A-?\d+(\.\d+)?\z/) ? value.to_f : value
82
+ else value
83
+ end
84
+ end
85
+
48
86
  def extract_relations(instance)
49
87
  relations = {}
50
88
 
@@ -23,7 +23,7 @@ class Archsight::MCP::ResourceDocTool < FastMcp::Tool
23
23
  • ApplicationComponent - Services and application building blocks
24
24
  • ApplicationInterface - APIs and interfaces exposed by components
25
25
  • ApplicationService - Business services provided by applications
26
- • BusinessRequirement - Compliance controls and business requirements
26
+ • MotivationRequirement - Statements of need, such as compliance and legal requirements
27
27
  • ComplianceEvidence - Evidence linking artifacts to compliance requirements
28
28
  DESC
29
29
  arguments do
@@ -61,7 +61,7 @@ class Archsight::MCP::ResourceDocTool < FastMcp::Tool
61
61
  kind: kind_symbol.to_s,
62
62
  description: klass.description || "No description available",
63
63
  annotation_count: klass.annotations.count,
64
- relation_count: klass.relations.count
64
+ relation_count: klass.declared_relations.count
65
65
  }
66
66
  end
67
67
 
@@ -218,7 +218,8 @@ module Archsight::Query::AST
218
218
  attr_reader :kind_name
219
219
 
220
220
  def initialize(kind_name)
221
- @kind_name = kind_name
221
+ # a renamed kind keeps matching under its old name
222
+ @kind_name = Archsight::Resources.canonical(kind_name)
222
223
  end
223
224
  end
224
225
 
@@ -292,7 +292,7 @@ class Archsight::Query::Evaluator
292
292
 
293
293
  case node.operator
294
294
  when "=="
295
- instance_kind == node.value.value.to_s
295
+ instance_kind == Archsight::Resources.canonical(node.value.value)
296
296
  when "=~"
297
297
  regex = build_regex_from_value(node.value)
298
298
  !!(instance_kind =~ regex)
@@ -303,7 +303,7 @@ class Archsight::Query::Evaluator
303
303
 
304
304
  def evaluate_kind_in_condition(node, instance)
305
305
  instance_kind = instance.class.to_s.split("::").last
306
- query_values = node.values.map { |v| v.value.to_s }
306
+ query_values = node.values.map { |v| Archsight::Resources.canonical(v.value) }
307
307
  query_values.include?(instance_kind)
308
308
  end
309
309
 
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ # References derives relations from what the text and the diagrams of resources say, so that nobody has to repeat in
5
+ # YAML what is already written:
6
+ #
7
+ # - `mentions`: `[[Target]]`, `[[Kind/Name]]` and `![[View/Name]]` in a markdown text (a page's content, the
8
+ # description of any resource, any annotation a kind defines as markdown)
9
+ # - `depicts`: the `resource "..."` of the nodes of a diagram: an ```asd block in such a text, an `.asd` file embedded
10
+ # with `![](file.asd)`, and the `architecture/diagram` annotation of any resource
11
+ #
12
+ # The relations are added to the resources like written ones (see Base#add_derived_relation), after the written ones
13
+ # are resolved, and are rebuilt on every load. Only what names a resource exactly counts: a target that is missing,
14
+ # ambiguous or only matches part of a name is skipped (the linter reports the broken ones), and so are links in code.
15
+ # Views and requirements blocks select resources with a query when they run, so they name nothing.
16
+ module References
17
+ # A fenced code block, and an inline code span: links in them are shown as written
18
+ FENCE = /^[ \t]*(?:```|~~~).*?^[ \t]*(?:```|~~~)[ \t]*$/m
19
+ INLINE_CODE = /`[^`\n]*`/
20
+
21
+ # Annotations that are markdown besides those a kind defines with `format: :markdown`
22
+ MARKDOWN_KEYS = %w[page/content architecture/description].freeze
23
+
24
+ module_function
25
+
26
+ # Adds the derived relations to every resource of the database.
27
+ def derive!(database)
28
+ finder = Finder.new(database)
29
+ database.instances.values.flat_map(&:values).each do |inst|
30
+ text_sources(inst).each { |markdown| mentions(markdown, finder).each { |target| link(inst, :mentions, target) } }
31
+ diagram_sources(inst, database).each { |source| depictions(source, finder).each { |target| link(inst, :depicts, target) } }
32
+ end
33
+ end
34
+
35
+ # The resources a markdown text mentions with `[[...]]` links and `![[View/...]]` embeds, outside code.
36
+ # @return [Array<Base>]
37
+ def mentions(markdown, finder)
38
+ visible = markdown.to_s.gsub(FENCE, "").gsub(INLINE_CODE, "")
39
+ embeds = visible.scan(Helpers::Embeds::PATTERN).flatten.map(&:strip).filter_map { |reference| finder.embed(reference) }
40
+ targets = visible.gsub(Helpers::Embeds::PATTERN, "").scan(Helpers::WikiLinks::PATTERN).map { |match| match.first.strip }
41
+ (embeds + targets.filter_map { |target| finder.link(target) }).uniq
42
+ end
43
+
44
+ # The resources the nodes of a diagram source name with `resource "..."`; a source that is not a valid diagram
45
+ # names none.
46
+ # @return [Array<Base>]
47
+ def depictions(source, finder)
48
+ Diagram.resource_references(source).filter_map { |reference| finder.reference(reference) }
49
+ rescue Diagram::Error
50
+ []
51
+ end
52
+
53
+ # The markdown texts of a resource: a page's content, the description, and every annotation its kind defines as
54
+ # markdown.
55
+ def text_sources(inst)
56
+ inst.annotations.filter_map do |key, value|
57
+ value if value.is_a?(String) && value.include?("[[") && markdown_annotation?(inst, key)
58
+ end
59
+ end
60
+
61
+ # The diagram sources of a resource: ```asd blocks and embedded `.asd` files of its markdown texts, and its
62
+ # annotations that are diagrams (`architecture/diagram`).
63
+ def diagram_sources(inst, database)
64
+ sources = [] #: Array[String]
65
+ inst.annotations.each do |key, value|
66
+ next unless value.is_a?(String)
67
+
68
+ if inst.class.annotation_format(key) == :asd
69
+ sources << value
70
+ elsif markdown_annotation?(inst, key)
71
+ sources.concat(Helpers::DiagramBlocks.sources(value))
72
+ sources.concat(embedded_diagrams(inst, value, database)) if value.include?(".asd")
73
+ end
74
+ end
75
+ sources
76
+ end
77
+
78
+ def markdown_annotation?(inst, key)
79
+ MARKDOWN_KEYS.include?(key) || inst.class.annotation_format(key) == :markdown
80
+ end
81
+
82
+ # The sources of the `.asd` files a markdown text embeds, found next to the file the resource comes from
83
+ def embedded_diagrams(inst, markdown, database)
84
+ resources_dir = database.path.to_s
85
+ base_dir = Assets.base_dir_for(inst, resources_dir: resources_dir)
86
+ return [] unless base_dir
87
+
88
+ Helpers::AssetImages.asd_files(markdown, base_dir: base_dir, resources_dir: resources_dir).filter_map do |_path, file|
89
+ File.read(file) if File.size(file) <= Helpers::AssetImages::MAX_ASD
90
+ end
91
+ rescue SystemCallError
92
+ []
93
+ end
94
+
95
+ def link(inst, verb, target)
96
+ return if target.equal?(inst)
97
+
98
+ inst.add_derived_relation(verb, target)
99
+ end
100
+
101
+ # Finds the resource a reference in a text names, exactly (see WikiLinks#exact_target_for)
102
+ class Finder
103
+ def initialize(database)
104
+ @database = database
105
+ @resolver = Helpers::ResourceResolver.new(database)
106
+ @links = Helpers::WikiLinks.new(database, resolver: @resolver)
107
+ end
108
+
109
+ # A `[[...]]` target: a page by name or title, `Kind/Name`, or a resource name
110
+ def link(target)
111
+ @links.exact_target_for(target)
112
+ end
113
+
114
+ # An `![[Kind/Name]]` embed: only views and analyses can be embedded
115
+ def embed(reference)
116
+ kind = reference.split("/", 2).first
117
+ return unless Helpers::Embeds::KINDS.include?(kind)
118
+
119
+ reference(reference)
120
+ end
121
+
122
+ # A `resource "..."` of a diagram or an embed: `Name` across all kinds, or `Kind/Name`
123
+ def reference(reference)
124
+ found = @resolver.find(reference)
125
+ found.is_a?(Array) ? @database.instances_by_kind(found.first)[found.last] : nil
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ # The requirements of a selection of resources, as the "Requirements" table of an instance page
5
+ # shows them, merged over all resources of the selection: one entry per MotivationRequirement, with the status the
6
+ # resources give it (`realizes` = implemented, `partiallyRealizes` = partial, `plans` = planned).
7
+ #
8
+ # Requirements.collect(db, of: 'ApplicationService: name =~ "Backup"', priority: ["must"])
9
+ # # => [{ name: "Requirement:X", status: "partial", priority: "must", story: "...",
10
+ # # by: [{ kind: "ApplicationService", name: "Backup", status: "partial" }] }]
11
+ module Requirements
12
+ # Relation verb -> status, best first
13
+ STATUSES = { "realizes" => "implemented", "partiallyRealizes" => "partial", "plans" => "planned" }.freeze
14
+ PRIORITIES = %w[must should may].freeze
15
+ RELATION = :motivationRequirements
16
+
17
+ module_function
18
+
19
+ # @param of [String] query selecting the resources whose requirements are listed
20
+ # @param priority [Array<String>] only these priorities (empty: all)
21
+ # @param status [Array<String>] only requirements with one of these statuses (empty: all)
22
+ # @return [Array<Hash>] sorted by priority (must, should, may, none), then name
23
+ # @raise [Archsight::Query::QueryError] if `of` is not a valid query
24
+ def collect(database, of:, priority: [], status: [])
25
+ found = {}
26
+ Archsight::Query.parse(of).filter(database).each do |resource|
27
+ STATUSES.each do |verb, resource_status|
28
+ resource.relations(verb, RELATION).each do |requirement|
29
+ entry = (found[requirement.name] ||= { requirement: requirement, by: [] })
30
+ entry[:by] << { kind: resource.class.name.split("::").last, name: resource.name, status: resource_status }
31
+ end
32
+ end
33
+ end
34
+
35
+ entries = found.values.map { |entry| entry(entry[:requirement], entry[:by]) }
36
+ entries = entries.select { |e| priority.include?(e[:priority]) } unless priority.empty?
37
+ entries = entries.select { |e| status.include?(e[:status]) } unless status.empty?
38
+ entries.sort_by { |e| [PRIORITIES.index(e[:priority]) || PRIORITIES.size, e[:name]] }
39
+ end
40
+
41
+ # The requirements of a ```requirements block as a plain table (see ViewTable), for places that cannot run the frontend.
42
+ # "Realized by" is only there when more than one resource contributes.
43
+ # @param spec [Hash] the parsed block: `{ title:, of:, priority:, status: }` (Helpers::RequirementsBlocks.parse)
44
+ # @raise [Archsight::Query::QueryError]
45
+ def table(database, spec)
46
+ entries = collect(database, of: spec[:of], priority: spec[:priority], status: spec[:status])
47
+ with_by = entries.flat_map { |e| e[:by].map { |b| [b[:kind], b[:name]] } }.uniq.length > 1
48
+ cell = ViewTable::Cell
49
+ rows = entries.first(ViewTable::LIMIT).map do |e|
50
+ row = [cell.new(text: e[:status], as: :status), cell.new(text: e[:name]), cell.new(text: e[:priority].to_s, as: :priority),
51
+ cell.new(text: e[:story].to_s, as: :markdown)]
52
+ row << cell.new(text: e[:by].map { |b| b[:name] }.join("\n")) if with_by
53
+ row
54
+ end
55
+ ViewTable::Table.new(title: spec[:title].to_s.empty? ? "Requirements" : spec[:title],
56
+ columns: ["Status", "Name", "Priority", "Story", ("Realized by" if with_by)].compact, rows: rows, total: entries.length)
57
+ end
58
+
59
+ def entry(requirement, by)
60
+ by = by.sort_by { |b| [STATUSES.values.index(b[:status]), b[:kind], b[:name]] }
61
+ {
62
+ name: requirement.name,
63
+ status: by.first[:status],
64
+ priority: requirement.annotations["requirement/priority"],
65
+ story: requirement.annotations["requirement/story"],
66
+ by: by
67
+ }
68
+ end
69
+ end
70
+ end
@@ -45,7 +45,8 @@ class Archsight::Resources::Analysis < Archsight::Resources::Base
45
45
  annotation "analysis/handler",
46
46
  description: "Script handler type (only 'ruby' currently supported)",
47
47
  title: "Handler",
48
- enum: %w[ruby]
48
+ enum: %w[ruby],
49
+ summary: true
49
50
 
50
51
  # Script content
51
52
  annotation "analysis/script",
@@ -141,13 +141,15 @@ class Archsight::Resources::ApplicationComponent < Archsight::Resources::Base
141
141
  annotation "deployment/cluster",
142
142
  description: "Target cluster name",
143
143
  title: "Cluster",
144
- filter: :word
144
+ filter: :word,
145
+ summary: true
145
146
 
146
147
  # Computed Annotations
147
148
  computed_annotation "repository/artifacts/total",
148
149
  title: "Total Git Repositories",
149
150
  description: "Number of related git repositories",
150
- type: Integer do
151
+ type: Integer,
152
+ summary: true do
151
153
  count(outgoing_transitive('TechnologyArtifact: artifact/type == "repo"'))
152
154
  end
153
155
 
@@ -190,7 +192,7 @@ class Archsight::Resources::ApplicationComponent < Archsight::Resources::Base
190
192
 
191
193
  computed_annotation "scc/language",
192
194
  title: "Primary Language",
193
- list: true,
195
+ summary: true,
194
196
  description: "Programming language with most lines of code across related artifacts",
195
197
  filter: :word,
196
198
  sidebar: true do
@@ -344,4 +346,7 @@ class Archsight::Resources::ApplicationComponent < Archsight::Resources::Base
344
346
  relation :exposes, :applicationInterfaces, :ApplicationInterface
345
347
  relation :dependsOn, :applicationInterfaces, :ApplicationInterface
346
348
  relation :dependsOn, :applicationComponents, :ApplicationComponent
349
+ relation :realizes, :motivationRequirements, :MotivationRequirement
350
+ relation :plans, :motivationRequirements, :MotivationRequirement
351
+ relation :evidencedBy, :complianceEvidences, :ComplianceEvidence
347
352
  end
@@ -34,17 +34,19 @@ class Archsight::Resources::ApplicationInterface < Archsight::Resources::Base
34
34
  annotation "api/responsiveness",
35
35
  description: "API 99th percentile responsiveness target",
36
36
  title: "API Responsiveness (99p)",
37
- enum: %w[10ms 100ms 1000ms 2s 5s 10s unresponsive]
37
+ enum: %w[10ms 100ms 1000ms 2s 5s 10s unresponsive],
38
+ summary: true
38
39
  annotation "api/authenticationMethod",
39
40
  description: "API authentication method",
40
41
  title: "API Authentication Method",
41
- enum: ["none", "hard coded", "token", "oidc"]
42
+ enum: ["none", "hard coded", "token", "oidc"],
43
+ summary: true
42
44
  annotation "api/authorization",
43
45
  description: "API authorization mechanism",
44
46
  title: "API Authorization",
45
47
  enum: ["none", "hard coded", "pbac", "abac", "rbac"]
46
48
 
47
49
  relation :servedBy, :technologyComponents, :TechnologyInterface
48
- relation :realizes, :businessConstraints, :BusinessConstraint
50
+ relation :realizes, :motivationConstraints, :MotivationConstraint
49
51
  relation :serves, :dataObjects, :DataObject
50
52
  end
@@ -34,13 +34,14 @@ class Archsight::Resources::ApplicationService < Archsight::Resources::Base
34
34
  description: "Service plane classification (control manages resources, data handles traffic)",
35
35
  title: "Service Plane",
36
36
  enum: %w[control data],
37
- list: true
37
+ summary: true
38
38
 
39
39
  # Computed Annotations
40
40
  computed_annotation "repository/artifacts/total",
41
41
  title: "Total Git Repositories",
42
42
  description: "Number of related git repositories",
43
- type: Integer do
43
+ type: Integer,
44
+ summary: true do
44
45
  count(outgoing_transitive('TechnologyArtifact: artifact/type == "repo"'))
45
46
  end
46
47
 
@@ -83,7 +84,7 @@ class Archsight::Resources::ApplicationService < Archsight::Resources::Base
83
84
 
84
85
  computed_annotation "scc/languages",
85
86
  title: "Primary Languages",
86
- list: true,
87
+ summary: true,
87
88
  description: "Top 4 programming languages by lines of code across related artifacts",
88
89
  filter: :list,
89
90
  sidebar: true do
@@ -213,10 +214,10 @@ class Archsight::Resources::ApplicationService < Archsight::Resources::Base
213
214
  relation :realizedThrough, :applicationComponents, :ApplicationComponent
214
215
  relation :servedBy, :businessActors, :BusinessActor
215
216
  relation :servedBy, :technologyServices, :TechnologyService
216
- relation :realizes, :businessConstraints, :BusinessConstraint
217
- relation :realizes, :businessRequirements, :BusinessRequirement
218
- relation :partiallyRealizes, :businessRequirements, :BusinessRequirement
219
- relation :plans, :businessRequirements, :BusinessRequirement
217
+ relation :realizes, :motivationConstraints, :MotivationConstraint
218
+ relation :realizes, :motivationRequirements, :MotivationRequirement
219
+ relation :partiallyRealizes, :motivationRequirements, :MotivationRequirement
220
+ relation :plans, :motivationRequirements, :MotivationRequirement
220
221
  relation :realizes, :dataObjects, :DataObject
221
222
  relation :evidencedBy, :complianceEvidences, :ComplianceEvidence
222
223
  end
@@ -15,21 +15,46 @@ module Archsight
15
15
  end
16
16
 
17
17
  def self.relation(verb, kind, klass_name)
18
- @relations ||= [] #: Array[[Symbol, Symbol, String]]
19
- @relations << [verb, kind, klass_name]
18
+ @declared_relations ||= [] #: Array[[Symbol, Symbol, String]]
19
+ @declared_relations << [verb, kind, klass_name]
20
+ @relations = nil
20
21
  end
21
22
 
23
+ # The relations a resource of this kind may be written with in a file (`spec`)
24
+ def self.declared_relations
25
+ @declared_relations || []
26
+ end
27
+
28
+ # Every relation a resource of this kind can have: the declared ones and the derived ones (`mentions`,
29
+ # `depicts`, see Archsight::References). Everything that follows relations (queries, graphs, impact analysis)
30
+ # uses this; what lets a user write or choose relations uses declared_relations.
22
31
  def self.relations
23
- @relations || []
32
+ @relations ||= (declared_relations + Archsight::Resources::DERIVED_RELATIONS).freeze
24
33
  end
25
34
 
35
+ # A kind may mark at most this many annotations as summary (see Base.annotation)
36
+ MAX_SUMMARY_ANNOTATIONS = 3
37
+
26
38
  # Define an annotation using the Annotation class
39
+ # `summary: true` marks it as a summary attribute: it is returned with every search hit of this kind.
27
40
  def self.annotation(key, description: nil, filter: nil, title: nil, format: nil, enum: nil, sidebar: true,
28
- type: nil, list: false, editor: true, validator: nil)
29
- @annotations ||= [] #: Array[Archsight::Annotations::Annotation]
41
+ type: nil, summary: false, editor: true, validator: nil)
30
42
  options = { description: description, filter: filter, title: title, format: format, enum: enum,
31
- sidebar: sidebar, type: type, list: list, editor: editor, validator: validator }
32
- @annotations << Archsight::Annotations::Annotation.new(key, options)
43
+ sidebar: sidebar, type: type, summary: summary, editor: editor, validator: validator }
44
+ register_annotation(Archsight::Annotations::Annotation.new(key, options))
45
+ end
46
+
47
+ # Append an annotation definition, enforcing the limit of summary annotations per kind.
48
+ # Annotations of included modules (include_annotations) come through here as well.
49
+ def self.register_annotation(annotation)
50
+ @annotations ||= [] #: Array[Archsight::Annotations::Annotation]
51
+ if annotation.summary? && @annotations.count(&:summary?) >= MAX_SUMMARY_ANNOTATIONS
52
+ raise ArgumentError,
53
+ "#{name}: at most #{MAX_SUMMARY_ANNOTATIONS} annotations can be summary attributes, " \
54
+ "cannot add #{annotation.key}"
55
+ end
56
+
57
+ @annotations << annotation
33
58
  end
34
59
 
35
60
  # Get all annotation definitions
@@ -48,19 +73,18 @@ module Archsight
48
73
  # @param enum [Array, nil] Allowed values
49
74
  # @param sidebar [Boolean] Show in sidebar (default false for computed)
50
75
  # @param type [Class, nil] Type for value coercion (Integer, Float, String)
51
- # @param list [Boolean] Whether values are lists (default false)
76
+ # @param summary [Boolean] Return the value with every search hit of this kind (default false, max 3 per kind)
52
77
  # @yield Block that computes the annotation value, evaluated in Evaluator context
53
78
  def self.computed_annotation(key, description: nil, filter: nil, title: nil, format: nil, enum: nil,
54
- sidebar: false, type: nil, list: false, editor: true, &)
79
+ sidebar: false, type: nil, summary: false, editor: true, &)
55
80
  require_relative "../annotations/computed"
56
81
  @computed_annotations ||= [] #: Array[Archsight::Annotations::Computed]
57
82
  @computed_annotations << Archsight::Annotations::Computed.new(key, description: description, type: type, &)
58
83
 
59
84
  # Also register as a regular annotation so it passes validation and is recognized
60
- @annotations ||= [] #: Array[Archsight::Annotations::Annotation]
61
85
  options = { description: description, filter: filter, title: title, format: format, enum: enum,
62
- sidebar: sidebar, type: type, list: list, editor: editor }
63
- @annotations << Archsight::Annotations::Annotation.new(key, options)
86
+ sidebar: sidebar, type: type, summary: summary, editor: editor }
87
+ register_annotation(Archsight::Annotations::Annotation.new(key, options))
64
88
  end
65
89
 
66
90
  # Get all computed annotation definitions
@@ -83,9 +107,9 @@ module Archsight
83
107
  annotations.select(&:filterable?).reject(&:pattern?)
84
108
  end
85
109
 
86
- # Get annotations marked for list display
87
- def self.list_annotations
88
- annotations.select(&:list_display?).reject(&:pattern?)
110
+ # Get the annotations marked as summary attributes (returned with search hits)
111
+ def self.summary_annotations
112
+ annotations.select(&:summary?).reject(&:pattern?)
89
113
  end
90
114
 
91
115
  def self.annotation_title(key)
@@ -96,6 +120,17 @@ module Archsight
96
120
  annotation_matching(key)&.format
97
121
  end
98
122
 
123
+ # The formats this kind defines for the given annotation keys, `{ "evidence/gaps" => "markdown" }` (keys without
124
+ # a format are left out). The frontend shows each value accordingly.
125
+ def self.annotation_formats(keys)
126
+ formats = {} #: Hash[String, String]
127
+ keys.each do |key|
128
+ format = annotation_format(key)
129
+ formats[key] = format.to_s if format
130
+ end
131
+ formats
132
+ end
133
+
99
134
  def self.annotation_enum(key)
100
135
  annotation_matching(key)&.enum
101
136
  end
@@ -216,17 +251,32 @@ module Archsight
216
251
  end
217
252
 
218
253
  def verb_allowed?(verb)
219
- self.class.relations.any? { |v, _, _| v.to_s == verb.to_s }
254
+ self.class.declared_relations.any? { |v, _, _| v.to_s == verb.to_s }
220
255
  end
221
256
 
222
257
  def verb_kind_allowed?(verb, kind)
223
- self.class.relations.any? { |v, k, _| v.to_s == verb.to_s && k.to_s == kind.to_s }
258
+ self.class.declared_relations.any? { |v, k, _| v.to_s == verb.to_s && k.to_s == kind.to_s }
224
259
  end
225
260
 
226
261
  def relations(verb, kind)
227
262
  (spec[verb.to_s] || {})[kind.to_s] || []
228
263
  end
229
264
 
265
+ # Records a relation that is derived from this resource's text or diagram, not written in its file: the target
266
+ # goes into `spec` where the relations of this verb are read (so queries and graphs see it like any other) and
267
+ # learns about this resource as one that refers to it.
268
+ def add_derived_relation(verb, target)
269
+ key = Archsight::Resources::DERIVED_KEY.to_s
270
+ spec_root = (@raw["spec"] ||= {}) #: Hash[String, untyped]
271
+ by_key = (spec_root[verb.to_s] ||= {}) #: Hash[String, Array[Base]]
272
+ by_key[key] ||= [] #: Array[Base]
273
+ targets = by_key.fetch(key)
274
+ return if targets.any? { |known| known.equal?(target) }
275
+
276
+ targets << target
277
+ target.referenced_by(self, verb.to_sym)
278
+ end
279
+
230
280
  def set_relations(verb, kind, rels)
231
281
  spec[verb.to_s][kind.to_s] = rels
232
282
  rels.each { |rel| rel.referenced_by(self, verb) }