archsight 0.1.4 → 0.1.5

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -0
  3. data/chart/archsight/Chart.yaml +6 -0
  4. data/chart/archsight/README.md +160 -0
  5. data/chart/archsight/templates/NOTES.txt +22 -0
  6. data/chart/archsight/templates/_helpers.tpl +62 -0
  7. data/chart/archsight/templates/deployment.yaml +114 -0
  8. data/chart/archsight/templates/ingress.yaml +56 -0
  9. data/chart/archsight/templates/resources-configmap.yaml +10 -0
  10. data/chart/archsight/templates/resources-pvc.yaml +23 -0
  11. data/chart/archsight/templates/service.yaml +15 -0
  12. data/chart/archsight/templates/serviceaccount.yaml +12 -0
  13. data/chart/archsight/values.yaml +162 -0
  14. data/lib/archsight/analysis/executor.rb +0 -10
  15. data/lib/archsight/annotations/annotation.rb +85 -36
  16. data/lib/archsight/annotations/architecture_annotations.rb +1 -34
  17. data/lib/archsight/annotations/computed.rb +1 -1
  18. data/lib/archsight/annotations/generated_annotations.rb +6 -3
  19. data/lib/archsight/annotations/git_annotations.rb +8 -4
  20. data/lib/archsight/annotations/interface_annotations.rb +35 -0
  21. data/lib/archsight/cli.rb +3 -1
  22. data/lib/archsight/editor/content_hasher.rb +37 -0
  23. data/lib/archsight/editor/file_writer.rb +79 -0
  24. data/lib/archsight/editor.rb +237 -0
  25. data/lib/archsight/import/handlers/github.rb +14 -6
  26. data/lib/archsight/import/handlers/gitlab.rb +14 -6
  27. data/lib/archsight/import/handlers/repository.rb +3 -1
  28. data/lib/archsight/import/team_matcher.rb +111 -61
  29. data/lib/archsight/mcp/execute_analysis_tool.rb +100 -0
  30. data/lib/archsight/mcp.rb +1 -0
  31. data/lib/archsight/resources/analysis.rb +1 -17
  32. data/lib/archsight/resources/application_interface.rb +1 -5
  33. data/lib/archsight/resources/base.rb +14 -14
  34. data/lib/archsight/resources/business_actor.rb +1 -1
  35. data/lib/archsight/resources/technology_interface.rb +1 -1
  36. data/lib/archsight/resources/technology_service.rb +5 -0
  37. data/lib/archsight/version.rb +1 -1
  38. data/lib/archsight/web/application.rb +8 -0
  39. data/lib/archsight/web/doc/import.md +10 -2
  40. data/lib/archsight/web/editor/form_builder.rb +100 -0
  41. data/lib/archsight/web/editor/routes.rb +293 -0
  42. data/lib/archsight/web/public/css/editor.css +863 -0
  43. data/lib/archsight/web/public/css/instance.css +6 -0
  44. data/lib/archsight/web/public/js/editor.js +421 -0
  45. data/lib/archsight/web/public/js/lexical-editor.js +308 -0
  46. data/lib/archsight/web/views/partials/editor/_field.haml +80 -0
  47. data/lib/archsight/web/views/partials/editor/_form.haml +131 -0
  48. data/lib/archsight/web/views/partials/editor/_relations.haml +39 -0
  49. data/lib/archsight/web/views/partials/editor/_yaml_output.haml +33 -0
  50. data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +4 -11
  51. data/lib/archsight/web/views/partials/instance/_detail.haml +4 -0
  52. data/lib/archsight/web/views/partials/layout/_content.haml +8 -2
  53. data/lib/archsight/web/views/partials/layout/_head.haml +2 -0
  54. metadata +26 -1
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "resources"
5
+ require_relative "editor/file_writer"
6
+ require_relative "editor/content_hasher"
7
+
8
+ module Archsight
9
+ # Editor handles building and validating resources for the web editor
10
+ module Editor
11
+ module_function
12
+
13
+ # Build a resource hash from form params
14
+ # @param kind [String] Resource kind (e.g., "TechnologyArtifact")
15
+ # @param name [String] Resource name
16
+ # @param annotations [Hash] Annotation key-value pairs
17
+ # @param relations [Array<Hash>] Array of {verb:, kind:, names:[]} hashes
18
+ # where kind is the target class name (e.g., "TechnologyArtifact")
19
+ # @return [Hash] Resource hash ready for YAML conversion
20
+ def build_resource(kind:, name:, annotations: {}, relations: [])
21
+ resource = {
22
+ "apiVersion" => "architecture/v1alpha1",
23
+ "kind" => kind,
24
+ "metadata" => {
25
+ "name" => name
26
+ }
27
+ }
28
+
29
+ # Add annotations if any non-empty values exist
30
+ filtered_annotations = filter_empty(annotations)
31
+ resource["metadata"]["annotations"] = filtered_annotations unless filtered_annotations.empty?
32
+
33
+ # Add spec with relations if any exist
34
+ spec = build_spec(kind, relations)
35
+ resource["spec"] = spec unless spec.empty?
36
+
37
+ resource
38
+ end
39
+
40
+ # Validate resource params using annotation definitions
41
+ # @param kind [String] Resource kind
42
+ # @param name [String] Resource name
43
+ # @param annotations [Hash] Annotation key-value pairs
44
+ # @return [Hash] { valid: Boolean, errors: { field => [messages] } }
45
+ def validate(kind, name:, annotations: {})
46
+ klass = Archsight::Resources[kind]
47
+ errors = {}
48
+
49
+ # Validate name
50
+ if name.nil? || name.strip.empty?
51
+ errors["name"] = ["Name is required"]
52
+ elsif name =~ /\s/
53
+ errors["name"] = ["Name cannot contain spaces"]
54
+ end
55
+
56
+ # Validate annotations against their definitions
57
+ klass.annotations.reject(&:pattern?).each do |ann|
58
+ value = annotations[ann.key]
59
+ next if value.nil? || value.to_s.strip.empty?
60
+
61
+ ann_errors = ann.validate(value)
62
+ errors[ann.key] = ann_errors if ann_errors.any?
63
+ end
64
+
65
+ { valid: errors.empty?, errors: errors }
66
+ end
67
+
68
+ # Generate YAML string from resource hash
69
+ # Uses custom YAML dump that formats multiline strings with literal block scalars
70
+ # @param resource_hash [Hash] Resource hash
71
+ # @return [String] YAML string
72
+ def to_yaml(resource_hash)
73
+ visitor = Psych::Visitors::YAMLTree.create
74
+ visitor << resource_hash
75
+
76
+ # Walk the AST and apply scalar style for multiline strings
77
+ ast = visitor.tree
78
+ apply_block_scalar_style(ast)
79
+
80
+ ast.yaml(nil, line_width: 80)
81
+ end
82
+
83
+ # Recursively apply literal block style for multiline strings in YAML AST
84
+ # @param node [Psych::Nodes::Node] YAML AST node
85
+ def apply_block_scalar_style(node)
86
+ case node
87
+ when Psych::Nodes::Scalar
88
+ if node.value.is_a?(String)
89
+ # Normalize Windows/old Mac line endings to Unix style
90
+ node.value = node.value.gsub("\r\n", "\n").gsub("\r", "\n") if node.value.include?("\r")
91
+ # Use literal block style for multiline strings
92
+ node.style = Psych::Nodes::Scalar::LITERAL if node.value.include?("\n")
93
+ end
94
+ when Psych::Nodes::Sequence, Psych::Nodes::Mapping, Psych::Nodes::Document, Psych::Nodes::Stream
95
+ node.children.each { |child| apply_block_scalar_style(child) }
96
+ end
97
+ end
98
+
99
+ # Get editable annotations for a resource kind
100
+ # Excludes pattern annotations, computed annotations, and annotations with editor: false
101
+ # @param kind [String] Resource kind
102
+ # @return [Array<Archsight::Annotations::Annotation>]
103
+ def editable_annotations(kind)
104
+ klass = Archsight::Resources[kind]
105
+ return [] unless klass
106
+
107
+ # Get all annotations except pattern annotations
108
+ annotations = klass.annotations.reject(&:pattern?)
109
+
110
+ # Filter out computed annotations
111
+ computed_keys = klass.computed_annotations.map(&:key)
112
+ annotations = annotations.reject { |a| computed_keys.include?(a.key) }
113
+
114
+ # Filter out non-editable annotations
115
+ annotations.reject { |a| a.editor == false }
116
+ end
117
+
118
+ # Get available relations for a resource kind
119
+ # @param kind [String] Resource kind
120
+ # @return [Array<Array>] Array of [verb, target_kind, target_class_name]
121
+ def available_relations(kind)
122
+ klass = Archsight::Resources[kind]
123
+ return [] unless klass
124
+
125
+ klass.relations
126
+ end
127
+
128
+ # Get unique verbs for a resource kind's relations
129
+ # @param kind [String] Resource kind
130
+ # @return [Array<String>]
131
+ def relation_verbs(kind)
132
+ available_relations(kind).map { |v, _, _| v.to_s }.uniq.sort
133
+ end
134
+
135
+ # Get valid target class names for a given verb (for UI display and instance lookup)
136
+ # @param kind [String] Resource kind
137
+ # @param verb [String] Relation verb
138
+ # @return [Array<String>] Target class names (e.g., "TechnologyArtifact")
139
+ def target_kinds_for_verb(kind, verb)
140
+ # Relations structure is [verb, relation_name, target_class_name]
141
+ available_relations(kind)
142
+ .select { |v, _, _| v.to_s == verb.to_s }
143
+ .map { |_, _, target_class| target_class.to_s }
144
+ .uniq
145
+ .sort
146
+ end
147
+
148
+ # Get relation name for a given verb and target class (for building spec)
149
+ # @param kind [String] Source resource kind
150
+ # @param verb [String] Relation verb
151
+ # @param target_class [String] Target class name
152
+ # @return [String, nil] Relation name (e.g., "technologyComponents")
153
+ def relation_name_for(kind, verb, target_class)
154
+ relation = available_relations(kind).find do |v, _, tc|
155
+ v.to_s == verb.to_s && tc.to_s == target_class.to_s
156
+ end
157
+ return nil unless relation
158
+
159
+ relation[1].to_s
160
+ end
161
+
162
+ # Get target class name for a given verb and relation name (reverse lookup)
163
+ # @param kind [String] Source resource kind
164
+ # @param verb [String] Relation verb
165
+ # @param relation_name [String] Relation name (e.g., "businessActors")
166
+ # @return [String, nil] Target class name (e.g., "BusinessActor")
167
+ def target_class_for_relation(kind, verb, relation_name)
168
+ relation = available_relations(kind).find do |v, rn, _|
169
+ v.to_s == verb.to_s && rn.to_s == relation_name.to_s
170
+ end
171
+ return nil unless relation
172
+
173
+ relation[2].to_s
174
+ end
175
+
176
+ # Filter out empty values from a hash and convert to plain Hash
177
+ # (avoids !ruby/hash:Sinatra::IndifferentHash in YAML output)
178
+ def filter_empty(hash)
179
+ return {} if hash.nil?
180
+
181
+ result = hash.reject { |_, v| v.nil? || v.to_s.strip.empty? }
182
+ # Convert to plain Hash to avoid Ruby-specific YAML tags
183
+ result.to_h
184
+ end
185
+
186
+ # Build spec hash from relations array
187
+ # @param source_kind [String] The source resource kind
188
+ # @param relations [Array<Hash>] Array of {verb:, kind:, names:[]} hashes
189
+ # where kind is the target class name (e.g., "TechnologyArtifact")
190
+ # @return [Hash] Spec hash with proper relation_name keys
191
+ def build_spec(source_kind, relations)
192
+ return {} if relations.nil? || relations.empty?
193
+
194
+ spec = {}
195
+ relations.each { |rel| add_relation_to_spec(spec, source_kind, rel) }
196
+ deduplicate_spec_values(spec)
197
+ end
198
+
199
+ def add_relation_to_spec(spec, source_kind, rel)
200
+ verb, target_class, names = extract_relation_parts(rel)
201
+ return if invalid_relation?(verb, target_class, names)
202
+
203
+ rel_name = relation_name_for(source_kind, verb, target_class)
204
+ return unless rel_name
205
+
206
+ spec[verb.to_s] ||= {}
207
+ spec[verb.to_s][rel_name] ||= []
208
+ spec[verb.to_s][rel_name].concat(names)
209
+ end
210
+
211
+ def extract_relation_parts(rel)
212
+ verb = rel[:verb] || rel["verb"]
213
+ target_class = rel[:kind] || rel["kind"]
214
+ names = normalize_names(rel[:names] || rel["names"] || [])
215
+ [verb, target_class, names]
216
+ end
217
+
218
+ def normalize_names(names)
219
+ names = [names] unless names.is_a?(Array)
220
+ names.map(&:to_s).reject(&:empty?)
221
+ end
222
+
223
+ def invalid_relation?(verb, target_class, names)
224
+ verb.nil? || verb.to_s.strip.empty? ||
225
+ target_class.nil? || target_class.to_s.strip.empty? ||
226
+ names.empty?
227
+ end
228
+
229
+ def deduplicate_spec_values(spec)
230
+ spec.transform_values { |kinds| kinds.transform_values(&:uniq) }
231
+ end
232
+
233
+ private_class_method :filter_empty, :build_spec, :add_relation_to_spec,
234
+ :extract_relation_parts, :normalize_names, :invalid_relation?,
235
+ :deduplicate_spec_values
236
+ end
237
+ end
@@ -14,6 +14,9 @@ require_relative "../registry"
14
14
  # import/config/org - GitHub organization name
