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
@@ -11,12 +11,101 @@
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
- def initialize(instance, database)
27
+ # TraversalCache holds what can be shared between all resolvers of one computation run: the unfiltered
28
+ # transitive neighbourhood of an instance (relations are fixed once the database is verified), parsed filter
29
+ # queries, one query evaluator and the short kind names. Filter results are never cached because they may
30
+ # depend on computed annotations that are set while the run progresses.
31
+ class TraversalCache
32
+ def initialize(database)
33
+ @database = database
34
+ @reach = {}
35
+ @queries = {}
36
+ @kind_names = {}.compare_by_identity
37
+ end
38
+
39
+ # Short kind name of a resource class ("ApplicationComponent")
40
+ def kind_name(klass)
41
+ @kind_names[klass] ||= klass.name.split("::").last
42
+ end
43
+
44
+ # Parsed query for a selector string
45
+ def query(selector)
46
+ @queries[selector] ||= begin
47
+ require_relative "../query/lexer"
48
+ require_relative "../query/parser"
49
+ Archsight::Query::Parser.new(Archsight::Query::Lexer.new(selector).tokenize).parse
50
+ end
51
+ end
52
+
53
+ def evaluator
54
+ @evaluator ||= begin
55
+ require_relative "../query/evaluator"
56
+ Archsight::Query::Evaluator.new(@database)
57
+ end
58
+ end
59
+
60
+ # All instances reachable from `start` within max_depth hops (direction :outgoing or :incoming),
61
+ # each once, in breadth-first order. `start` itself is included when a cycle leads back to it.
62
+ def reachable(start, direction, max_depth)
63
+ by_instance = (@reach[[direction, max_depth]] ||= {}.compare_by_identity)
64
+ by_instance[start] ||= walk(start, direction, max_depth)
65
+ end
66
+
67
+ private
68
+
69
+ def walk(start, direction, max_depth)
70
+ results = []
71
+ listed = {}.compare_by_identity
72
+ expanded = { start => true }.compare_by_identity
73
+ frontier = [start]
74
+ depth = 0
75
+ while depth < max_depth && !frontier.empty?
76
+ following = []
77
+ frontier.each do |node|
78
+ neighbours(node, direction).each do |other|
79
+ unless listed.key?(other)
80
+ listed[other] = true
81
+ results << other
82
+ end
83
+ next if expanded.key?(other)
84
+
85
+ expanded[other] = true
86
+ following << other
87
+ end
88
+ end
89
+ frontier = following
90
+ depth += 1
91
+ end
92
+ results
93
+ end
94
+
95
+ def neighbours(inst, direction)
96
+ if direction == :outgoing
97
+ inst.class.declared_relations.flat_map { |verb, kind_name, _klass_name| inst.relations(verb, kind_name) }
98
+ else
99
+ Archsight::Annotations::ComputedRelationResolver.modelled_references(inst)
100
+ end
101
+ end
102
+ end
103
+
104
+ # @param cache [TraversalCache, nil] shared per computation run; a private one is created when omitted
105
+ def initialize(instance, database, cache = nil)
17
106
  @instance = instance
18
107
  @database = database
19
- @query_cache = {}
108
+ @cache = cache || TraversalCache.new(database)
20
109
  end
21
110
 
22
111
  # Get direct outgoing relations (-> Kind)
@@ -25,7 +114,7 @@ class Archsight::Annotations::ComputedRelationResolver
25
114
  def outgoing(filter = nil)
26
115
  results = []
27
116
 
28
- @instance.class.relations.each do |_verb, kind_name, _klass_name|
117
+ @instance.class.declared_relations.each do |_verb, kind_name, _klass_name|
29
118
  rels = @instance.relations(_verb, kind_name)
30
119
  rels.each do |rel|
31
120
  results << rel if matches_filter?(rel, filter)
@@ -41,11 +130,7 @@ class Archsight::Annotations::ComputedRelationResolver
41
130
  # @param max_depth [Integer] Maximum traversal depth (default 10)
42
131
  # @return [Array] Array of transitively related instances
43
132
  def outgoing_transitive(filter = nil, max_depth: MAX_DEPTH)
44
- visited = Set.new
45
- results = []
46
-
47
- collect_transitive_outgoing(@instance, filter, visited, 0, max_depth, results)
48
- results.uniq
133
+ filtered(@cache.reachable(@instance, :outgoing, max_depth), filter)
49
134
  end
50
135
 
51
136
  # Get direct incoming relations (<- Kind)
