archsight 0.3.1 → 0.3.3

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CONTRIBUTING.md +1 -1
  3. data/README.md +6 -1
  4. data/docs/icons.md +29 -2
  5. data/docs/index.md.erb +22 -4
  6. data/docs/modeling.md +268 -6
  7. data/docs/pages.md +12 -3
  8. data/docs/search.md +9 -2
  9. data/docs/togaf.md +8 -1
  10. data/lib/archsight/annotations/asset_annotations.rb +34 -0
  11. data/lib/archsight/annotations/relation_resolver.rb +15 -6
  12. data/lib/archsight/annotations/risk_annotations.rb +21 -0
  13. data/lib/archsight/cli.rb +6 -0
  14. data/lib/archsight/database.rb +57 -3
  15. data/lib/archsight/diagram.rb +7 -0
  16. data/lib/archsight/documentation.rb +10 -6
  17. data/lib/archsight/editor.rb +2 -2
  18. data/lib/archsight/helpers/requirements_blocks.rb +1 -1
  19. data/lib/archsight/helpers/resource_resolver.rb +1 -1
  20. data/lib/archsight/helpers/wiki_links.rb +11 -0
  21. data/lib/archsight/linter.rb +110 -0
  22. data/lib/archsight/mcp/analyze_resource_tool.rb +2 -2
  23. data/lib/archsight/mcp/resource_doc_tool.rb +2 -2
  24. data/lib/archsight/query/ast.rb +2 -1
  25. data/lib/archsight/query/evaluator.rb +2 -2
  26. data/lib/archsight/references.rb +129 -0
  27. data/lib/archsight/requirements.rb +4 -4
  28. data/lib/archsight/resources/application_component.rb +11 -1
  29. data/lib/archsight/resources/application_event.rb +79 -0
  30. data/lib/archsight/resources/application_interface.rb +1 -1
  31. data/lib/archsight/resources/application_service.rb +10 -5
  32. data/lib/archsight/resources/base.rb +40 -5
  33. data/lib/archsight/resources/business_actor.rb +9 -1
  34. data/lib/archsight/resources/business_control.rb +88 -0
  35. data/lib/archsight/resources/business_event.rb +79 -0
  36. data/lib/archsight/resources/business_process.rb +11 -3
  37. data/lib/archsight/resources/business_product.rb +2 -2
  38. data/lib/archsight/resources/business_role.rb +69 -0
  39. data/lib/archsight/resources/compliance_evidence.rb +44 -3
  40. data/lib/archsight/resources/data_object.rb +7 -2
  41. data/lib/archsight/resources/implementation_deliverable.rb +62 -0
  42. data/lib/archsight/resources/implementation_event.rb +52 -0
  43. data/lib/archsight/resources/implementation_gap.rb +49 -0
  44. data/lib/archsight/resources/implementation_plateau.rb +61 -0
  45. data/lib/archsight/resources/implementation_work_package.rb +83 -0
  46. data/lib/archsight/resources/motivation_assessment.rb +121 -0
  47. data/lib/archsight/resources/{business_constraint.rb → motivation_constraint.rb} +11 -5
  48. data/lib/archsight/resources/motivation_driver.rb +50 -0
  49. data/lib/archsight/resources/motivation_goal.rb +14 -2
  50. data/lib/archsight/resources/motivation_principle.rb +75 -0
  51. data/lib/archsight/resources/{business_requirement.rb → motivation_requirement.rb} +27 -7
  52. data/lib/archsight/resources/motivation_stakeholder.rb +9 -2
  53. data/lib/archsight/resources/page.rb +5 -0
  54. data/lib/archsight/resources/strategy_capability.rb +2 -2
  55. data/lib/archsight/resources/technology_event.rb +80 -0
  56. data/lib/archsight/resources/technology_node.rb +8 -2
  57. data/lib/archsight/resources/technology_service.rb +3 -3
  58. data/lib/archsight/resources/technology_system_software.rb +3 -3
  59. data/lib/archsight/resources.rb +34 -3
  60. data/lib/archsight/template.rb +2 -2
  61. data/lib/archsight/version.rb +1 -1
  62. data/lib/archsight/web/api/docs.rb +1 -1
  63. data/lib/archsight/web/api/json_helpers.rb +14 -10
  64. data/lib/archsight/web/api/openapi/spec.yaml +2 -2
  65. data/lib/archsight/web/api/page_helpers.rb +8 -7
  66. data/lib/archsight/web/api/routes.rb +1 -1
  67. data/lib/archsight/web/application.rb +6 -1
  68. data/lib/archsight/web/public/vue/{ApiDocsPage-D-cPRZCT.js → ApiDocsPage-BgqnQgwa.js} +1 -1
  69. data/lib/archsight/web/public/vue/{DocPage-DK6vNDbF.js → DocPage-CNO71nBH.js} +1 -1
  70. data/lib/archsight/web/public/vue/{EditorPage-BoJpQaVw.js → EditorPage-DR0FCNTz.js} +1 -1
  71. data/lib/archsight/web/public/vue/{ErrorPage-PGyjdtEf.js → ErrorPage-BfYArv6s.js} +1 -1
  72. data/lib/archsight/web/public/vue/{GraphView-BLiKR4zP.js → GraphView-BVTW30Ak.js} +1 -1
  73. data/lib/archsight/web/public/vue/HomePage-Wk9P4Pma.js +2 -0
  74. data/lib/archsight/web/public/vue/InstanceRouter-BV8ycydz.js +1 -0
  75. data/lib/archsight/web/public/vue/{InstanceRouter-60Tt3ZNM.css → InstanceRouter-jeavM6PW.css} +1 -1
  76. data/lib/archsight/web/public/vue/KindList-C8yMsN5J.js +1 -0
  77. data/lib/archsight/web/public/vue/{PageView-9MgHtrgl.js → PageView-BaN6TyJB.js} +1 -1
  78. data/lib/archsight/web/public/vue/{QueryError-D1FL1xgA.js → QueryError-D790wH-a.js} +1 -1
  79. data/lib/archsight/web/public/vue/ResourceList-D-66nas2.js +2 -0
  80. data/lib/archsight/web/public/vue/{SearchResults-DiW5XVYW.css → SearchResults-BewvsfOc.css} +1 -1
  81. data/lib/archsight/web/public/vue/SearchResults-CHyqIerQ.js +1 -0
  82. data/lib/archsight/web/public/vue/{WikiPage-CeCQBTDS.js → WikiPage-D2svpk6B.js} +3 -3
  83. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +1 -0
  84. data/lib/archsight/web/public/vue/{index-D7m61Ahx.js → index-BXXkUK1V.js} +2 -2
  85. data/lib/archsight/web/public/vue/index-Cov1SnzY.css +1 -0
  86. data/lib/archsight/web/public/vue/{useGraphviz-DweKV7Kg.js → useGraphviz-DlSnFeCL.js} +12 -0
  87. data/lib/archsight/web/public/vue.html +2 -2
  88. metadata +38 -22
  89. data/lib/archsight/web/public/vue/HomePage-C0lR8i2C.js +0 -2
  90. data/lib/archsight/web/public/vue/InstanceRouter-D3W2jJHV.js +0 -1
  91. data/lib/archsight/web/public/vue/KindList-BlsaRBNO.js +0 -1
  92. data/lib/archsight/web/public/vue/ResourceList-vkgFyeOY.js +0 -2
  93. data/lib/archsight/web/public/vue/SearchResults-Cl3O_OEr.js +0 -1
  94. data/lib/archsight/web/public/vue/WikiPage-C-8SG67a.css +0 -1
  95. data/lib/archsight/web/public/vue/index-Dbx3MXWG.css +0 -1