15
15
  # import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
16
16
  # import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
17
+ # import/config/fallbackTeam - Default team when no contributor match found (propagated to child imports)
18
+ # import/config/botTeam - Team for bot-only repositories (propagated to child imports)
19
+ # import/config/corporateAffixes - Comma-separated corporate username affixes for team matching (propagated to child imports)
17
20
  #
18
21
  # Environment:
19
22
  # GITHUB_TOKEN - GitHub Personal Access Token (required)
@@ -136,15 +139,20 @@ class Archsight::Import::Handlers::Github < Archsight::Import::Handler
136
139
  child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
137
140
  child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
138
141
 
142
+ child_config = {
143
+ "path" => repo_path,
144
+ "gitUrl" => git_url,
145
+ "archived" => repo["isArchived"].to_s,
146
+ "visibility" => visibility == "public" ? "open-source" : "internal"
147
+ }
148
+ child_config["fallbackTeam"] = config("fallbackTeam") if config("fallbackTeam")
149
+ child_config["botTeam"] = config("botTeam") if config("botTeam")
150
+ child_config["corporateAffixes"] = config("corporateAffixes") if config("corporateAffixes")
151
+
139
152
  import_yaml(
140
153
  name: "Import:Repo:github:#{@org}:#{repo_name}",
141
154
  handler: "repository",
142
- config: {
143
- "path" => repo_path,
144
- "gitUrl" => git_url,
145
- "archived" => repo["isArchived"].to_s,
146
- "visibility" => visibility == "public" ? "open-source" : "internal"
147
- },
155
+ config: child_config,
148
156
  annotations: child_annotations
149
157
  )