@@ -53,9 +138,7 @@ class Archsight::Annotations::ComputedRelationResolver
53
138
  # @param filter [Symbol, String, nil] Optional kind filter (Symbol) or query selector (String)
54
139
  # @return [Array] Array of instances that reference this one
55
140
  def incoming(filter = nil)
56
- refs = @instance.references || []
57
- # Extract instances from reference hashes
58
- instances = refs.map { |ref| ref.is_a?(Hash) ? ref[:instance] : ref }.compact
141
+ instances = self.class.modelled_references(@instance).compact
59
142
 
60
143
  if filter.nil?
61
144
  instances
@@ -70,15 +153,23 @@ class Archsight::Annotations::ComputedRelationResolver
70
153
  # @param max_depth [Integer] Maximum traversal depth (default 10)
71
154
  # @return [Array] Array of instances that transitively reference this one
72
155
  def incoming_transitive(filter = nil, max_depth: MAX_DEPTH)
73
- visited = Set.new
74
- results = []
75
-
76
- collect_transitive_incoming(@instance, filter, visited, 0, max_depth, results)
77
- results.uniq
156
+ filtered(@cache.reachable(@instance, :incoming, max_depth), filter)
78
157
  end
79
158
 
80
159
  private
81
160
 
161
+ def filtered(instances, filter)
162
+ return instances.dup if filter.nil?
163
+
164
+ if filter.is_a?(Symbol)
165
+ kind = filter.to_s
166
+ instances.select { |inst| @cache.kind_name(inst.class) == kind }
167
+ else
168
+ query_node = @cache.query(filter)
169
+ instances.select { |inst| @cache.evaluator.matches?(query_node, inst) }
170
+ end
171
+ end
172
+
82
173
  # Check if an instance matches the given filter
83
174
  # @param instance [Object] The instance to check
84
175
  # @param filter [Symbol, String, nil] Kind filter or query selector
@@ -86,75 +177,10 @@ class Archsight::Annotations::ComputedRelationResolver
86
177
  def matches_filter?(instance, filter)
87
178
  return true if filter.nil?
88
179
 
89
- instance_kind = instance.class.name.split("::").last
90
-
91
180
  if filter.is_a?(Symbol)
92
- # Simple kind check
93
- instance_kind == filter.to_s
181
+ @cache.kind_name(instance.class) == filter.to_s
94
182
  else
95
- # Query selector - parse and evaluate
96
- query_node = parse_query(filter)
97
- evaluator.matches?(query_node, instance)
98
- end
99
- end
100
-
101
- # Parse a query string (with caching)
102
- def parse_query(query_string)
103
- @query_cache[query_string] ||= begin
104
- require_relative "../query/lexer"
105
- require_relative "../query/parser"
106
- tokens = Archsight::Query::Lexer.new(query_string).tokenize
107
- Archsight::Query::Parser.new(tokens).parse
108
- end
109
- end
110
-
111
- # Get or create the query evaluator
112
- def evaluator
113
- @evaluator ||= begin
114
- require_relative "../query/evaluator"
115
- Archsight::Query::Evaluator.new(@database)
116
- end
117
- end
118
-
119
- # Recursively collect transitive outgoing relations
120
- def collect_transitive_outgoing(inst, filter, visited, depth, max_depth, results)
121
- return if depth >= max_depth
122
-
123
- key = "#{inst.class}/#{inst.name}"
124
- return if visited.include?(key)
125
-
126
- visited.add(key)
127
-
128
- inst.class.relations.each do |verb, kind_name, _klass_name|
129
- rels = inst.relations(verb, kind_name)
130
- rels.each do |rel|
131
- # Add to results if matches filter (or no filter)
132
- results << rel if matches_filter?(rel, filter)
133
-
134
- # Continue traversal (regardless of whether this matched)
135
- collect_transitive_outgoing(rel, filter, visited.dup, depth + 1, max_depth, results)
136
- end
137
- end
138
- end
139
-
140
- # Recursively collect transitive incoming relations
141
- def collect_transitive_incoming(inst, filter, visited, depth, max_depth, results)
142
- return if depth >= max_depth
143
-
144
- key = "#{inst.class}/#{inst.name}"
145
- return if visited.include?(key)
146
-
147
- visited.add(key)
148
-
149
- refs = inst.references || []
150
- # Extract instances from reference hashes
151
- instances = refs.map { |ref| ref.is_a?(Hash) ? ref[:instance] : ref }.compact
152
- instances.each do |ref|
153
- # Add to results if matches filter (or no filter)
154
- results << ref if matches_filter?(ref, filter)
155
-
156
- # Continue traversal (regardless of whether this matched)
157
- collect_transitive_incoming(ref, filter, visited.dup, depth + 1, max_depth, results)
183
+ @cache.evaluator.matches?(@cache.query(filter), instance)
158
184
  end