@@ -11,6 +11,17 @@
11
11
  # - Symbol: Simple kind filter (e.g., :TechnologyArtifact)
12
12
  # - String: Query selector (e.g., 'TechnologyArtifact: activity/status == "active"')
13
13
  class Archsight::Annotations::ComputedRelationResolver
14
+ # The instances that refer to a resource through relations that are written in files. Computed annotations sum up the
15
+ # modelled architecture (costs, teams, repositories): what a page or a description merely mentions or depicts (see
16
+ # Archsight::References) must not change them, so they follow modelled relations only.
17
+ def self.modelled_references(inst)
18
+ (inst.references || []).filter_map do |ref|
19
+ next ref unless ref.is_a?(Hash)
20
+
21
+ ref[:instance] unless Archsight::Resources::DERIVED_VERBS.include?(ref[:verb])
22
+ end
23
+ end
24
+
14
25
  MAX_DEPTH = 10
15
26
 
16
27
  # TraversalCache holds what can be shared between all resolvers of one computation run: the unfiltered
@@ -83,9 +94,9 @@ class Archsight::Annotations::ComputedRelationResolver
83
94
 
84
95
  def neighbours(inst, direction)
85
96
  if direction == :outgoing
86
- inst.class.relations.flat_map { |verb, kind_name, _klass_name| inst.relations(verb, kind_name) }
97
+ inst.class.declared_relations.flat_map { |verb, kind_name, _klass_name| inst.relations(verb, kind_name) }
87
98
  else
