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,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archsight
|
|
4
|
+
# Graphvis implements a graphviz abstraction
|
|
5
|
+
class Graphvis
|
|
6
|
+
def initialize(name, renderer = :dot, attrs = {})
|
|
7
|
+
@name = name
|
|
8
|
+
@renderer = renderer.to_s
|
|
9
|
+
@attrs = { rankdir: :LR }.merge(attrs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def draw_and_render_file(&block)
|
|
13
|
+
File.open("#{@name}.dot", "w") do |f|
|
|
14
|
+
render(f, &block)
|
|
15
|
+
end
|
|
16
|
+
system "#{@renderer} -Tpng #{@name}.dot -o #{@name}.png"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def draw_dot(&)
|
|
20
|
+
f = StringIO.new
|
|
21
|
+
render(f, &)
|
|
22
|
+
f.string
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def draw_svg(&block)
|
|
26
|
+
IO.popen([@renderer, "-Tsvg"], "r+") do |pipe|
|
|
27
|
+
render(pipe, &block)
|
|
28
|
+
pipe.close_write
|
|
29
|
+
pipe.read
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render(file)
|
|
34
|
+
@file = file
|
|
35
|
+
file.puts "digraph G {"
|
|
36
|
+
@attrs.each do |k, v|
|
|
37
|
+
@file.puts " #{k.to_s.inspect} = #{value v};"
|
|
38
|
+
end
|
|
39
|
+
yield(self)
|
|
40
|
+
file.puts "}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def node(name, attrs = {})
|
|
44
|
+
@file.print " #{name.inspect} ["
|
|
45
|
+
attrs.each do |k, v|
|
|
46
|
+
@file.print " #{k.to_s.inspect} = #{value v}"
|
|
47
|
+
end
|
|
48
|
+
@file.puts " ];"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def edge(node_a, node_b, attrs = {})
|
|
52
|
+
@file.print " #{node_a.inspect} -> #{node_b.inspect} ["
|
|
53
|
+
attrs.each do |k, v|
|
|
54
|
+
@file.print " #{k.to_s.inspect} = #{value v}"
|
|
55
|
+
end
|
|
56
|
+
@file.puts " ];"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def subgraph(name, attrs)
|
|
60
|
+
@file.puts " subgraph #{name.inspect} {"
|
|
61
|
+
attrs.each do |k, v|
|
|
62
|
+
@file.puts " #{k.to_s.inspect} = #{value v};"
|
|
63
|
+
end
|
|
64
|
+
yield(self)
|
|
65
|
+
@file.puts " }"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def value(val)
|
|
69
|
+
val = val.to_s
|
|
70
|
+
val =~ /^<.*>$/ ? "<#{val}>" : val.inspect
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def same_rank
|
|
74
|
+
@file.puts " { rank = same; "
|
|
75
|
+
yield(self)
|
|
76
|
+
@file.puts " }"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# GraphvisHelper for generating graphs using javascript and wasm
|
|
81
|
+
module GraphvisHelper
|
|
82
|
+
def graphviz_svg(dot, element_id)
|
|
83
|
+
format(%{
|
|
84
|
+
<script type="module">
|
|
85
|
+
import { Graphviz } from "https://cdn.jsdelivr.net/npm/@hpcc-js/wasm/dist/index.js";
|
|
86
|
+
if (Graphviz) {
|
|
87
|
+
const re =/<svg width="([^"]+)pt" height="([^"]+)pt"/;
|
|
88
|
+
const graphviz = await Graphviz.load();
|
|
89
|
+
const dot = %s;
|
|
90
|
+
let svg = graphviz.layout(dot, "svg", "dot");
|
|
91
|
+
svg = svg.replace(re, "<svg");
|
|
92
|
+
svg = svg.replace(/<polygon fill="white"[^>]*>/, "");
|
|
93
|
+
svg = svg.replace(/fill="white"/g, 'fill="none"');
|
|
94
|
+
|
|
95
|
+
const parser = new DOMParser();
|
|
96
|
+
const svgDoc = parser.parseFromString(svg, "image/svg+xml");
|
|
97
|
+
const svgElement = svgDoc.querySelector("svg");
|
|
98
|
+
|
|
99
|
+
const cssResponse = await fetch("/css/graph.css?" + Date.now());
|
|
100
|
+
const cssText = await cssResponse.text();
|
|
101
|
+
const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
|
|
102
|
+
style.textContent = cssText;
|
|
103
|
+
svgElement.insertBefore(style, svgElement.firstChild);
|
|
104
|
+
|
|
105
|
+
document.getElementById(%s).innerHTML = svgElement.outerHTML;
|
|
106
|
+
// Dispatch event to notify GraphViewer that SVG is ready
|
|
107
|
+
document.dispatchEvent(new CustomEvent('graphviz:ready', { detail: { elementId: %s } }));
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
110
|
+
}, dot.inspect, element_id.inspect, element_id.inspect)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archsight
|
|
4
|
+
# Helpers provides utility functions for the architecture tool
|
|
5
|
+
module Helpers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Make path relative to resources directory
|
|
9
|
+
def relative_error_path(path)
|
|
10
|
+
# Find 'resources/' in path and return from there
|
|
11
|
+
if (idx = path.index("resources/"))
|
|
12
|
+
path[idx..]
|
|
13
|
+
else
|
|
14
|
+
File.basename(path)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Extract context lines around a YAML document containing an error
|
|
19
|
+
# Returns array of hashes with :line_no, :content, :selected keys
|
|
20
|
+
def error_context_lines(path, error_line_no, context_lines: 5)
|
|
21
|
+
return [] unless File.exist?(path)
|
|
22
|
+
|
|
23
|
+
lines = File.readlines(path, chomp: true)
|
|
24
|
+
error_idx = error_line_no - 1 # Convert to 0-indexed
|
|
25
|
+
|
|
26
|
+
# Find the start of the YAML document (--- separator at or before error line)
|
|
27
|
+
doc_start = error_idx
|
|
28
|
+
doc_start -= 1 while doc_start.positive? && lines[doc_start] != "---"
|
|
29
|
+
|
|
30
|
+
# Find the end of the YAML document (next --- or end of file)
|
|
31
|
+
doc_end = error_idx + 1
|
|
32
|
+
doc_end += 1 while doc_end < lines.length && lines[doc_end] != "---"
|
|
33
|
+
doc_end -= 1 # Don't include the next ---
|
|
34
|
+
|
|
35
|
+
# Show context_lines before doc start, full document, and context_lines after doc end
|
|
36
|
+
context_start = [doc_start - context_lines, 0].max
|
|
37
|
+
context_end = [doc_end + context_lines, lines.length - 1].min
|
|
38
|
+
|
|
39
|
+
(context_start..context_end).map do |i|
|
|
40
|
+
{
|
|
41
|
+
line_no: i + 1,
|
|
42
|
+
content: lines[i],
|
|
43
|
+
selected: (i + 1) == error_line_no
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def classify(val)
|
|
49
|
+
val.to_s.split("-").map(&:capitalize).join
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def deep_merge(hash1, hash2)
|
|
53
|
+
hash1.dup.merge(hash2) do |_, old_value, new_value|
|
|
54
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
55
|
+
deep_merge(old_value, new_value)
|
|
56
|
+
elsif old_value.is_a?(Array) && new_value.is_a?(Array)
|
|
57
|
+
(old_value + new_value).uniq
|
|
58
|
+
else
|
|
59
|
+
new_value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Generate htmx attributes for a custom search query
|
|
65
|
+
def search_link_attrs(query)
|
|
66
|
+
search_url = "/search?#{URI.encode_www_form(q: query)}"
|
|
67
|
+
{
|
|
68
|
+
"href" => search_url,
|
|
69
|
+
"hx-post" => "/search",
|
|
70
|
+
"hx-vals" => { q: query }.to_json,
|
|
71
|
+
"hx-swap" => "innerHTML",
|
|
72
|
+
"hx-target" => ".content",
|
|
73
|
+
"hx-push-url" => search_url
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Generate htmx attributes for filtering by annotation (convenience wrapper)
|
|
78
|
+
def filter_link_attrs(tag, value, method = "==", kind = nil)
|
|
79
|
+
query = "#{tag} #{method} \"#{value}\""
|
|
80
|
+
query = "#{kind}: #{query}" if kind
|
|
81
|
+
search_link_attrs(query)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get icon class for a URL based on domain patterns
|
|
85
|
+
def icon_for_url(url)
|
|
86
|
+
case url
|
|
87
|
+
when %r{docs\.google\.com/(document|spreadsheets|presentation)}
|
|
88
|
+
"iconoir-google-docs"
|
|
89
|
+
when /github\.com/
|
|
90
|
+
"iconoir-github"
|
|
91
|
+
when /gitlab/
|
|
92
|
+
"iconoir-git-fork"
|
|
93
|
+
when /confluence\.|atlassian\.net/
|
|
94
|
+
"iconoir-page-edit"
|
|
95
|
+
when /jira\.|atlassian\.net.*jira/
|
|
96
|
+
"iconoir-list"
|
|
97
|
+
when /grafana/
|
|
98
|
+
"iconoir-graph-up"
|
|
99
|
+
when /prometheus/
|
|
100
|
+
"iconoir-database"
|
|
101
|
+
when /api\./
|
|
102
|
+
"iconoir-code"
|
|
103
|
+
when /docs\./
|
|
104
|
+
"iconoir-book"
|
|
105
|
+
else
|
|
106
|
+
"iconoir-internet"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Convert a GitHub git URL to a raw.githubusercontent.com base URL
|
|
111
|
+
# @param git_url [String] Git URL like "git@github.com:owner/repo.git" or "https://github.com/owner/repo"
|
|
112
|
+
# @param branch [String] Branch name, defaults to "main"
|
|
113
|
+
# @return [String, nil] Base URL for raw content, or nil if not a GitHub URL
|
|
114
|
+
def github_raw_base_url(git_url, branch: "main")
|
|
115
|
+
return nil unless git_url
|
|
116
|
+
|
|
117
|
+
# Extract owner/repo from various GitHub URL formats
|
|
118
|
+
match = git_url.match(%r{github\.com[:/]([^/]+)/([^/.]+)})
|
|
119
|
+
return nil unless match
|
|
120
|
+
|
|
121
|
+
owner = match[1]
|
|
122
|
+
repo = match[2]
|
|
123
|
+
"https://raw.githubusercontent.com/#{owner}/#{repo}/#{branch}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Resolve relative paths in HTML/Markdown content to absolute URLs
|
|
127
|
+
# @param content [String] HTML content with potential relative paths
|
|
128
|
+
# @param base_url [String] Base URL to prepend to relative paths
|
|
129
|
+
# @return [String] Content with resolved URLs
|
|
130
|
+
def resolve_relative_urls(content, base_url)
|
|
131
|
+
return content unless base_url
|
|
132
|
+
|
|
133
|
+
# Match src="./path" or src="path" (not starting with http/https/data//)
|
|
134
|
+
content.gsub(%r{(\ssrc=["'])(\./)?((?!https?:|data:|//)[^"']+)(["'])}) do
|
|
135
|
+
prefix = ::Regexp.last_match(1)
|
|
136
|
+
_dot_slash = ::Regexp.last_match(2)
|
|
137
|
+
path = ::Regexp.last_match(3)
|
|
138
|
+
suffix = ::Regexp.last_match(4)
|
|
139
|
+
"#{prefix}#{base_url}/#{path}#{suffix}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get category for a URL based on domain patterns
|
|
144
|
+
def category_for_url(url)
|
|
145
|
+
case url
|
|
146
|
+
when %r{docs\.google\.com/(document|spreadsheets|presentation)}
|
|
147
|
+
"Documentation"
|
|
148
|
+
when /github\.com|gitlab/
|
|
149
|
+
"Code Repository"
|
|
150
|
+
when /confluence\.|atlassian\.net/
|
|
151
|
+
"Documentation"
|
|
152
|
+
when /jira\.|atlassian\.net.*jira/
|
|
153
|
+
"Project Management"
|
|
154
|
+
when /grafana/
|
|
155
|
+
"Monitoring"
|
|
156
|
+
when /prometheus/
|
|
157
|
+
"Monitoring"
|
|
158
|
+
when /api\./
|
|
159
|
+
"API"
|
|
160
|
+
when /docs\./
|
|
161
|
+
"Documentation"
|
|
162
|
+
else
|
|
163
|
+
"Other"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Sort instances by multiple fields
|
|
168
|
+
# @param instances [Array] Array of resource instances to sort
|
|
169
|
+
# @param sort_fields [Array<String>] Fields to sort by, prefix with '-' for descending
|
|
170
|
+
# @return [Array] Sorted instances
|
|
171
|
+
def sort_instances(instances, sort_fields)
|
|
172
|
+
return instances if sort_fields.empty?
|
|
173
|
+
|
|
174
|
+
instances.sort do |a, b|
|
|
175
|
+
cmp = 0
|
|
176
|
+
sort_fields.each do |sort_spec|
|
|
177
|
+
break if cmp != 0
|
|
178
|
+
|
|
179
|
+
desc = sort_spec.start_with?("-")
|
|
180
|
+
field = desc ? sort_spec[1..] : sort_spec
|
|
181
|
+
|
|
182
|
+
val_a = instance_sort_value(a, field)
|
|
183
|
+
val_b = instance_sort_value(b, field)
|
|
184
|
+
|
|
185
|
+
cmp = compare_values(val_a, val_b)
|
|
186
|
+
cmp = -cmp if desc
|
|
187
|
+
end
|
|
188
|
+
cmp
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Get the value of a field from an instance for sorting
|
|
193
|
+
def instance_sort_value(instance, field)
|
|
194
|
+
case field
|
|
195
|
+
when "name" then instance.name.to_s
|
|
196
|
+
when "kind" then instance.klass.to_s
|
|
197
|
+
else instance.annotations[field]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Compare two values, using numeric comparison when both are integers
|
|
202
|
+
def compare_values(val_a, val_b)
|
|
203
|
+
if val_a.to_s.match?(/\A-?\d+\z/) && val_b.to_s.match?(/\A-?\d+\z/)
|
|
204
|
+
val_a.to_i <=> val_b.to_i
|
|
205
|
+
else
|
|
206
|
+
(val_a || "").to_s.downcase <=> (val_b || "").to_s.downcase
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
|
|
5
|
+
module Archsight
|
|
6
|
+
class Linter
|
|
7
|
+
# Available view components (partials in views/partials/components/)
|
|
8
|
+
VALID_COMPONENTS = %w[activity git jira languages owner repositories status].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(database)
|
|
11
|
+
@database = database
|
|
12
|
+
@errors = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate
|
|
16
|
+
@database.instances.each_value do |instances_hash|
|
|
17
|
+
instances_hash.each_value do |instance|
|
|
18
|
+
validate_instance_annotations(instance)
|
|
19
|
+
validate_view_fields(instance) if instance.klass == "View"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@errors
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def validate_instance_annotations(instance)
|
|
29
|
+
instance.annotations.each do |key, value|
|
|
30
|
+
# Find matching annotation definition (handles both exact and pattern matches)
|
|
31
|
+
annotation = instance.class.annotation_matching(key)
|
|
32
|
+
|
|
33
|
+
if annotation.nil?
|
|
34
|
+
@errors << "#{instance.path_ref}: Unknown annotation '#{key}' for #{instance.klass}"
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Skip validation if annotation has no constraints (enum or type)
|
|
39
|
+
next unless annotation.has_validation?
|
|
40
|
+
|
|
41
|
+
# Validate value against annotation schema (type and enum constraints)
|
|
42
|
+
annotation.validate(value).each do |error|
|
|
43
|
+
@errors << "#{instance.path_ref}: Annotation '#{key}' #{error}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check markdown syntax
|
|
47
|
+
validate_markdown(instance, key, value) if annotation.markdown?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_markdown(instance, key, value)
|
|
52
|
+
# Skip markdown validation for generated files
|
|
53
|
+
return if instance.annotations.key?("generated/script")
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
Kramdown::Document.new(value, input: "GFM")
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
@errors << "#{instance.path_ref}: Markdown syntax error in annotation '#{key}': #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_view_fields(instance)
|
|
63
|
+
fields = instance.annotations["view/fields"]
|
|
64
|
+
return unless fields
|
|
65
|
+
|
|
66
|
+
fields.split(",").map(&:strip).each do |field|
|
|
67
|
+
next unless field.start_with?("@")
|
|
68
|
+
|
|
69
|
+
component_name = field[1..]
|
|
70
|
+
unless VALID_COMPONENTS.include?(component_name)
|
|
71
|
+
@errors << "#{instance.path_ref}: Unknown view component '@#{component_name}'. " \
|
|
72
|
+
"Valid components: #{VALID_COMPONENTS.map { |c| "@#{c}" }.join(", ")}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
class Archsight::MCP::AnalyzeResourceTool < FastMcp::Tool
|
|
6
|
+
tool_name "analyze_resource"
|
|
7
|
+
|
|
8
|
+
description <<~DESC.gsub("\n", " ").strip
|
|
9
|
+
Get detailed information about a specific architecture resource by kind and name.
|
|
10
|
+
|
|
11
|
+
THREE MODES OF OPERATION:
|
|
12
|
+
|
|
13
|
+
1. BASIC MODE (default): Returns resource details including spec, annotations, and direct relations.
|
|
14
|
+
Example: kind="ApplicationComponent", name="MyService"
|
|
15
|
+
|
|
16
|
+
2. DEPENDENCY TREE MODE (depth > 0): Returns hierarchical view of all dependencies.
|
|
17
|
+
Shows both outgoing (what this resource depends on) and incoming (what depends on this resource)
|
|
18
|
+
relations recursively up to the specified depth.
|
|
19
|
+
Example: kind="ApplicationComponent", name="MyService", depth=3
|
|
20
|
+
|
|
21
|
+
3. IMPACT ANALYSIS MODE (impact=true): Analyzes what would break if this resource is deprecated.
|
|
22
|
+
Returns all resources that directly or transitively depend on this resource, grouped by
|
|
23
|
+
kind/verb/depth with summary statistics. Essential for change impact assessment.
|
|
24
|
+
Example: kind="ApplicationInterface", name="Kubernetes:RestAPI", impact=true
|
|
25
|
+
|
|
26
|
+
RESOURCE KINDS: TechnologyArtifact, ApplicationComponent, ApplicationInterface,
|
|
27
|
+
ApplicationService, BusinessRequirement, ComplianceEvidence, and more.
|
|
28
|
+
DESC
|
|
29
|
+
arguments do
|
|
30
|
+
required(:kind).filled(:string).description(
|
|
31
|
+
"Resource type to analyze. Common kinds: TechnologyArtifact (repos, code), " \
|
|
32
|
+
"ApplicationComponent (services), ApplicationInterface (APIs), " \
|
|
33
|
+
"BusinessRequirement (compliance controls), ComplianceEvidence (compliance proof)"
|
|
34
|
+
)
|
|
35
|
+
required(:name).filled(:string).description(
|
|
36
|
+
"Exact name of the resource (case-sensitive). Use QueryTool first if unsure of exact name."
|
|
37
|
+
)
|
|
38
|
+
optional(:depth).filled(:integer).description(
|
|
39
|
+
"Dependency tree depth (0-10). " \
|
|
40
|
+
"0 = show direct relations only (default), " \
|
|
41
|
+
"1+ = recursively expand dependencies to this depth. " \
|
|
42
|
+
"Higher values show more context but increase response size."
|
|
43
|
+
)
|
|
44
|
+
optional(:impact).filled(:bool).description(
|
|
45
|
+
"Enable impact analysis mode. When true, finds all resources that would be affected " \
|
|
46
|
+
"if this resource is deprecated or removed. Returns grouped results with statistics. " \
|
|
47
|
+
"Useful for change management and deprecation planning."
|
|
48
|
+
)
|
|
49
|
+
optional(:group_by).filled(:string).description(
|
|
50
|
+
"How to group impact analysis results: " \
|
|
51
|
+
"'kind' = group by resource type (default, good for understanding scope), " \
|
|
52
|
+
"'verb' = group by relationship type (exposes, realizes, etc.), " \
|
|
53
|
+
"'depth' = group by distance from target (1=direct, 2+=transitive)"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def call(kind:, name:, depth: 0, impact: false, group_by: "kind")
|
|
58
|
+
db = Archsight::MCP.db
|
|
59
|
+
|
|
60
|
+
klass = Archsight::Resources[kind]
|
|
61
|
+
return error_response("Unknown resource kind: #{kind}") unless klass
|
|
62
|
+
|
|
63
|
+
instance = db.instance_by_kind(kind, name)
|
|
64
|
+
return error_response("Resource not found: #{kind}/#{name}") unless instance
|
|
65
|
+
|
|
66
|
+
depth = [[depth.to_i, 0].max, 10].min # Clamp to 0-10
|
|
67
|
+
|
|
68
|
+
if impact
|
|
69
|
+
build_impact_analysis(db, instance, kind, name, depth, group_by)
|
|
70
|
+
else
|
|
71
|
+
build_resource_analysis(db, instance, kind, name, depth)
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
error_response(e.message, e.class.name, e.backtrace&.first(10))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def error_response(message, error_type = "Error", backtrace = nil)
|
|
80
|
+
result = {
|
|
81
|
+
error: error_type,
|
|
82
|
+
message: message
|
|
83
|
+
}
|
|
84
|
+
result[:backtrace] = backtrace if backtrace
|
|
85
|
+
JSON.pretty_generate(result)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_resource_analysis(db, instance, kind, name, depth)
|
|
89
|
+
result = {
|
|
90
|
+
kind: kind,
|
|
91
|
+
name: name,
|
|
92
|
+
spec: instance.spec,
|
|
93
|
+
annotations: instance.annotations
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if depth.positive?
|
|
97
|
+
# Include dependency trees
|
|
98
|
+
visited = Set.new
|
|
99
|
+
result[:outgoing] = build_dependency_tree(db, instance, "outgoing", 0, depth, visited.dup)
|
|
100
|
+
result[:incoming] = build_dependency_tree(db, instance, "incoming", 0, depth, visited.dup)
|
|
101
|
+
else
|
|
102
|
+
# Just direct relations
|
|
103
|
+
result[:relations] = Archsight::MCP.extract_relations(instance)
|
|
104
|
+
result[:has_relations] = instance.has_relations?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
JSON.pretty_generate(result)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_impact_analysis(db, instance, kind, name, max_depth, group_by)
|
|
111
|
+
max_depth = 5 if max_depth.zero? # Default depth for impact analysis
|
|
112
|
+
|
|
113
|
+
# Collect all impacted resources
|
|
114
|
+
impacted = []
|
|
115
|
+
visited = Set.new
|
|
116
|
+
collect_impact(db, instance, 0, max_depth, visited, impacted)
|
|
117
|
+
|
|
118
|
+
# Group results
|
|
119
|
+
grouped = case group_by
|
|
120
|
+
when "verb"
|
|
121
|
+
impacted.group_by { |i| i[:verb].to_s }
|
|
122
|
+
when "kind"
|
|
123
|
+
impacted.group_by { |i| i[:kind] }
|
|
124
|
+
when "depth"
|
|
125
|
+
impacted.group_by { |i| i[:depth] }
|
|
126
|
+
else
|
|
127
|
+
{ "all" => impacted }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Calculate summary statistics
|
|
131
|
+
summary = {
|
|
132
|
+
total_impacted: impacted.length,
|
|
133
|
+
unique_resources: impacted.map { |i| "#{i[:kind]}/#{i[:name]}" }.uniq.length,
|
|
134
|
+
by_kind: impacted.group_by { |i| i[:kind] }.transform_values(&:length),
|
|
135
|
+
max_depth_reached: impacted.map { |i| i[:depth] }.max || 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
result = {
|
|
139
|
+
target: { kind: kind, name: name },
|
|
140
|
+
analysis: "impact",
|
|
141
|
+
max_depth: max_depth,
|
|
142
|
+
group_by: group_by,
|
|
143
|
+
summary: summary,
|
|
144
|
+
impacted_resources: grouped
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
JSON.pretty_generate(result)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def collect_impact(db, resource, current_depth, max_depth, visited, collector)
|
|
151
|
+
return if current_depth >= max_depth
|
|
152
|
+
|
|
153
|
+
key = "#{resource.class.to_s.split("::").last}/#{resource.name}"
|
|
154
|
+
return if visited.include?(key)
|
|
155
|
+
|
|
156
|
+
visited.add(key)
|
|
157
|
+
|
|
158
|
+
db.instances.each_value do |instances_hash|
|
|
159
|
+
instances_hash.each_value do |other|
|
|
160
|
+
next if other == resource
|
|
161
|
+
|
|
162
|
+
other.class.relations.each do |verb, kind_name, _|
|
|
163
|
+
rels = other.relations(verb, kind_name)
|
|
164
|
+
next unless rels.include?(resource)
|
|
165
|
+
|
|
166
|
+
collector << {
|
|
167
|
+
kind: other.class.to_s.split("::").last,
|
|
168
|
+
name: other.name,
|
|
169
|
+
verb: verb,
|
|
170
|
+
depth: current_depth + 1
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
collect_impact(db, other, current_depth + 1, max_depth, visited.dup, collector)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_dependency_tree(db, resource, direction, current_depth, max_depth, visited)
|
|
180
|
+
return [] if current_depth >= max_depth
|
|
181
|
+
|
|
182
|
+
key = "#{resource.class.to_s.split("::").last}/#{resource.name}"
|
|
183
|
+
return [] if visited.include?(key)
|
|
184
|
+
|
|
185
|
+
visited.add(key)
|
|
186
|
+
deps = []
|
|
187
|
+
|
|
188
|
+
if direction == "outgoing"
|
|
189
|
+
resource.class.relations.each do |verb, kind_name, _|
|
|
190
|
+
rels = resource.relations(verb, kind_name)
|
|
191
|
+
rels.each do |rel|
|
|
192
|
+
deps << {
|
|
193
|
+
kind: rel.class.to_s.split("::").last,
|
|
194
|
+
name: rel.name,
|
|
195
|
+
verb: verb.to_s,
|
|
196
|
+
children: build_dependency_tree(db, rel, direction, current_depth + 1, max_depth, visited.dup)
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
elsif direction == "incoming"
|
|
201
|
+
db.instances.each_value do |instances_hash|
|
|
202
|
+
instances_hash.each_value do |other|
|
|
203
|
+
next if other == resource
|
|
204
|
+
|
|
205
|
+
other.class.relations.each do |verb, kind_name, _|
|
|
206
|
+
rels = other.relations(verb, kind_name)
|
|
207
|
+
next unless rels.include?(resource)
|
|
208
|
+
|
|
209
|
+
deps << {
|
|
210
|
+
kind: other.class.to_s.split("::").last,
|
|
211
|
+
name: other.name,
|
|
212
|
+
verb: verb.to_s,
|
|
213
|
+
children: build_dependency_tree(db, other, direction, current_depth + 1, max_depth, visited.dup)
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
deps
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast_mcp"
|
|
4
|
+
require_relative "../database"
|
|
5
|
+
require_relative "../resources"
|
|
6
|
+
|
|
7
|
+
# Shared database and helper methods for MCP tools
|
|
8
|
+
module Archsight::MCP
|
|
9
|
+
class << self
|
|
10
|
+
attr_accessor :db
|
|
11
|
+
|
|
12
|
+
def complete_summary(resource, omit_kind: false)
|
|
13
|
+
result = {
|
|
14
|
+
name: resource.name,
|
|
15
|
+
metadata: {
|
|
16
|
+
annotations: resource.annotations
|
|
17
|
+
},
|
|
18
|
+
spec: resource.spec
|
|
19
|
+
}
|
|
20
|
+
result[:kind] = resource.class.to_s.split("::").last unless omit_kind
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def brief_summary(resource, omit_kind: false)
|
|
25
|
+
result = { name: resource.name }
|
|
26
|
+
result[:kind] = resource.class.to_s.split("::").last unless omit_kind
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def extract_description(resource)
|
|
31
|
+
description = resource.annotations["architecture/description"]
|
|
32
|
+
return "No description" if description.nil?
|
|
33
|
+
|
|
34
|
+
description.split("\n").first
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_relations(instance)
|
|
38
|
+
relations = {}
|
|
39
|
+
|
|
40
|
+
instance.class.relations.each do |verb, kind_name, _|
|
|
41
|
+
relations[verb] ||= {}
|
|
42
|
+
relations[verb][kind_name] = instance.relations(verb, kind_name).map(&:name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
relations
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|