159
185
  end
160
186
  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) }
@@ -282,7 +288,7 @@ module Archsight
282
288
  desc "export [PAGE...]", "Export wiki pages to another system"
283
289
  long_desc <<~DESC
284
290
  Publishes wiki pages to the system named by --to. With no PAGE, every page that links to a target
285
- page (for confluence: `confluence: <page URL>` in its frontmatter) is exported.
291
+ page (for confluence: `confluence: <page URL>` in its frontmatter) is exported. --tag TAG limits the export to pages carrying one of the given tags.
286
292
 
287
293
  confluence: a page is only overwritten when Confluence still holds what the last export wrote. Pages
288
294
  never exported before, or edited in Confluence since, are reported as blocked and not exported until
@@ -295,6 +301,7 @@ module Archsight
295
301
  option :force, type: :boolean, default: false, desc: "Overwrite pages that were edited in the target since the last export"
296
302
  option :lock, type: :boolean, default: true, desc: "Restrict editing of exported pages to the exporting user (--no-lock to skip)"
297
303
  option :dry_run, type: :boolean, default: false, desc: "Show what would be exported without writing anything"
304
+ option :tag, type: :array, default: [], desc: "Only export pages with at least one of these tags (repeatable, case-insensitive)"
298
305
  option :config, type: :string, desc: "Configuration file (default: ARCHSIGHT_CONFIG or ~/.config/archsight/archsight.yaml)"
299
306
  option :drawio, type: :boolean, desc: "The target has the draw.io app: export diagrams as draw.io macros, else as images (default: `confluence.drawio` of the configuration)"
300
307
  def export(*names)
@@ -317,7 +324,7 @@ module Archsight
317
324
  end
318
325
  exporter = Archsight::Export.exporter_for(options[:to]).new(
319
326
  database: db, resources_dir: Archsight.resources_dir, force: options[:force], lock: options[:lock],
320
- dry_run: options[:dry_run], settings: settings, drawio: options[:drawio]
327
+ dry_run: options[:dry_run], settings: settings, drawio: options[:drawio], tags: options[:tag]
321
328
  )
322
329
  results = exporter.run(names)
323
330
  print_export_results(results)
@@ -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,11 +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
- inst.name || raise("metadata name of #{kind} not present")
134
+ raise("metadata name of #{kind} not present") if inst.name.to_s.strip.empty?
135
+
123
136
  inst
124
137
  end
125
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
+
126
175
  def load_file(path)
127
176
  File.open(path, "r") do |f|
128
177
  YAML.parse_stream(f) do |node|
@@ -131,7 +180,7 @@ module Archsight
131
180
  next unless obj # skip empty / unknown documents
132
181
 
133
182
  # Skip resources that don't match only_kinds filter
134
- next if @only_kinds && !@only_kinds.include?(obj["kind"])
183
+ next unless kind_wanted?(obj["kind"])
135
184
 
136
185
  self << create_valid_instance(obj)
137
186
  end
@@ -185,7 +234,7 @@ module Archsight
185
234
  end
186
235
 
187
236
  def verify_instance_relations!(inst)
188
- inst.class.relations.each do |verb, kind, klass_name|
237
+ inst.class.declared_relations.each do |verb, kind, klass_name|
189
238
  rels = inst.relations(verb, kind).map do |rel_name|
190
239
  rel_klass = Archsight::Resources[klass_name] || raise_for(inst, "#{klass_name} is not a valid relation kind")
191
240
  kind_display = rel_klass.to_s.sub(/^Archsight::Resources::/, "")
@@ -196,6 +245,12 @@ module Archsight
196
245
  end
197
246
  end
198
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
+
199
254
  # Compute all computed annotations for all instances
200
255
  def compute_all_annotations!
201
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
 
@@ -104,7 +104,7 @@ module Archsight
104
104
  # Skip kinds not in a displayed layer
105
105
  next unless LAYER_ORDER.include?(klass.layer)
106
106
 
107
- klass.relations.each do |verb, _relation_kind, target_klass|
107
+ klass.declared_relations.each do |verb, _relation_kind, target_klass|
108
108
  # Skip relations to excluded kinds