150
158
  end
@@ -19,6 +19,9 @@ require_relative "../registry"
19
19
  # import/config/sslFingerprint - SSL certificate fingerprint for pinning (SHA256, colon-separated hex)
20
20
  # import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
21
21
  # import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
22
+ # import/config/fallbackTeam - Default team when no contributor match found (propagated to child imports)
23
+ # import/config/botTeam - Team for bot-only repositories (propagated to child imports)
24
+ # import/config/corporateAffixes - Comma-separated corporate username affixes for team matching (propagated to child imports)
22
25
  #
23
26
  # Environment:
24
27
  # GITLAB_TOKEN - GitLab personal access token (required)
@@ -177,15 +180,20 @@ class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
177
180
  child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
178
181
  child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
179
182
 
183
+ child_config = {
184
+ "path" => repo_path,
185
+ "gitUrl" => git_url,
186
+ "archived" => project["archived"].to_s,
187
+ "visibility" => project["visibility"] || "internal"
188
+ }
189
+ child_config["fallbackTeam"] = config("fallbackTeam") if config("fallbackTeam")
190
+ child_config["botTeam"] = config("botTeam") if config("botTeam")
191
+ child_config["corporateAffixes"] = config("corporateAffixes") if config("corporateAffixes")
192
+
180
193
  import_yaml(
181
194
  name: "Import:Repo:gitlab:#{dir_name}",
182
195
  handler: "repository",
183
- config: {
184
- "path" => repo_path,
185
- "gitUrl" => git_url,
186
- "archived" => project["archived"].to_s,
187
- "visibility" => project["visibility"] || "internal"
188
- },
196
+ config: child_config,
189
197
  annotations: child_annotations
190
198
  )
