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.
Files changed (122) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/CONTRIBUTING.md +186 -0
  5. data/Dockerfile +39 -0
  6. data/LICENSE.txt +201 -0
  7. data/README.md +170 -0
  8. data/SECURITY.md +27 -0
  9. data/exe/archsight +9 -0
  10. data/lib/archsight/annotations/aggregators.rb +109 -0
  11. data/lib/archsight/annotations/annotation.rb +168 -0
  12. data/lib/archsight/annotations/architecture_annotations.rb +59 -0
  13. data/lib/archsight/annotations/backup_annotations.rb +21 -0
  14. data/lib/archsight/annotations/computed.rb +264 -0
  15. data/lib/archsight/annotations/email_recipient.rb +35 -0
  16. data/lib/archsight/annotations/generated_annotations.rb +17 -0
  17. data/lib/archsight/annotations/git_annotations.rb +21 -0
  18. data/lib/archsight/annotations/relation_resolver.rb +160 -0
  19. data/lib/archsight/cli.rb +120 -0
  20. data/lib/archsight/configuration.rb +36 -0
  21. data/lib/archsight/database.rb +183 -0
  22. data/lib/archsight/documentation.rb +171 -0
  23. data/lib/archsight/graph.rb +113 -0
  24. data/lib/archsight/helpers.rb +210 -0
  25. data/lib/archsight/linter.rb +77 -0
  26. data/lib/archsight/mcp/analyze_resource_tool.rb +222 -0
  27. data/lib/archsight/mcp/base.rb +48 -0
  28. data/lib/archsight/mcp/query_tool.rb +113 -0
  29. data/lib/archsight/mcp/resource_doc_tool.rb +87 -0
  30. data/lib/archsight/mcp.rb +6 -0
  31. data/lib/archsight/query/ast.rb +279 -0
  32. data/lib/archsight/query/errors.rb +39 -0
  33. data/lib/archsight/query/evaluator.rb +707 -0
  34. data/lib/archsight/query/lexer.rb +289 -0
  35. data/lib/archsight/query/parser.rb +506 -0
  36. data/lib/archsight/query.rb +68 -0
  37. data/lib/archsight/renderer.rb +134 -0
  38. data/lib/archsight/resources/application_component.rb +346 -0
  39. data/lib/archsight/resources/application_interface.rb +54 -0
  40. data/lib/archsight/resources/application_service.rb +222 -0
  41. data/lib/archsight/resources/base.rb +300 -0
  42. data/lib/archsight/resources/business_actor.rb +195 -0
  43. data/lib/archsight/resources/business_constraint.rb +32 -0
  44. data/lib/archsight/resources/business_process.rb +37 -0
  45. data/lib/archsight/resources/business_product.rb +206 -0
  46. data/lib/archsight/resources/business_requirement.rb +56 -0
  47. data/lib/archsight/resources/compliance_evidence.rb +42 -0
  48. data/lib/archsight/resources/data_object.rb +49 -0
  49. data/lib/archsight/resources/motivation_goal.rb +37 -0
  50. data/lib/archsight/resources/motivation_outcome.rb +33 -0
  51. data/lib/archsight/resources/motivation_stakeholder.rb +38 -0
  52. data/lib/archsight/resources/strategy_capability.rb +38 -0
  53. data/lib/archsight/resources/technology_artifact.rb +154 -0
  54. data/lib/archsight/resources/technology_interface.rb +34 -0
  55. data/lib/archsight/resources/technology_node.rb +42 -0
  56. data/lib/archsight/resources/technology_service.rb +35 -0
  57. data/lib/archsight/resources/technology_system_software.rb +37 -0
  58. data/lib/archsight/resources/view.rb +51 -0
  59. data/lib/archsight/resources.rb +49 -0
  60. data/lib/archsight/template.rb +49 -0
  61. data/lib/archsight/version.rb +5 -0
  62. data/lib/archsight/web/application.rb +290 -0
  63. data/lib/archsight/web/doc/archimate.md +215 -0
  64. data/lib/archsight/web/doc/computed_annotations.md +316 -0
  65. data/lib/archsight/web/doc/icons.md +303 -0
  66. data/lib/archsight/web/doc/index.md.erb +74 -0
  67. data/lib/archsight/web/doc/modeling.md +200 -0
  68. data/lib/archsight/web/doc/search.md +227 -0
  69. data/lib/archsight/web/doc/togaf.md +255 -0
  70. data/lib/archsight/web/doc/tool.md +90 -0
  71. data/lib/archsight/web/public/css/artifact.css +985 -0
  72. data/lib/archsight/web/public/css/base.css +201 -0
  73. data/lib/archsight/web/public/css/graph.css +106 -0
  74. data/lib/archsight/web/public/css/highlight.min.css +10 -0
  75. data/lib/archsight/web/public/css/iconoir.css +22 -0
  76. data/lib/archsight/web/public/css/instance.css +329 -0
  77. data/lib/archsight/web/public/css/layout.css +421 -0
  78. data/lib/archsight/web/public/css/mermaid-layers.css +188 -0
  79. data/lib/archsight/web/public/css/pico.min.css +4 -0
  80. data/lib/archsight/web/public/favicon.ico +0 -0
  81. data/lib/archsight/web/public/img/archimate.png +0 -0
  82. data/lib/archsight/web/public/img/togaf-high-level.png +0 -0
  83. data/lib/archsight/web/public/js/graph-zoom.js +18 -0
  84. data/lib/archsight/web/public/js/highlight.min.js +3899 -0
  85. data/lib/archsight/web/public/js/htmx.min.js +1 -0
  86. data/lib/archsight/web/public/js/mermaid-init.js +88 -0
  87. data/lib/archsight/web/public/js/mermaid.min.js +2811 -0
  88. data/lib/archsight/web/public/js/sparkline.js +42 -0
  89. data/lib/archsight/web/public/js/svg-pan-zoom.min.js +3 -0
  90. data/lib/archsight/web/public/js/svg-zoom-controls.js +93 -0
  91. data/lib/archsight/web/views/index.haml +12 -0
  92. data/lib/archsight/web/views/partials/artifact/_activity.haml +55 -0
  93. data/lib/archsight/web/views/partials/artifact/_agentic.haml +25 -0
  94. data/lib/archsight/web/views/partials/artifact/_deployment.haml +29 -0
  95. data/lib/archsight/web/views/partials/artifact/_git_info.haml +16 -0
  96. data/lib/archsight/web/views/partials/artifact/_language_stats.haml +53 -0
  97. data/lib/archsight/web/views/partials/artifact/_links.haml +24 -0
  98. data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +26 -0
  99. data/lib/archsight/web/views/partials/artifact/_repositories.haml +55 -0
  100. data/lib/archsight/web/views/partials/artifact/_team.haml +83 -0
  101. data/lib/archsight/web/views/partials/artifact/_workflow.haml +69 -0
  102. data/lib/archsight/web/views/partials/components/_activity.haml +37 -0
  103. data/lib/archsight/web/views/partials/components/_git.haml +17 -0
  104. data/lib/archsight/web/views/partials/components/_jira.haml +18 -0
  105. data/lib/archsight/web/views/partials/components/_languages.haml +29 -0
  106. data/lib/archsight/web/views/partials/components/_owner.haml +15 -0
  107. data/lib/archsight/web/views/partials/components/_repositories.haml +37 -0
  108. data/lib/archsight/web/views/partials/components/_status.haml +23 -0
  109. data/lib/archsight/web/views/partials/instance/_detail.haml +99 -0
  110. data/lib/archsight/web/views/partials/instance/_graph.haml +6 -0
  111. data/lib/archsight/web/views/partials/instance/_list.haml +84 -0
  112. data/lib/archsight/web/views/partials/instance/_relations.haml +43 -0
  113. data/lib/archsight/web/views/partials/instance/_requirements.haml +41 -0
  114. data/lib/archsight/web/views/partials/instance/_view_detail.haml +57 -0
  115. data/lib/archsight/web/views/partials/layout/_content.haml +40 -0
  116. data/lib/archsight/web/views/partials/layout/_error.haml +22 -0
  117. data/lib/archsight/web/views/partials/layout/_head.haml +24 -0
  118. data/lib/archsight/web/views/partials/layout/_navigation.haml +20 -0
  119. data/lib/archsight/web/views/partials/layout/_sidebar.haml +27 -0
  120. data/lib/archsight/web/views/search.haml +53 -0
  121. data/lib/archsight.rb +17 -0
  122. 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