109
109
  next if EXCLUDED_KINDS.include?(target_klass.to_s)
110
110
 
@@ -142,19 +142,23 @@ module Archsight
142
142
  rows = ["| Annotation | Description | Values |", "|------------|-------------|--------|"]
143
143
  annotations.each do |a|
144
144
  values = format_values(a)
145
- rows << "| `#{a.key}` | #{a.description || "-"} | #{values} |"
145
+ description = a.summary? ? "#{a.description || "-"} _(summary: shown with search hits)_" : (a.description || "-")
146
+ rows << "| `#{a.key}` | #{description} | #{values} |"
146
147
  end
147
148
  rows.join("\n")
148
149
  end
149
150
 
150
151
  def generate_relations_table(klass)
151
- return "_No relations defined._" if klass.relations.empty?
152
+ derived = "Every resource also has the derived relations #{Archsight::Resources::DERIVED_VERBS.map { |v| "`#{v}`" }.join(" and ")} " \
153
+ "towards any kind: they follow from links in its text and from its diagrams, so they are not written in files " \
154
+ "(see the Modeling Guide)."
155
+ return "_No relations defined._\n\n#{derived}" if klass.declared_relations.empty?
152
156
 
153
157
  rows = ["| Relation | Target | Kind |", "|----------|--------|------|"]
154
- klass.relations.each do |verb, kind, target_klass|
158
+ klass.declared_relations.each do |verb, kind, target_klass|
155
159
  rows << "| #{verb} | #{target_klass} | #{kind} |"
156
160
  end
157
- rows.join("\n")
161
+ "#{rows.join("\n")}\n\n#{derived}"
158
162
  end
159
163
 
160
164
  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
@@ -31,8 +31,9 @@ module Archsight
31
31
  # @param database [Archsight::Database]
32
32
  # @param settings [Credentials::Settings, nil] token and draw.io support; default: Credentials.load
33
33
  # @param drawio [Boolean, nil] overrides the settings' draw.io flag
34
+ # @param tags [Array<String>] only export pages that have at least one of these `page/tags` (case-insensitive)
34
35
  # @param client_factory [#call, nil] `(base_url, token) -> Client`, for tests
35
- def initialize(database:, resources_dir:, force: false, lock: true, dry_run: false, settings: nil, drawio: nil, client_factory: nil)
36
+ def initialize(database:, resources_dir:, force: false, lock: true, dry_run: false, settings: nil, drawio: nil, tags: [], client_factory: nil)
36
37
  @database = database
37
38
  @resources_dir = resources_dir
38
39
  @force = force
@@ -40,6 +41,7 @@ module Archsight
40
41
  @dry_run = dry_run
41
42
  @settings = settings
42
43
  @drawio = drawio
44
+ @tags = Array(tags).map { |t| t.to_s.strip.downcase }.reject(&:empty?)
43
45
  @client_factory = client_factory || ->(base, secret) { Client.new(base: base, token: secret) }
44
46
  @clients = {}
45
47
  end
@@ -55,17 +57,24 @@ module Archsight
55
57
  # @return [Array<Array(Page|String, Result|nil)>]
56
58
  def select(names)
57
59
  all = @database.instances_by_kind("Page")
58
- return all.values.select { |p| link(p) }.sort_by(&:name).map { |p| [p, nil] } if names.empty?
60
+ return all.values.select { |p| link(p) && tagged?(p) }.sort_by(&:name).map { |p| [p, nil] } if names.empty?
59
61
 
60
62
  names.map do |name|
61
63
  page = all[name]
62
64
  if page.nil? then [name, Result.new(page: name, status: :failed, message: "no such page")]
63
65
  elsif link(page).nil? then [page, Result.new(page: name, status: :skipped, message: "no `confluence:` link in the frontmatter")]
66
+ elsif !tagged?(page) then [page, Result.new(page: name, status: :skipped, message: "has none of the tags #{@tags.join(", ")}")]
64
67
  else [page, nil]
65
68
  end
66
69
  end
67
70
  end
68
71
 
72
+ def tagged?(page)
73
+ return true if @tags.empty?
74
+
75
+ page.annotations["page/tags"].to_s.split(",").map { |t| t.strip.downcase }.intersect?(@tags)
76
+ end
77
+
69
78
  def link(page)
70
79
  value = page.annotations["page/confluence"].to_s.strip
71
80
  value.empty? ? nil : value