191
199
  end
@@ -18,6 +18,7 @@ require_relative "../team_matcher"
18
18
  # import/config/sccPath - Optional path to scc binary (default: scc)
19
19
  # import/config/fallbackTeam - Optional team name when no contributor match found
20
20
  # import/config/botTeam - Optional team name for bot-only repositories
21
+ # import/config/corporateAffixes - Optional comma-separated corporate username affixes for team matching (e.g., "ionos,1and1")
21
22
  class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
22
23
  def execute
23
24
  @path = config("path")
@@ -256,7 +257,8 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
256
257
  def match_teams(top_contributors, activity_status)
257
258
  return nil unless database && top_contributors&.any?
258
259
 
259
- matcher = Archsight::Import::TeamMatcher.new(database)
260
+ affixes = config("corporateAffixes")&.split(",")&.map(&:strip) || []
261
+ matcher = Archsight::Import::TeamMatcher.new(database, corporate_affixes: affixes)
260
262
  result = matcher.analyze(top_contributors)
261
263
 
262
264
  # Apply fallbacks from config
@@ -15,11 +15,16 @@ class Archsight::Import::TeamMatcher
15
15
  # Teams to ignore when matching (bots, unknown, etc.)
16
16
  IGNORED_TEAMS = %w[Bot:Team No:Team Team:Unknown Team:Bot].freeze
