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.
- checksums.yaml +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +6 -1
- data/docs/icons.md +29 -2
- data/docs/index.md.erb +22 -4
- data/docs/modeling.md +268 -6
- data/docs/pages.md +12 -3
- data/docs/search.md +9 -2
- data/docs/togaf.md +8 -1
- data/lib/archsight/annotations/asset_annotations.rb +34 -0
- data/lib/archsight/annotations/relation_resolver.rb +15 -6
- data/lib/archsight/annotations/risk_annotations.rb +21 -0
- data/lib/archsight/cli.rb +6 -0
- data/lib/archsight/database.rb +57 -3
- data/lib/archsight/diagram.rb +7 -0
- data/lib/archsight/documentation.rb +10 -6
- data/lib/archsight/editor.rb +2 -2
- data/lib/archsight/helpers/requirements_blocks.rb +1 -1
- data/lib/archsight/helpers/resource_resolver.rb +1 -1
- data/lib/archsight/helpers/wiki_links.rb +11 -0
- data/lib/archsight/linter.rb +110 -0
- data/lib/archsight/mcp/analyze_resource_tool.rb +2 -2
- data/lib/archsight/mcp/resource_doc_tool.rb +2 -2
- data/lib/archsight/query/ast.rb +2 -1
- data/lib/archsight/query/evaluator.rb +2 -2
- data/lib/archsight/references.rb +129 -0
- data/lib/archsight/requirements.rb +4 -4
- data/lib/archsight/resources/application_component.rb +11 -1
- data/lib/archsight/resources/application_event.rb +79 -0
- data/lib/archsight/resources/application_interface.rb +1 -1
- data/lib/archsight/resources/application_service.rb +10 -5
- data/lib/archsight/resources/base.rb +40 -5
- data/lib/archsight/resources/business_actor.rb +9 -1
- data/lib/archsight/resources/business_control.rb +88 -0
- data/lib/archsight/resources/business_event.rb +79 -0
- data/lib/archsight/resources/business_process.rb +11 -3
- data/lib/archsight/resources/business_product.rb +2 -2
- data/lib/archsight/resources/business_role.rb +69 -0
- data/lib/archsight/resources/compliance_evidence.rb +44 -3
- data/lib/archsight/resources/data_object.rb +7 -2
- data/lib/archsight/resources/implementation_deliverable.rb +62 -0
- data/lib/archsight/resources/implementation_event.rb +52 -0
- data/lib/archsight/resources/implementation_gap.rb +49 -0
- data/lib/archsight/resources/implementation_plateau.rb +61 -0
- data/lib/archsight/resources/implementation_work_package.rb +83 -0
- data/lib/archsight/resources/motivation_assessment.rb +121 -0
- data/lib/archsight/resources/{business_constraint.rb → motivation_constraint.rb} +11 -5
- data/lib/archsight/resources/motivation_driver.rb +50 -0
- data/lib/archsight/resources/motivation_goal.rb +14 -2
- data/lib/archsight/resources/motivation_principle.rb +75 -0
- data/lib/archsight/resources/{business_requirement.rb → motivation_requirement.rb} +27 -7
- data/lib/archsight/resources/motivation_stakeholder.rb +9 -2
- data/lib/archsight/resources/page.rb +5 -0
- data/lib/archsight/resources/strategy_capability.rb +2 -2
- data/lib/archsight/resources/technology_event.rb +80 -0
- data/lib/archsight/resources/technology_node.rb +8 -2
- data/lib/archsight/resources/technology_service.rb +3 -3
- data/lib/archsight/resources/technology_system_software.rb +3 -3
- data/lib/archsight/resources.rb +34 -3
- data/lib/archsight/template.rb +2 -2
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/docs.rb +1 -1
- data/lib/archsight/web/api/json_helpers.rb +14 -10
- data/lib/archsight/web/api/openapi/spec.yaml +2 -2
- data/lib/archsight/web/api/page_helpers.rb +8 -7
- data/lib/archsight/web/api/routes.rb +1 -1
- data/lib/archsight/web/application.rb +6 -1
- data/lib/archsight/web/public/vue/{ApiDocsPage-D-cPRZCT.js → ApiDocsPage-BgqnQgwa.js} +1 -1
- data/lib/archsight/web/public/vue/{DocPage-DK6vNDbF.js → DocPage-CNO71nBH.js} +1 -1
- data/lib/archsight/web/public/vue/{EditorPage-BoJpQaVw.js → EditorPage-DR0FCNTz.js} +1 -1
- data/lib/archsight/web/public/vue/{ErrorPage-PGyjdtEf.js → ErrorPage-BfYArv6s.js} +1 -1
- data/lib/archsight/web/public/vue/{GraphView-BLiKR4zP.js → GraphView-BVTW30Ak.js} +1 -1
- data/lib/archsight/web/public/vue/HomePage-Wk9P4Pma.js +2 -0
- data/lib/archsight/web/public/vue/InstanceRouter-BV8ycydz.js +1 -0
- data/lib/archsight/web/public/vue/{InstanceRouter-60Tt3ZNM.css → InstanceRouter-jeavM6PW.css} +1 -1
- data/lib/archsight/web/public/vue/KindList-C8yMsN5J.js +1 -0
- data/lib/archsight/web/public/vue/{PageView-9MgHtrgl.js → PageView-BaN6TyJB.js} +1 -1
- data/lib/archsight/web/public/vue/{QueryError-D1FL1xgA.js → QueryError-D790wH-a.js} +1 -1
- data/lib/archsight/web/public/vue/ResourceList-D-66nas2.js +2 -0
- data/lib/archsight/web/public/vue/{SearchResults-DiW5XVYW.css → SearchResults-BewvsfOc.css} +1 -1
- data/lib/archsight/web/public/vue/SearchResults-CHyqIerQ.js +1 -0
- data/lib/archsight/web/public/vue/{WikiPage-CeCQBTDS.js → WikiPage-D2svpk6B.js} +3 -3
- data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +1 -0
- data/lib/archsight/web/public/vue/{index-D7m61Ahx.js → index-BXXkUK1V.js} +2 -2
- data/lib/archsight/web/public/vue/index-Cov1SnzY.css +1 -0
- data/lib/archsight/web/public/vue/{useGraphviz-DweKV7Kg.js → useGraphviz-DlSnFeCL.js} +12 -0
- data/lib/archsight/web/public/vue.html +2 -2
- metadata +38 -22
- data/lib/archsight/web/public/vue/HomePage-C0lR8i2C.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-D3W2jJHV.js +0 -1
- data/lib/archsight/web/public/vue/KindList-BlsaRBNO.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-vkgFyeOY.js +0 -2
- data/lib/archsight/web/public/vue/SearchResults-Cl3O_OEr.js +0 -1
- data/lib/archsight/web/public/vue/WikiPage-C-8SG67a.css +0 -1
- 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.
|
|
97
|
+
inst.class.declared_relations.flat_map { |verb, kind_name, _klass_name| inst.relations(verb, kind_name) }
|
|
87
98
|
else
|
|
88
|
-
(inst
|
|
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.
|
|
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
|
-
|
|
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) }
|
data/lib/archsight/database.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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)
|
data/lib/archsight/diagram.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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)
|
data/lib/archsight/editor.rb
CHANGED
|
@@ -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
|
|
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)}"
|
data/lib/archsight/linter.rb
CHANGED
|
@@ -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,
|
|
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
|
-
"
|
|
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
|
-
•
|
|
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.
|
|
64
|
+
relation_count: klass.declared_relations.count
|
|
65
65
|
}
|
|
66
66
|
end
|
|
67
67
|
|
data/lib/archsight/query/ast.rb
CHANGED
|
@@ -218,7 +218,8 @@ module Archsight::Query::AST
|
|
|
218
218
|
attr_reader :kind_name
|
|
219
219
|
|
|
220
220
|
def initialize(kind_name)
|
|
221
|
-
|
|
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
|
|
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
|
|
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 ``, 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
|