archsight 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +186 -0
- data/Dockerfile +39 -0
- data/LICENSE.txt +201 -0
- data/README.md +170 -0
- data/SECURITY.md +27 -0
- data/exe/archsight +9 -0
- data/lib/archsight/annotations/aggregators.rb +109 -0
- data/lib/archsight/annotations/annotation.rb +168 -0
- data/lib/archsight/annotations/architecture_annotations.rb +59 -0
- data/lib/archsight/annotations/backup_annotations.rb +21 -0
- data/lib/archsight/annotations/computed.rb +264 -0
- data/lib/archsight/annotations/email_recipient.rb +35 -0
- data/lib/archsight/annotations/generated_annotations.rb +17 -0
- data/lib/archsight/annotations/git_annotations.rb +21 -0
- data/lib/archsight/annotations/relation_resolver.rb +160 -0
- data/lib/archsight/cli.rb +120 -0
- data/lib/archsight/configuration.rb +36 -0
- data/lib/archsight/database.rb +183 -0
- data/lib/archsight/documentation.rb +171 -0
- data/lib/archsight/graph.rb +113 -0
- data/lib/archsight/helpers.rb +210 -0
- data/lib/archsight/linter.rb +77 -0
- data/lib/archsight/mcp/analyze_resource_tool.rb +222 -0
- data/lib/archsight/mcp/base.rb +48 -0
- data/lib/archsight/mcp/query_tool.rb +113 -0
- data/lib/archsight/mcp/resource_doc_tool.rb +87 -0
- data/lib/archsight/mcp.rb +6 -0
- data/lib/archsight/query/ast.rb +279 -0
- data/lib/archsight/query/errors.rb +39 -0
- data/lib/archsight/query/evaluator.rb +707 -0
- data/lib/archsight/query/lexer.rb +289 -0
- data/lib/archsight/query/parser.rb +506 -0
- data/lib/archsight/query.rb +68 -0
- data/lib/archsight/renderer.rb +134 -0
- data/lib/archsight/resources/application_component.rb +346 -0
- data/lib/archsight/resources/application_interface.rb +54 -0
- data/lib/archsight/resources/application_service.rb +222 -0
- data/lib/archsight/resources/base.rb +300 -0
- data/lib/archsight/resources/business_actor.rb +195 -0
- data/lib/archsight/resources/business_constraint.rb +32 -0
- data/lib/archsight/resources/business_process.rb +37 -0
- data/lib/archsight/resources/business_product.rb +206 -0
- data/lib/archsight/resources/business_requirement.rb +56 -0
- data/lib/archsight/resources/compliance_evidence.rb +42 -0
- data/lib/archsight/resources/data_object.rb +49 -0
- data/lib/archsight/resources/motivation_goal.rb +37 -0
- data/lib/archsight/resources/motivation_outcome.rb +33 -0
- data/lib/archsight/resources/motivation_stakeholder.rb +38 -0
- data/lib/archsight/resources/strategy_capability.rb +38 -0
- data/lib/archsight/resources/technology_artifact.rb +154 -0
- data/lib/archsight/resources/technology_interface.rb +34 -0
- data/lib/archsight/resources/technology_node.rb +42 -0
- data/lib/archsight/resources/technology_service.rb +35 -0
- data/lib/archsight/resources/technology_system_software.rb +37 -0
- data/lib/archsight/resources/view.rb +51 -0
- data/lib/archsight/resources.rb +49 -0
- data/lib/archsight/template.rb +49 -0
- data/lib/archsight/version.rb +5 -0
- data/lib/archsight/web/application.rb +290 -0
- data/lib/archsight/web/doc/archimate.md +215 -0
- data/lib/archsight/web/doc/computed_annotations.md +316 -0
- data/lib/archsight/web/doc/icons.md +303 -0
- data/lib/archsight/web/doc/index.md.erb +74 -0
- data/lib/archsight/web/doc/modeling.md +200 -0
- data/lib/archsight/web/doc/search.md +227 -0
- data/lib/archsight/web/doc/togaf.md +255 -0
- data/lib/archsight/web/doc/tool.md +90 -0
- data/lib/archsight/web/public/css/artifact.css +985 -0
- data/lib/archsight/web/public/css/base.css +201 -0
- data/lib/archsight/web/public/css/graph.css +106 -0
- data/lib/archsight/web/public/css/highlight.min.css +10 -0
- data/lib/archsight/web/public/css/iconoir.css +22 -0
- data/lib/archsight/web/public/css/instance.css +329 -0
- data/lib/archsight/web/public/css/layout.css +421 -0
- data/lib/archsight/web/public/css/mermaid-layers.css +188 -0
- data/lib/archsight/web/public/css/pico.min.css +4 -0
- data/lib/archsight/web/public/favicon.ico +0 -0
- data/lib/archsight/web/public/img/archimate.png +0 -0
- data/lib/archsight/web/public/img/togaf-high-level.png +0 -0
- data/lib/archsight/web/public/js/graph-zoom.js +18 -0
- data/lib/archsight/web/public/js/highlight.min.js +3899 -0
- data/lib/archsight/web/public/js/htmx.min.js +1 -0
- data/lib/archsight/web/public/js/mermaid-init.js +88 -0
- data/lib/archsight/web/public/js/mermaid.min.js +2811 -0
- data/lib/archsight/web/public/js/sparkline.js +42 -0
- data/lib/archsight/web/public/js/svg-pan-zoom.min.js +3 -0
- data/lib/archsight/web/public/js/svg-zoom-controls.js +93 -0
- data/lib/archsight/web/views/index.haml +12 -0
- data/lib/archsight/web/views/partials/artifact/_activity.haml +55 -0
- data/lib/archsight/web/views/partials/artifact/_agentic.haml +25 -0
- data/lib/archsight/web/views/partials/artifact/_deployment.haml +29 -0
- data/lib/archsight/web/views/partials/artifact/_git_info.haml +16 -0
- data/lib/archsight/web/views/partials/artifact/_language_stats.haml +53 -0
- data/lib/archsight/web/views/partials/artifact/_links.haml +24 -0
- data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +26 -0
- data/lib/archsight/web/views/partials/artifact/_repositories.haml +55 -0
- data/lib/archsight/web/views/partials/artifact/_team.haml +83 -0
- data/lib/archsight/web/views/partials/artifact/_workflow.haml +69 -0
- data/lib/archsight/web/views/partials/components/_activity.haml +37 -0
- data/lib/archsight/web/views/partials/components/_git.haml +17 -0
- data/lib/archsight/web/views/partials/components/_jira.haml +18 -0
- data/lib/archsight/web/views/partials/components/_languages.haml +29 -0
- data/lib/archsight/web/views/partials/components/_owner.haml +15 -0
- data/lib/archsight/web/views/partials/components/_repositories.haml +37 -0
- data/lib/archsight/web/views/partials/components/_status.haml +23 -0
- data/lib/archsight/web/views/partials/instance/_detail.haml +99 -0
- data/lib/archsight/web/views/partials/instance/_graph.haml +6 -0
- data/lib/archsight/web/views/partials/instance/_list.haml +84 -0
- data/lib/archsight/web/views/partials/instance/_relations.haml +43 -0
- data/lib/archsight/web/views/partials/instance/_requirements.haml +41 -0
- data/lib/archsight/web/views/partials/instance/_view_detail.haml +57 -0
- data/lib/archsight/web/views/partials/layout/_content.haml +40 -0
- data/lib/archsight/web/views/partials/layout/_error.haml +22 -0
- data/lib/archsight/web/views/partials/layout/_head.haml +24 -0
- data/lib/archsight/web/views/partials/layout/_navigation.haml +20 -0
- data/lib/archsight/web/views/partials/layout/_sidebar.haml +27 -0
- data/lib/archsight/web/views/search.haml +53 -0
- data/lib/archsight.rb +17 -0
- metadata +311 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ComputedRelationResolver provides methods for traversing resource relations.
|
|
4
|
+
# It mirrors the relation traversal operators in the query language:
|
|
5
|
+
# - outgoing (->): Direct outgoing relations
|
|
6
|
+
# - outgoing_transitive (~>): Transitive outgoing relations
|
|
7
|
+
# - incoming (<-): Direct incoming relations
|
|
8
|
+
# - incoming_transitive (<~): Transitive incoming relations
|
|
9
|
+
#
|
|
10
|
+
# Filter parameter can be:
|
|
11
|
+
# - Symbol: Simple kind filter (e.g., :TechnologyArtifact)
|
|
12
|
+
# - String: Query selector (e.g., 'TechnologyArtifact: activity/status == "active"')
|
|
13
|
+
class Archsight::Annotations::ComputedRelationResolver
|
|
14
|
+
MAX_DEPTH = 10
|
|
15
|
+
|
|
16
|
+
def initialize(instance, database)
|
|
17
|
+
@instance = instance
|
|
18
|
+
@database = database
|
|
19
|
+
@query_cache = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get direct outgoing relations (-> Kind)
|
|
23
|
+
# @param filter [Symbol, String, nil] Optional kind filter (Symbol) or query selector (String)
|
|
24
|
+
# @return [Array] Array of related instances
|
|
25
|
+
def outgoing(filter = nil)
|
|
26
|
+
results = []
|
|
27
|
+
|
|
28
|
+
@instance.class.relations.each do |_verb, kind_name, _klass_name|
|
|
29
|
+
rels = @instance.relations(_verb, kind_name)
|
|
30
|
+
rels.each do |rel|
|
|
31
|
+
results << rel if matches_filter?(rel, filter)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
results.uniq
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get transitive outgoing relations (~> Kind)
|
|
39
|
+
# Follows all relation chains up to max_depth
|
|
40
|
+
# @param filter [Symbol, String, nil] Optional kind filter (Symbol) or query selector (String)
|
|
41
|
+
# @param max_depth [Integer] Maximum traversal depth (default 10)
|
|
42
|
+
# @return [Array] Array of transitively related instances
|
|
43
|
+
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
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get direct incoming relations (<- Kind)
|
|
52
|
+
# Uses the references array maintained during relation resolution
|
|
53
|
+
# @param filter [Symbol, String, nil] Optional kind filter (Symbol) or query selector (String)
|
|
54
|
+
# @return [Array] Array of instances that reference this one
|
|
55
|
+
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
|
|
59
|
+
|
|
60
|
+
if filter.nil?
|
|
61
|
+
instances
|
|
62
|
+
else
|
|
63
|
+
instances.select { |ref| matches_filter?(ref, filter) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get transitive incoming relations (<~ Kind)
|
|
68
|
+
# Follows all reverse relation chains up to max_depth
|
|
69
|
+
# @param filter [Symbol, String, nil] Optional kind filter (Symbol) or query selector (String)
|
|
70
|
+
# @param max_depth [Integer] Maximum traversal depth (default 10)
|
|
71
|
+
# @return [Array] Array of instances that transitively reference this one
|
|
72
|
+
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
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Check if an instance matches the given filter
|
|
83
|
+
# @param instance [Object] The instance to check
|
|
84
|
+
# @param filter [Symbol, String, nil] Kind filter or query selector
|
|
85
|
+
# @return [Boolean] true if instance matches
|
|
86
|
+
def matches_filter?(instance, filter)
|
|
87
|
+
return true if filter.nil?
|
|
88
|
+
|
|
89
|
+
instance_kind = instance.class.name.split("::").last
|
|
90
|
+
|
|
91
|
+
if filter.is_a?(Symbol)
|
|
92
|
+
# Simple kind check
|
|
93
|
+
instance_kind == filter.to_s
|
|
94
|
+
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)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Archsight
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
class_option :resources,
|
|
8
|
+
aliases: "-r",
|
|
9
|
+
type: :string,
|
|
10
|
+
desc: "Path to resources directory (default: ARCHSIGHT_RESOURCES_DIR or current directory)"
|
|
11
|
+
|
|
12
|
+
desc "web", "Start the web server"
|
|
13
|
+
option :port, aliases: "-p", type: :numeric, default: 4567, desc: "Port to listen on"
|
|
14
|
+
option :host, aliases: "-H", type: :string, default: "localhost", desc: "Host to bind to"
|
|
15
|
+
def web
|
|
16
|
+
configure_resources
|
|
17
|
+
require "archsight/web/application"
|
|
18
|
+
Archsight::Web::Application.run!(port: options[:port], bind: options[:host])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "lint", "Validate architecture resources"
|
|
22
|
+
def lint
|
|
23
|
+
configure_resources
|
|
24
|
+
require "archsight/database"
|
|
25
|
+
require "archsight/linter"
|
|
26
|
+
require "archsight/helpers"
|
|
27
|
+
|
|
28
|
+
db = Archsight::Database.new(Archsight.resources_dir, compute_annotations: false, verbose: true)
|
|
29
|
+
begin
|
|
30
|
+
db.reload!
|
|
31
|
+
rescue Archsight::ResourceError => e
|
|
32
|
+
display_error_with_context(e.to_s)
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
linter = Archsight::Linter.new(db)
|
|
37
|
+
errors = linter.validate
|
|
38
|
+
|
|
39
|
+
if errors.any?
|
|
40
|
+
puts "Validation Errors (#{errors.count}):"
|
|
41
|
+
errors.each { |error| display_error_with_context(error) }
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
puts "All validations passed!"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "template [KIND]", "Generate a YAML template for a resource kind"
|
|
49
|
+
def template(kind = nil)
|
|
50
|
+
require "archsight/template"
|
|
51
|
+
require "archsight/resources"
|
|
52
|
+
|
|
53
|
+
if kind.nil?
|
|
54
|
+
list_kinds
|
|
55
|
+
else
|
|
56
|
+
puts Archsight::Template.generate(kind)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
desc "console", "Start an interactive console"
|
|
61
|
+
def console
|
|
62
|
+
configure_resources
|
|
63
|
+
require "archsight/database"
|
|
64
|
+
require "irb"
|
|
65
|
+
|
|
66
|
+
db = Archsight::Database.new(Archsight.resources_dir, verbose: true)
|
|
67
|
+
db.reload!
|
|
68
|
+
|
|
69
|
+
puts "Database loaded. Available: db"
|
|
70
|
+
binding.irb
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc "version", "Show version"
|
|
74
|
+
def version
|
|
75
|
+
puts "archsight #{Archsight::VERSION}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
default_task :version
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def configure_resources
|
|
83
|
+
Archsight.resources_dir = options[:resources] if options[:resources]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def list_kinds
|
|
87
|
+
puts "Available resource kinds:\n\n"
|
|
88
|
+
Archsight::Resources.resource_classes.each_key { |kind| puts " - #{kind}" }
|
|
89
|
+
puts "\nUsage: archsight template <kind>"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def display_error_with_context(error_string)
|
|
93
|
+
# Parse error to extract file path and line number
|
|
94
|
+
if error_string =~ /^(.+?):(\d+):/
|
|
95
|
+
file_path = ::Regexp.last_match(1)
|
|
96
|
+
line_number = ::Regexp.last_match(2).to_i
|
|
97
|
+
puts "\n#{error_string}"
|
|
98
|
+
show_file_context(file_path, line_number)
|
|
99
|
+
else
|
|
100
|
+
puts error_string
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def show_file_context(file_path, line_number, context_lines: 3)
|
|
105
|
+
return unless File.exist?(file_path)
|
|
106
|
+
|
|
107
|
+
lines = File.readlines(file_path)
|
|
108
|
+
start_line = [line_number - context_lines - 1, 0].max
|
|
109
|
+
end_line = [line_number + context_lines - 1, lines.length - 1].min
|
|
110
|
+
|
|
111
|
+
puts ""
|
|
112
|
+
(start_line..end_line).each do |i|
|
|
113
|
+
line_num = i + 1
|
|
114
|
+
prefix = line_num == line_number ? ">> " : " "
|
|
115
|
+
puts format("%s%4d | %s", prefix, line_num, lines[i])
|
|
116
|
+
end
|
|
117
|
+
puts ""
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archsight
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :resources_dir, :verbose, :verify, :compute_annotations
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@resources_dir = ENV["ARCHSIGHT_RESOURCES_DIR"] || Dir.pwd
|
|
9
|
+
@verbose = true
|
|
10
|
+
@verify = true
|
|
11
|
+
@compute_annotations = true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def configuration
|
|
17
|
+
@configuration ||= Configuration.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def configure
|
|
21
|
+
yield(configuration)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resources_dir
|
|
25
|
+
configuration.resources_dir
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resources_dir=(path)
|
|
29
|
+
configuration.resources_dir = File.absolute_path(path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset_configuration!
|
|
33
|
+
@configuration = Configuration.new
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "graph"
|
|
5
|
+
require_relative "resources"
|
|
6
|
+
require_relative "query"
|
|
7
|
+
|
|
8
|
+
module Archsight
|
|
9
|
+
# LineReference combines a path and line reference
|
|
10
|
+
class LineReference
|
|
11
|
+
attr_accessor :path, :line_no
|
|
12
|
+
|
|
13
|
+
def initialize(path, line_no)
|
|
14
|
+
@path = path
|
|
15
|
+
@line_no = line_no
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
"#{@path}:#{@line_no}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def at_line(line_no)
|
|
23
|
+
self.class.new(@path, line_no)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ResourceError is an error with a path and line no attached
|
|
28
|
+
class ResourceError < StandardError
|
|
29
|
+
attr_reader :ref, :message
|
|
30
|
+
|
|
31
|
+
def initialize(msg, ref)
|
|
32
|
+
super(msg)
|
|
33
|
+
@message = msg
|
|
34
|
+
@ref = ref
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_s
|
|
38
|
+
"#{ref}: #{super}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Database loads yaml files and folders to create an in-memory representation
|
|
43
|
+
# of the structure. The loading and parsing of files will raise errors
|
|
44
|
+
# if invalid data is passed.
|
|
45
|
+
class Database
|
|
46
|
+
attr_accessor :instances, :verbose, :verify, :compute_annotations
|
|
47
|
+
|
|
48
|
+
def initialize(path, verbose: false, verify: true, compute_annotations: true)
|
|
49
|
+
@path = path
|
|
50
|
+
@verbose = verbose
|
|
51
|
+
@verify = verify
|
|
52
|
+
@compute_annotations = compute_annotations
|
|
53
|
+
@instances = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reload!
|
|
57
|
+
@instances = {}
|
|
58
|
+
|
|
59
|
+
# load all resources
|
|
60
|
+
Dir.glob(File.join(@path, "**/*.yaml")).each do |path|
|
|
61
|
+
@current_ref = LineReference.new(path, 0)
|
|
62
|
+
puts "parsing #{path}..." if @verbose
|
|
63
|
+
load_file(path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
verify! if @verify
|
|
67
|
+
compute_all_annotations! if @verify && @compute_annotations
|
|
68
|
+
rescue Psych::SyntaxError => e
|
|
69
|
+
# Wrap YAML syntax errors in ResourceError for consistent handling
|
|
70
|
+
ref = LineReference.new(e.file || @current_ref&.path || "unknown", e.line || 0)
|
|
71
|
+
Kernel.raise(ResourceError.new(e.problem || e.message, ref))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def instances_by_kind(kind)
|
|
75
|
+
@instances[Archsight::Resources[kind]] || {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def instance_by_kind(kind, instance)
|
|
79
|
+
@instances[Archsight::Resources[kind]][instance]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Collect unique annotation values across all instances of a kind
|
|
83
|
+
def annotation_values(kind, annotation)
|
|
84
|
+
instances = instances_by_kind(kind).values
|
|
85
|
+
values = instances.flat_map { |inst| Array(annotation.value_for(inst)) }
|
|
86
|
+
values.compact.uniq.sort
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get filterable annotations with their values for a kind (excludes empty)
|
|
90
|
+
def filters_for_kind(kind)
|
|
91
|
+
klass = Archsight::Resources[kind]
|
|
92
|
+
return [] unless klass
|
|
93
|
+
|
|
94
|
+
klass.filterable_annotations
|
|
95
|
+
.map { |a| [a, annotation_values(kind, a)] }
|
|
96
|
+
.reject { |_, values| values.empty? }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Execute a query string and return matching instances
|
|
100
|
+
def query(query_string)
|
|
101
|
+
q = Archsight::Query.parse(query_string)
|
|
102
|
+
q.filter(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check if a specific instance matches a query
|
|
106
|
+
def instance_matches?(instance, query_string)
|
|
107
|
+
q = Archsight::Query.parse(query_string)
|
|
108
|
+
q.matches?(instance, database: self)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def create_valid_instance(obj)
|
|
114
|
+
raise("invalid api version") if obj["apiVersion"] != "architecture/v1alpha1"
|
|
115
|
+
|
|
116
|
+
kind = obj["kind"] || raise("kind not defined")
|
|
117
|
+
klass = Archsight::Resources[kind] || raise("#{kind} is not a valid kind")
|
|
118
|
+
inst = klass.new(obj, @current_ref)
|
|
119
|
+
inst.name || raise("metadata name of #{kind} not present")
|
|
120
|
+
inst
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def load_file(path)
|
|
124
|
+
File.open(path, "r") do |f|
|
|
125
|
+
YAML.parse_stream(f) do |node|
|
|
126
|
+
@current_ref = @current_ref.at_line(node.children.first.start_line)
|
|
127
|
+
obj = node.to_ruby
|
|
128
|
+
next unless obj # skip empty / unknown documents
|
|
129
|
+
|
|
130
|
+
self << create_valid_instance(obj)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def <<(inst)
|
|
136
|
+
inst.verify!
|
|
137
|
+
klass = inst.class
|
|
138
|
+
@instances[klass] ||= {}
|
|
139
|
+
if (existing_inst = @instances[klass][inst.name])
|
|
140
|
+
existing_inst.merge!(inst)
|
|
141
|
+
else
|
|
142
|
+
@instances[klass][inst.name] = inst
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# raise provides a helper for better error messages including current path and line no
|
|
147
|
+
def raise(msg)
|
|
148
|
+
Kernel.raise(ResourceError.new(msg, @current_ref))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# raise_for provides error messages with the instance's path reference
|
|
152
|
+
def raise_for(inst, msg)
|
|
153
|
+
Kernel.raise(ResourceError.new(msg, inst.path_ref))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# verify and resolve relations between resources
|
|
157
|
+
def verify!
|
|
158
|
+
@instances.each_value do |instances|
|
|
159
|
+
instances.each_value do |inst|
|
|
160
|
+
verify_instance_relations!(inst)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def verify_instance_relations!(inst)
|
|
166
|
+
inst.class.relations.each do |verb, kind, klass_name|
|
|
167
|
+
rels = inst.relations(verb, kind).map do |rel_name|
|
|
168
|
+
rel_klass = Archsight::Resources[klass_name] || raise_for(inst, "#{klass_name} is not a valid relation kind")
|
|
169
|
+
kind_display = rel_klass.to_s.sub(/^Archsight::Resources::/, "")
|
|
170
|
+
@instances[rel_klass] || raise_for(inst, "#{rel_name} is not defined as kind #{kind_display}")
|
|
171
|
+
@instances[rel_klass][rel_name] || raise_for(inst, "#{rel_name} is not defined as kind #{kind_display}")
|
|
172
|
+
end
|
|
173
|
+
inst.set_relations(verb, kind, rels) unless rels.empty?
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Compute all computed annotations for all instances
|
|
178
|
+
def compute_all_annotations!
|
|
179
|
+
manager = Archsight::Annotations::ComputedManager.new(self)
|
|
180
|
+
manager.compute_all!
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resources"
|
|
4
|
+
require_relative "template"
|
|
5
|
+
|
|
6
|
+
module Archsight
|
|
7
|
+
# Documentation generates markdown documentation for architecture resources
|
|
8
|
+
class Documentation
|
|
9
|
+
# Layer display order (top to bottom)
|
|
10
|
+
LAYER_ORDER = %w[motivation strategy business application technology].freeze
|
|
11
|
+
|
|
12
|
+
# Layer display names
|
|
13
|
+
LAYER_NAMES = {
|
|
14
|
+
"motivation" => "Motivation Layer",
|
|
15
|
+
"strategy" => "Strategy Layer",
|
|
16
|
+
"business" => "Business Layer",
|
|
17
|
+
"application" => "Application Layer",
|
|
18
|
+
"technology" => "Technology Layer"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Resource kinds to exclude from the diagram
|
|
22
|
+
EXCLUDED_KINDS = %w[View].freeze
|
|
23
|
+
|
|
24
|
+
@mermaid_cache = nil
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_accessor :mermaid_cache
|
|
28
|
+
|
|
29
|
+
# Generate mermaid flowchart diagram showing all resource types and relationships
|
|
30
|
+
# Results are cached for performance
|
|
31
|
+
# @return [String] Mermaid flowchart diagram
|
|
32
|
+
def generate_mermaid_diagram
|
|
33
|
+
return @mermaid_cache if @mermaid_cache
|
|
34
|
+
|
|
35
|
+
@mermaid_cache = build_mermaid_diagram
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clear the mermaid cache (call when resources change)
|
|
39
|
+
def clear_cache
|
|
40
|
+
@mermaid_cache = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build the mermaid diagram from resource definitions
|
|
44
|
+
# Layer colors are defined in CSS (public/css/mermaid-layers.css)
|
|
45
|
+
def build_mermaid_diagram
|
|
46
|
+
lines = []
|
|
47
|
+
lines << "flowchart TB"
|
|
48
|
+
|
|
49
|
+
# Group resources by layer (excluding certain kinds)
|
|
50
|
+
resources_by_layer = Hash.new { |h, k| h[k] = [] }
|
|
51
|
+
Archsight::Resources.each do |kind_name|
|
|
52
|
+
next if EXCLUDED_KINDS.include?(kind_name.to_s)
|
|
53
|
+
|
|
54
|
+
klass = Archsight::Resources.const_get(kind_name)
|
|
55
|
+
layer = klass.layer
|
|
56
|
+
next unless LAYER_ORDER.include?(layer) # Skip resources in unlisted layers
|
|
57
|
+
|
|
58
|
+
resources_by_layer[layer] << kind_name.to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generate subgraphs for each layer in order
|
|
62
|
+
LAYER_ORDER.each do |layer|
|
|
63
|
+
next unless resources_by_layer.key?(layer)
|
|
64
|
+
|
|
65
|
+
kinds = resources_by_layer[layer].sort
|
|
66
|
+
next if kinds.empty?
|
|
67
|
+
|
|
68
|
+
lines << ""
|
|
69
|
+
lines << " subgraph #{layer.capitalize}[\"#{LAYER_NAMES[layer]}\"]"
|
|
70
|
+
kinds.each do |kind|
|
|
71
|
+
lines << " #{kind}:::#{layer}"
|
|
72
|
+
end
|
|
73
|
+
lines << " end"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Collect all relations and deduplicate
|
|
77
|
+
relations = collect_relations
|
|
78
|
+
lines << ""
|
|
79
|
+
relations.each do |from_kind, verb, to_kind|
|
|
80
|
+
lines << " #{from_kind} -->|#{verb}| #{to_kind}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Add click handlers for each kind
|
|
84
|
+
lines << ""
|
|
85
|
+
all_kinds = resources_by_layer.values.flatten
|
|
86
|
+
all_kinds.sort.each do |kind_name|
|
|
87
|
+
lines << " click #{kind_name} \"/kinds/#{kind_name}\""
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Collect all unique relations from resource definitions
|
|
94
|
+
# @return [Array<Array>] Array of [from_kind, verb, to_kind] tuples
|
|
95
|
+
def collect_relations
|
|
96
|
+
relations = []
|
|
97
|
+
seen = Set.new
|
|
98
|
+
|
|
99
|
+
Archsight::Resources.each do |kind_name|
|
|
100
|
+
# Skip excluded kinds
|
|
101
|
+
next if EXCLUDED_KINDS.include?(kind_name.to_s)
|
|
102
|
+
|
|
103
|
+
klass = Archsight::Resources.const_get(kind_name)
|
|
104
|
+
# Skip kinds not in a displayed layer
|
|
105
|
+
next unless LAYER_ORDER.include?(klass.layer)
|
|
106
|
+
|
|
107
|
+
klass.relations.each do |verb, _relation_kind, target_klass|
|
|
108
|
+
# Skip relations to excluded kinds
|
|
109
|
+
next if EXCLUDED_KINDS.include?(target_klass.to_s)
|
|
110
|
+
|
|
111
|
+
key = "#{kind_name}|#{verb}|#{target_klass}"
|
|
112
|
+
next if seen.include?(key)
|
|
113
|
+
|
|
114
|
+
seen.add(key)
|
|
115
|
+
relations << [kind_name.to_s, verb.to_s.delete_prefix(":"), target_klass.to_s]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
relations.sort_by { |from, verb, to| [from, to, verb] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def generate(kind_name)
|
|
123
|
+
klass = Archsight::Resources[kind_name.to_s]
|
|
124
|
+
raise "Unknown resource kind '#{kind_name}'" unless klass
|
|
125
|
+
|
|
126
|
+
md = []
|
|
127
|
+
md << "# #{kind_name}\n"
|
|
128
|
+
md << klass.description if klass.description
|
|
129
|
+
md << "\n## Annotations\n"
|
|
130
|
+
md << generate_annotations_table(klass)
|
|
131
|
+
md << "\n## Relations\n"
|
|
132
|
+
md << generate_relations_table(klass)
|
|
133
|
+
md << "\n## Example\n"
|
|
134
|
+
md << "```yaml\n#{Archsight::Template.generate(kind_name)}```"
|
|
135
|
+
md.compact.join("\n")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def generate_annotations_table(klass)
|
|
139
|
+
annotations = klass.annotations.reject(&:pattern?)
|
|
140
|
+
return "_No annotations defined._" if annotations.empty?
|
|
141
|
+
|
|
142
|
+
rows = ["| Annotation | Description | Values |", "|------------|-------------|--------|"]
|
|
143
|
+
annotations.each do |a|
|
|
144
|
+
values = format_values(a)
|
|
145
|
+
rows << "| `#{a.key}` | #{a.description || "-"} | #{values} |"
|
|
146
|
+
end
|
|
147
|
+
rows.join("\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def generate_relations_table(klass)
|
|
151
|
+
return "_No relations defined._" if klass.relations.empty?
|
|
152
|
+
|
|
153
|
+
rows = ["| Relation | Target | Kind |", "|----------|--------|------|"]
|
|
154
|
+
klass.relations.each do |verb, kind, target_klass|
|
|
155
|
+
rows << "| #{verb} | #{target_klass} | #{kind} |"
|
|
156
|
+
end
|
|
157
|
+
rows.join("\n")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def format_values(annotation)
|
|
161
|
+
if annotation.enum
|
|
162
|
+
annotation.enum.join(", ")
|
|
163
|
+
elsif annotation.type
|
|
164
|
+
annotation.type.to_s.split("::").last
|
|
165
|
+
else
|
|
166
|
+
"-"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|