17
17
 
18
- def initialize(database)
18
+ # Team annotation keys that contain member/lead information
19
+ TEAM_ANNOTATION_KEYS = %w[team/members team/lead].freeze
20
+
21
+ def initialize(database, corporate_affixes: [])
19
22
  @database = database
23
+ @corporate_affixes = corporate_affixes
20
24
  @teams = load_teams
21
25
  @email_to_team = build_email_index
22
26
  @name_to_team = build_name_index
27
+ @member_identities = build_member_identities
23
28
  end
24
29
 
25
30
  # Analyze top contributors and return team assignments
@@ -72,6 +77,12 @@ class Archsight::Import::TeamMatcher
72
77
  return team if team
73
78
  end
74
79
 
80
+ # Try corporate username pattern match (e.g. jsmith-ionos -> John Smith)
81
+ if name
82
+ team = pattern_match_username(name)
83
+ return team if team
84
+ end
85
+
75
86
  nil
76
87
  end
77
88
 
@@ -87,21 +98,22 @@ class Archsight::Import::TeamMatcher
87
98
  end
88
99
  end
89
100
 
90
- def build_email_index
91
- index = {}
92
-
101
+ # Yields (team_name, annotation_value) for each team/members and team/lead annotation
102
+ def each_team_annotation
93
103
  @teams.each do |team|