88
- (inst.references || []).filter_map { |ref| ref.is_a?(Hash) ? ref[:instance] : ref }
99
+ Archsight::Annotations::ComputedRelationResolver.modelled_references(inst)
89
100
  end
90
101
  end
91
102
  end
@@ -103,7 +114,7 @@ class Archsight::Annotations::ComputedRelationResolver
103
114
  def outgoing(filter = nil)
104
115
  results = []
105
116
 
106
- @instance.class.relations.each do |_verb, kind_name, _klass_name|
117
+ @instance.class.declared_relations.each do |_verb, kind_name, _klass_name|
107
118
  rels = @instance.relations(_verb, kind_name)
108
119
  rels.each do |rel|
109
120
  results << rel if matches_filter?(rel, filter)
@@ -127,9 +138,7 @@ class Archsight::Annotations::ComputedRelationResolver
127
138
  # @param filter [Symbol, String, nil] Optional kind filter (Symbol) or query selector (String)
128
139
  # @return [Array] Array of instances that reference this one
129
140
  def incoming(filter = nil)
130
- refs = @instance.references || []
131
- # Extract instances from reference hashes
132
- instances = refs.map { |ref| ref.is_a?(Hash) ? ref[:instance] : ref }.compact
141
+ instances = self.class.modelled_references(@instance).compact
133
142
 
134
143
  if filter.nil?
135
144
  instances
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Risk module adds the annotations that group security and risk resources: the paper's "risk domain" and the
4
+ # classification of the risk
5
+ module Archsight::Annotations::Risk
6
+ def self.included(base)
7
+ base.class_eval do
8
+ annotation "risk/domain",
9
+ description: "Risk domain(s) the resource belongs to (comma-separated); groups threats, risks, " \
10
+ "vulnerabilities, measures and assets that share a context",
11
+ title: "Risk domain",
12
+ filter: :list
13
+
14
+ annotation "risk/category",
15
+ description: "Classification of the risk (CAS enterprise risk classification, plus compliance and security)",
16
+ title: "Risk category",
17
+ enum: %w[hazard financial operational strategic compliance security],
18
+ filter: :word
19
+ end
20
+ end
21
+ end
data/lib/archsight/cli.rb CHANGED
@@ -127,6 +127,12 @@ module Archsight
127
127
  linter = Archsight::Linter.new(db)
128
128
  errors = linter.validate
129
129
 
130
+ warnings = linter.warnings
131
+ if warnings.any?
132
+ puts "Deprecations (#{warnings.count}):"
133
+ warnings.each { |warning| puts " #{warning}" }
134
+ end
135
+
130
136
  if errors.any?
131
137
  puts "Validation Errors (#{errors.count}):"
132
138
  errors.each { |error| display_error_with_context(error) }
@@ -5,6 +5,7 @@ require_relative "graph"
5
5
  require_relative "resources"
6
6
  require_relative "page_loader"