94
104
  team_name = team.name
95
-
96
- # Extract emails from team/members annotation
97
- members = team.annotations["team/members"]
98
- parse_email_list(members).each do |email|
99
- index[email.downcase] = team_name
105
+ TEAM_ANNOTATION_KEYS.each do |key|
106
+ value = team.annotations[key]
107
+ yield team_name, value if value && !value.empty?
100
108
  end
109
+ end
110
+ end
111
+
112
+ def build_email_index
113
+ index = {}
101
114
 
102
- # Extract email from team/lead annotation
103
- lead = team.annotations["team/lead"]
104
- parse_email_list(lead).each do |email|
115
+ each_team_annotation do |team_name, value|
116
+ parse_email_list(value).each do |email|
105
117
  index[email.downcase] = team_name
106
118
  end
107
119
  end
@@ -112,19 +124,8 @@ class Archsight::Import::TeamMatcher
112
124
  def build_name_index
113
125
  index = {}
114
126
 
115
- @teams.each do |team|
116
- team_name = team.name
117
-
118
- # Extract names from team/members annotation
119
- members = team.annotations["team/members"]
120
- parse_name_list(members).each do |name|
121
- normalized = normalize_name(name)
122
- index[normalized] = team_name if normalized && !normalized.empty?
123
- end
124
-
125
- # Extract name from team/lead annotation
126
- lead = team.annotations["team/lead"]
127
- parse_name_list(lead).each do |name|
127
+ each_team_annotation do |team_name, value|
128
+ parse_name_list(value).each do |name|
128
129
  normalized = normalize_name(name)
129
130
  index[normalized] = team_name if normalized && !normalized.empty?
130
131
  end
@@ -136,60 +137,109 @@ class Archsight::Import::TeamMatcher
136
137
  # Parse email addresses from team annotation
137
138
  # Supports formats: "Name <email>", "email", or comma/newline separated lists
138
139
  def parse_email_list(value)
139
- return [] if value.nil? || value.empty?
140
+ parse_name_email_pairs(value).filter_map { |pair| pair[:email] }
141
+ end
140
142
 
141
- emails = []
143
+ # Parse names from team annotation
144
+ # Supports formats: "Name <email>", "First Last", or comma/newline separated lists
145
+ def parse_name_list(value)
146
+ parse_name_email_pairs(value).filter_map { |pair| pair[:name] }
147
+ end
142
148
 
143
- # Split by comma or newline
144
- value.split(/[,\n]/).each do |entry|
145
- entry = entry.strip
146
- next if entry.empty?
149
+ # Normalize name for matching
150
+ # Converts to lowercase, removes extra spaces, handles common variations
151
+ def normalize_name(name)
152
+ return nil if name.nil?
147
153
 
148
- # Try to extract email from "Name <email>" format
149
- if (match = entry.match(/<([^>]+)>/))
150
- emails << match[1].strip
151
- elsif entry.include?("@")
152
- # Plain email address
153
- emails << entry
154
+ name.to_s
155
+ .downcase
156
+ .gsub(/\s+/, " ")
157
+ .strip
158
+ end
159
+
160
+ # Match git author name as corporate username pattern {first_initial}{lastname}[-affix] or [affix-]{first_initial}{lastname}
161
+ # e.g. "jsmith-ionos" or "ionos-jsmith" -> initial "j", lastname "smith" -> matches "John Smith"
162
+ #
163
+ # Limitations: Multi-part names (e.g. "Hans von Braun") only match by the last
164
+ # name part ("braun"), so "hvonbraun" would not match. Hyphenated lastnames
165
+ # (e.g. "Meyer-Schmidt") only match the full hyphenated form or the email lastname.
166
+ def pattern_match_username(name)
167
+ username = name.downcase.strip
168
+ return nil unless username.match?(/\A[a-z0-9][-a-z0-9]*\z/)
169
+ return nil if @corporate_affixes.empty?
170
+
171
+ @corporate_affixes.each do |affix|
172
+ clean = affix.delete_prefix("-").delete_suffix("-")
173
+ username = username.delete_suffix("-#{clean}")
174
+ username = username.delete_prefix("#{clean}-")
175
+ end
176
+ return nil if username.length < 3
177
+
178
+ initial = username[0]
179
+ lastname = username[1..]
180
+ return nil if lastname.length < 3
181
+
182
+ candidates = @member_identities.select do |member|
183
+ lastname_match = member[:lastname] == lastname || member[:email_lastname] == lastname
184
+ initial_match = member[:firstname]&.start_with?(initial)
185
+ lastname_match && initial_match
186
+ end
187
+
188
+ teams = candidates.map { |c| c[:team] }.uniq
189
+ return teams.first if teams.size == 1
190
+
191
+ nil
192
+ end
193
+
194
+ # Build identity records for corporate username pattern matching
195
+ def build_member_identities
196
+ identities = []
197
+
198
+ each_team_annotation do |team_name, value|
199
+ parse_name_email_pairs(value).each do |entry|
200
+ name_parts = entry[:name]&.downcase&.split(/\s+/)
201
+ next if name_parts.nil? || name_parts.size < 2
202
+
203
+ firstname = name_parts.first
204
+ lastname = name_parts.last
205
+
206
+ email_prefix = entry[:email]&.split("@")&.first
207
+ email_parts = email_prefix&.split(/[.-]/)
208
+ email_lastname = email_parts&.last&.downcase
209
+
210
+ identities << { team: team_name, firstname: firstname, lastname: lastname, email_lastname: email_lastname }
154
211
  end
155
212
  end
156
213
 
157
- emails
214
+ identities
158
215
  end
159
216
 
160
- # Parse names from team annotation
161
- # Supports formats: "Name <email>", "First Last", or comma/newline separated lists
162
- def parse_name_list(value)
217
+ # Parse name and email pairs from team annotation
218
+ # Returns array of { name:, email: } hashes from "Name <email>" format
219
+ def parse_name_email_pairs(value)
163
220
  return [] if value.nil? || value.empty?
164
221
 
165
- names = []
222
+ pairs = []
166
223
 
167
- # Split by comma or newline
168
224
  value.split(/[,\n]/).each do |entry|
169
225
  entry = entry.strip
170
226
  next if entry.empty?
171
227
 
172
- # Try to extract name from "Name <email>" format
173
- if (match = entry.match(/^([^<]+)</))
228
+ name = nil
229
+ email = nil
230
+
231
+ if (match = entry.match(/^([^<]+)<([^>]+)>/))
174
232
  name = match[1].strip
175
- names << name unless name.empty?
176
- elsif !entry.include?("@")
177
- # Plain name (no email)
178
- names << entry
233
+ email = match[2].strip
234
+ elsif entry.include?("@")
235
+ email = entry.strip
236
+ else
237
+ name = entry.strip
179
238
  end
180
- end
181
-
182
- names
183
- end
184
239
 
185
- # Normalize name for matching
186
- # Converts to lowercase, removes extra spaces, handles common variations
187
- def normalize_name(name)
188
- return nil if name.nil?
240
+ pairs << { name: name, email: email } if name || email
241
+ end
189
242
 