7
7
  require_relative "query"
8
+ require_relative "references"
8
9
 
9
10
  module Archsight
10
11
  # LineReference combines a path and line reference
@@ -40,12 +41,19 @@ module Archsight
40
41
  end
41
42
  end
42
43
 
44
+ # Deprecation is a use of a renamed kind or relation key that was accepted under its old name
45
+ Deprecation = Struct.new(:ref, :message) do
46
+ def to_s
47
+ "#{ref}: #{message}"
48
+ end
49
+ end
50
+
43
51
  # Database loads yaml files and folders to create an in-memory representation
44
52
  # of the structure. The loading and parsing of files will raise errors
45
53
  # if invalid data is passed.
46
54
  class Database
47
55
  attr_accessor :instances, :verbose, :verify, :compute_annotations, :only_kinds
48
- attr_reader :path
56
+ attr_reader :path, :deprecations
49
57
 
50
58
  def initialize(path, verbose: false, verify: true, compute_annotations: true, only_kinds: nil)
51
59
  @path = path
@@ -54,10 +62,12 @@ module Archsight
54
62
  @compute_annotations = compute_annotations
55
63
  @only_kinds = only_kinds
56
64
  @instances = {}
65
+ @deprecations = []
57
66
  end
58
67
 
59
68
  def reload!
60
69
  @instances = {}
70
+ @deprecations = []
61
71
 
62
72
  # load all resources
63
73
  Dir.glob(File.join(@path, "**/*.{yaml,md}")).each do |path|
@@ -67,6 +77,7 @@ module Archsight
67
77
  end
68
78
 
69
79
  verify! if @verify
80
+ derive_references! if @verify
70
81
  compute_all_annotations! if @verify && @compute_annotations
71
82
  rescue Psych::SyntaxError => e
72
83
  # Wrap YAML syntax errors in ResourceError for consistent handling
@@ -118,12 +129,49 @@ module Archsight
118
129
 
119
130
  kind = obj["kind"] || raise("kind not defined")
120
131
  klass = Archsight::Resources[kind] || raise("#{kind} is not a valid kind")
132
+ accept_old_names(obj)
121
133
  inst = klass.new(obj, @current_ref)
122
134
  raise("metadata name of #{kind} not present") if inst.name.to_s.strip.empty?
123
135
 
124
136
  inst
125
137
  end
126
138
 
139
+ # Documents may still use the old name of a renamed kind and the old relation keys. They are rewritten to the
140
+ # current names here, so everything after loading only sees those, and each rewrite is kept as a deprecation
141
+ # (reported by `archsight lint`).
142
+ def accept_old_names(obj)
143
+ kind = obj["kind"]
144
+ current = Archsight::Resources.canonical(kind)
145
+ if current != kind
146
+ deprecate("kind '#{kind}' is renamed to '#{current}'")
147
+ obj["kind"] = current
148
+ end
149
+
150
+ return unless obj["spec"].is_a?(Hash)
151
+
152
+ obj["spec"].each do |verb, keys|
153
+ next unless keys.is_a?(Hash)
154
+
155
+ Archsight::Resources::RELATION_KEY_ALIASES.each do |old_key, new_key|
156
+ next unless keys.key?(old_key)
157
+
158
+ deprecate("relation key '#{old_key}' under '#{verb}' is renamed to '#{new_key}'")
159
+ keys[new_key] = (Array(keys[new_key]) + Array(keys.delete(old_key))).uniq
160
+ end
161
+ end
162
+ end
163
+
164
+ def deprecate(message)
165
+ @deprecations << Deprecation.new(@current_ref, "#{message} (the old name will be removed in a future release)")
166
+ end
167
+
168
+ # Whether a document of this kind is wanted by the only_kinds filter, whichever of its names either side uses
169
+ def kind_wanted?(kind)
170
+ return true unless @only_kinds
171
+
172
+ @only_kinds.map { |k| Archsight::Resources.canonical(k) }.include?(Archsight::Resources.canonical(kind))
173
+ end
174
+
127
175
  def load_file(path)