190
- name.to_s
191
- .downcase
192
- .gsub(/\s+/, " ")
193
- .strip
243
+ pairs
194
244
  end
195
245
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Archsight::MCP::ExecuteAnalysisTool < FastMcp::Tool
6
+ tool_name "execute_analysis"
7
+
8
+ description <<~DESC.gsub("\n", " ").strip
9
+ Discover and execute Analysis resources that validate architecture data and produce reports.
10
+
11
+ TWO MODES OF OPERATION:
12
+
13
+ 1. LIST MODE (no name): Returns all available Analysis resources with name, description,
14
+ handler, and timeout. Use this to discover what analyses exist before running them.
15
+
16
+ 2. EXECUTE MODE (name provided): Runs the specified Analysis script in a sandboxed environment
17
+ and returns the results as markdown. The output includes structured findings like tables,
18
+ lists, warnings, and errors generated by the script.
19
+ DESC
20
+
21
+ arguments do
22
+ optional(:name).filled(:string).description(
23
+ "Analysis name to execute (e.g., 'Analysis:Service:TeamOwnership'). " \
24
+ "Omit to list all available analyses."
25
+ )
26
+ optional(:verbose).filled(:bool).description(
27
+ "When true, tables and lists in output are not truncated. Default: false."
28
+ )
29
+ end
30
+
31
+ def call(name: nil, verbose: false)
32
+ if name.nil?
33
+ list_analyses
34
+ else
35
+ execute_analysis(name, verbose: verbose)
36
+ end
37
+ rescue StandardError => e
38
+ error_response(e.message)
39
+ end
40
+
41
+ private
42
+
43
+ def list_analyses
44
+ db = Archsight::MCP.db
45
+ analyses = db.instances_by_kind("Analysis").values
46
+
47
+ resources = analyses.map do |analysis|
48
+ {
49
+ name: analysis.name,
50
+ description: analysis.annotations["analysis/description"] || analysis.annotations["architecture/description"],
51
+ handler: analysis.annotations["analysis/handler"] || "ruby",
52
+ timeout: analysis.annotations["analysis/timeout"] || "30s"
53
+ }
54
+ end
55
+
56
+ JSON.pretty_generate(
57
+ total: resources.size,
58
+ analyses: resources
59
+ )
60
+ end
61
+
62
+ def execute_analysis(name, verbose: false)
63
+ require "archsight/analysis"
64
+
65
+ db = Archsight::MCP.db
66
+ analysis = db.instance_by_kind("Analysis", name)
67
+ return error_response("Analysis not found: #{name}") unless analysis
68
+
69
+ executor = Archsight::Analysis::Executor.new(db)
70
+ result = executor.execute(analysis)
71
+
72
+ if result.success?
73
+ JSON.pretty_generate(
74
+ name: result.name,
75
+ success: true,
76
+ duration: result.duration,
77
+ output: result.to_markdown(verbose: verbose),
78
+ has_findings: result.has_findings?,
79
+ warning_count: result.warning_count,
80
+ error_count: result.error_count
81
+ )
82
+ else
83
+ response = {
84
+ name: result.name,
85
+ success: false,
86
+ error: result.error
87
+ }
88
+ partial = result.to_markdown(verbose: verbose)
89
+ response[:partial_output] = partial unless partial.empty?
90
+ JSON.pretty_generate(response)
91
+ end
92
+ end
93
+
94
+ def error_response(message)
95
+ JSON.pretty_generate(
96
+ error: "Error",
97
+ message: message
98
+ )
99
+ end
100
+ end
data/lib/archsight/mcp.rb CHANGED
@@ -4,3 +4,4 @@ require_relative "mcp/base"
4
4
  require_relative "mcp/query_tool"
5
5
  require_relative "mcp/analyze_resource_tool"
6
6
  require_relative "mcp/resource_doc_tool"
7
+ require_relative "mcp/execute_analysis_tool"