128
176
  File.open(path, "r") do |f|
129
177
  YAML.parse_stream(f) do |node|
@@ -132,7 +180,7 @@ module Archsight
132
180
  next unless obj # skip empty / unknown documents
133
181
 
134
182
  # Skip resources that don't match only_kinds filter
135
- next if @only_kinds && !@only_kinds.include?(obj["kind"])
183
+ next unless kind_wanted?(obj["kind"])
136
184
 
137
185
  self << create_valid_instance(obj)
138
186
  end
@@ -186,7 +234,7 @@ module Archsight
186
234
  end
187
235
 
188
236
  def verify_instance_relations!(inst)
189
- inst.class.relations.each do |verb, kind, klass_name|
237
+ inst.class.declared_relations.each do |verb, kind, klass_name|
190
238
  rels = inst.relations(verb, kind).map do |rel_name|
191
239
  rel_klass = Archsight::Resources[klass_name] || raise_for(inst, "#{klass_name} is not a valid relation kind")
192
240
  kind_display = rel_klass.to_s.sub(/^Archsight::Resources::/, "")
@@ -197,6 +245,12 @@ module Archsight
197
245
  end
198
246
  end
199
247
 
248
+ # Adds the relations that the text and the diagrams of the resources imply (`mentions`, `depicts`), after all
249
+ # written relations are resolved and before the computed annotations are calculated, which follow relations.
250
+ def derive_references!
251
+ Archsight::References.derive!(self)
252
+ end
253
+
200
254
  # Compute all computed annotations for all instances
201
255
  def compute_all_annotations!
202
256
  manager = Archsight::Annotations::ComputedManager.new(self)
@@ -44,6 +44,13 @@ module Archsight
44
44
  time(profile, :render) { Renderer.render(graph, layout, relation_filter: relation_filter, style: style, id_prefix: id_prefix) }
45
45
  end
46
46
 
47
+ # The `resource "..."` references of the nodes of a diagram source, in order and without duplicates. Parses and
48
+ # builds the graph but does not lay it out, so it is cheap enough to run over every diagram of a model.
49
+ # @raise [Archsight::Diagram::Error] if the source is not a valid diagram
50
+ def self.resource_references(source)
51
+ Graph.build(Parser.parse(source)).nodes_by_id.values.filter_map { |node| node.attrs["resource"] }.uniq
52
+ end
53
+
47
54
  def self.time(profile, key)
48
55
  return yield unless profile
49
56
 
@@ -7,7 +7,7 @@ module Archsight
7
7
  # Documentation generates markdown documentation for architecture resources
8
8
  class Documentation
9
9
  # Layer display order (top to bottom)
10
- LAYER_ORDER = %w[motivation strategy business application technology].freeze
10
+ LAYER_ORDER = %w[motivation strategy business application technology implementation].freeze
11
11
 
12
12
  # Layer display names
13
13
  LAYER_NAMES = {
@@ -15,7 +15,8 @@ module Archsight
15
15
  "strategy" => "Strategy Layer",
16
16
  "business" => "Business Layer",
17
17
  "application" => "Application Layer",
18
- "technology" => "Technology Layer"
18
+ "technology" => "Technology Layer",
19
+ "implementation" => "Implementation & Migration Layer"
19
20
  }.freeze
20
21
 
21
22
  # Resource kinds to exclude from the diagram
@@ -104,7 +105,7 @@ module Archsight
104
105
  # Skip kinds not in a displayed layer
105
106
  next unless LAYER_ORDER.include?(klass.layer)
106
107
 
107
- klass.relations.each do |verb, _relation_kind, target_klass|
108
+ klass.declared_relations.each do |verb, _relation_kind, target_klass|
108
109
  # Skip relations to excluded kinds
109
110
  next if EXCLUDED_KINDS.include?(target_klass.to_s)
110
111
 
@@ -149,13 +150,16 @@ module Archsight
149
150
  end
150
151
 
151
152
  def generate_relations_table(klass)
152
- return "_No relations defined._" if klass.relations.empty?
153
+ derived = "Every resource also has the derived relations #{Archsight::Resources::DERIVED_VERBS.map { |v| "`#{v}`" }.join(" and ")} " \
154
+ "towards any kind: they follow from links in its text and from its diagrams, so they are not written in files " \
155
+ "(see the Modeling Guide)."
156
+ return "_No relations defined._\n\n#{derived}" if klass.declared_relations.empty?
153
157
 
154
158
  rows = ["| Relation | Target | Kind |", "|----------|--------|------|"]
155
- klass.relations.each do |verb, kind, target_klass|
159
+ klass.declared_relations.each do |verb, kind, target_klass|
156
160
  rows << "| #{verb} | #{target_klass} | #{kind} |"
157
161
  end
158
- rows.join("\n")
162
+ "#{rows.join("\n")}\n\n#{derived}"
159
163
  end
160
164
 
161
165
  def format_values(annotation)
@@ -21,7 +21,7 @@ module Archsight
21
21
  def build_resource(kind:, name:, annotations: {}, relations: [])
22
22
  resource = {
23
23
  "apiVersion" => "architecture/v1alpha1",
24
- "kind" => kind,
24
+ "kind" => Archsight::Resources.canonical(kind), # saving a resource that had the old name migrates it
25
25
  "metadata" => {
26
26
  "name" => name
27
27
  }
@@ -129,7 +129,7 @@ module Archsight
129
129
  klass = Archsight::Resources[kind]
130
130
  return [] unless klass
131
131
 
132
- klass.relations
132
+ klass.declared_relations # derived relations are not written in files
133
133
  end
134
134
 
135
135
  # Get unique verbs for a resource kind's relations
@@ -5,7 +5,7 @@ require_relative "fenced_blocks"
5
5
 
6
6
  module Archsight
7
7
  module Helpers
8
- # Turns ```requirements fenced blocks in rendered markdown into the table of business requirements of a
8
+ # Turns ```requirements fenced blocks in rendered markdown into the table of requirements of a
9
9
  # selection of resources (see Archsight::Requirements):
10
10
  #
11
11
  # ```requirements
@@ -38,7 +38,7 @@ module Archsight
38
38
  klass = Archsight::Resources[kind]
39
39
  return [] unless klass && @database.instances.fetch(klass, {}).key?(name)
40
40
 
41
- [[kind, name]]
41
+ [[Archsight::Resources.canonical(kind), name]] # a renamed kind is linked under its new name
42
42
  end
43
43
 
44
44
  def in_any_kind(name)
@@ -64,6 +64,17 @@ module Archsight
64
64
  found.is_a?(Array) ? @database.instances_by_kind(found.first)[found.last] : nil
65
65
  end
66
66
 
67
+ # Like target_for, but only an exact name counts: a page by name or title, `Kind/Name`, or a resource name that
68
+ # exists in one kind. A target that only matches part of a name does not name anything. For relations that are
69
+ # derived from text, where a wrong guess would be a wrong relation.
70
+ def exact_target_for(target)
71
+ page = find_page(target)
72
+ return page if page
73
+
74
+ found = @resolver.find(target)
75
+ found.is_a?(Array) ? @database.instances_by_kind(found.first)[found.last] : nil
76
+ end
77
+
67
78
  # Page path for a page name, nil if there is no such page
68
79
  def page_path(page)
69
80
  "/pages/#{ERB::Util.url_encode(page.name)}"
@@ -8,6 +8,12 @@ module Archsight
8
8
  # Valid @component references in View annotations
9
9
  VALID_COMPONENTS = %w[activity git jira languages owner repositories status].freeze
10
10
 
11
+ # Relations the cycle check leaves out. Relations are followed from the dependent to what it relies on, so
12
+ # 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
+ # never enter the check.
15
+ CYCLE_EXEMPT_VERBS = %i[dependsOn].freeze
16
+
11
17
  def initialize(database)
12
18
  @database = database
13
19
  @errors = []
@@ -22,10 +28,17 @@ module Archsight
22
28
  validate_menu(instance) if instance.klass == "PageMenu"
23
29
  end
24
30
  end
31
+ validate_relation_cycles
25
32
 
26
33
  @errors
27
34
  end
28
35
 
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.
38
+ def warnings
39
+ @database.respond_to?(:deprecations) ? @database.deprecations.map(&:to_s) : []
40
+ end
41
+
29
42
  private
30
43
 
31
44
  # Pages need exactly one menu and their [[links]] must resolve
@@ -73,6 +86,103 @@ module Archsight
73
86
  nil
74
87
  end
75
88
 
89
+ # Declared relations must not lead back to where they started (see the "Direction of Relations" modeling guide)
90
+ def validate_relation_cycles
91
+ graph = relation_graph
92
+ strongly_connected_groups(graph).each do |group|
93
+ cycle = cycle_through(group.first, group, graph)
94
+ names = cycle.map { |step| step[:to].name }
95
+ 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(" ")})"
98
+ end
99
+ end
100
+
101
+ # { instance => [{ verb:, to: }] } over the declared relations
102
+ def relation_graph
103
+ graph = {}
104
+ @database.instances.each_value do |instances_hash|
105
+ instances_hash.each_value do |instance|
106
+ graph[instance] = instance.class.declared_relations.flat_map do |verb, key, _kind|
107
+ next [] if CYCLE_EXEMPT_VERBS.include?(verb)
108
+
109
+ instance.relations(verb, key).map { |target| { verb: verb, to: target } }
110
+ end
111
+ end
112
+ end
113
+ graph
114
+ end
115
+
116
+ # Groups of instances that reach each other (iterative Tarjan, the graph can be deep); a single instance counts
117
+ # only when it points at itself
118
+ def strongly_connected_groups(graph)
119
+ index = {}
120
+ lowlink = {}
121
+ on_stack = {}
122
+ stack = []
123
+ groups = []
124
+ counter = 0
125
+
126
+ graph.each_key do |root|
127
+ next if index.key?(root)
128
+
129
+ work = [[root, 0]]
130
+ until work.empty?
131
+ node, edge = work.last
132
+ if edge.zero?
133
+ index[node] = lowlink[node] = counter
134
+ counter += 1
135
+ stack << node
136
+ on_stack[node] = true
137
+ end
138
+
139
+ edges = graph[node] || []
140
+ if edge < edges.length
141
+ work.last[1] += 1
142
+ target = edges[edge][:to]
143
+ if !index.key?(target)
144
+ work << [target, 0]
145
+ elsif on_stack[target]
146
+ lowlink[node] = [lowlink[node], index[target]].min
147
+ end
148
+ else
149
+ work.pop
150
+ lowlink[work.last.first] = [lowlink[work.last.first], lowlink[node]].min unless work.empty?
151
+ next unless lowlink[node] == index[node]
152
+
153
+ group = []
154
+ loop do
155
+ member = stack.pop
156
+ on_stack[member] = false
157
+ group << member
158
+ break if member.equal?(node)
159
+ end
160
+ groups << group if group.length > 1 || edges.any? { |e| e[:to].equal?(node) }
161
+ end
162
+ end
163
+ end
164
+ groups
165
+ end
166
+
167
+ # One concrete cycle through `start` inside its group: [{ verb:, to: }, ...] ending at `start`
168
+ def cycle_through(start, group, graph)
169
+ members = group.to_h { |member| [member, true] }
170
+ queue = [[start, []]]
171
+ seen = { start => true }
172
+ until queue.empty?
173
+ node, path = queue.shift
174
+ graph[node].each do |edge|
175
+ next unless members[edge[:to]]
176
+ return path + [edge] if edge[:to].equal?(start)
177
+ next if seen[edge[:to]]
178
+
179
+ seen[edge[:to]] = true
180
+ queue << [edge[:to], path + [edge]]
181
+ end
182
+ end
183
+ []
184
+ end
185
+
76
186
  def validate_instance_annotations(instance)
77
187
  instance.annotations.each do |key, value|
78
188
  # Find matching annotation definition (handles both exact and pattern matches)
@@ -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."
@@ -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