archsight 0.1.2 → 0.1.3

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -5
  3. data/lib/archsight/analysis/executor.rb +112 -0
  4. data/lib/archsight/analysis/result.rb +174 -0
  5. data/lib/archsight/analysis/sandbox.rb +319 -0
  6. data/lib/archsight/analysis.rb +11 -0
  7. data/lib/archsight/annotations/architecture_annotations.rb +2 -2
  8. data/lib/archsight/cli.rb +163 -0
  9. data/lib/archsight/database.rb +6 -2
  10. data/lib/archsight/helpers/analysis_renderer.rb +83 -0
  11. data/lib/archsight/helpers/formatting.rb +95 -0
  12. data/lib/archsight/helpers.rb +20 -4
  13. data/lib/archsight/import/concurrent_progress.rb +341 -0
  14. data/lib/archsight/import/executor.rb +466 -0
  15. data/lib/archsight/import/git_analytics.rb +626 -0
  16. data/lib/archsight/import/handler.rb +263 -0
  17. data/lib/archsight/import/handlers/github.rb +161 -0
  18. data/lib/archsight/import/handlers/gitlab.rb +202 -0
  19. data/lib/archsight/import/handlers/jira_base.rb +189 -0
  20. data/lib/archsight/import/handlers/jira_discover.rb +161 -0
  21. data/lib/archsight/import/handlers/jira_metrics.rb +179 -0
  22. data/lib/archsight/import/handlers/openapi_schema_parser.rb +279 -0
  23. data/lib/archsight/import/handlers/repository.rb +439 -0
  24. data/lib/archsight/import/handlers/rest_api.rb +293 -0
  25. data/lib/archsight/import/handlers/rest_api_index.rb +183 -0
  26. data/lib/archsight/import/progress.rb +91 -0
  27. data/lib/archsight/import/registry.rb +54 -0
  28. data/lib/archsight/import/shared_file_writer.rb +67 -0
  29. data/lib/archsight/import/team_matcher.rb +195 -0
  30. data/lib/archsight/import.rb +14 -0
  31. data/lib/archsight/resources/analysis.rb +91 -0
  32. data/lib/archsight/resources/application_component.rb +2 -2
  33. data/lib/archsight/resources/application_service.rb +12 -12
  34. data/lib/archsight/resources/business_product.rb +12 -12
  35. data/lib/archsight/resources/data_object.rb +1 -1
  36. data/lib/archsight/resources/import.rb +79 -0
  37. data/lib/archsight/resources/technology_artifact.rb +23 -2
  38. data/lib/archsight/version.rb +1 -1
  39. data/lib/archsight/web/api/docs.rb +17 -0
  40. data/lib/archsight/web/api/json_helpers.rb +164 -0
  41. data/lib/archsight/web/api/openapi/spec.yaml +500 -0
  42. data/lib/archsight/web/api/routes.rb +101 -0
  43. data/lib/archsight/web/application.rb +66 -43
  44. data/lib/archsight/web/doc/import.md +458 -0
  45. data/lib/archsight/web/doc/index.md.erb +1 -0
  46. data/lib/archsight/web/public/css/artifact.css +10 -0
  47. data/lib/archsight/web/public/css/graph.css +14 -0
  48. data/lib/archsight/web/public/css/instance.css +489 -0
  49. data/lib/archsight/web/views/api_docs.erb +19 -0
  50. data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +14 -8
  51. data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +74 -0
  52. data/lib/archsight/web/views/partials/instance/_analysis_result.haml +64 -0
  53. data/lib/archsight/web/views/partials/instance/_detail.haml +7 -3
  54. data/lib/archsight/web/views/partials/instance/_import_detail.haml +87 -0
  55. data/lib/archsight/web/views/partials/instance/_relations.haml +4 -4
  56. data/lib/archsight/web/views/partials/layout/_content.haml +4 -0
  57. data/lib/archsight/web/views/partials/layout/_navigation.haml +6 -5
  58. metadata +78 -1
data/lib/archsight/cli.rb CHANGED
@@ -4,6 +4,10 @@ require "thor"
4
4
 
5
5
  module Archsight
6
6
  class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
7
11
  class_option :resources,
8
12
  aliases: "-r",
9
13
  type: :string,
@@ -12,11 +16,21 @@ module Archsight
12
16
  desc "web", "Start the web server"
13
17
  option :port, aliases: "-p", type: :numeric, default: 4567, desc: "Port to listen on"
14
18
  option :host, aliases: "-H", type: :string, default: "localhost", desc: "Host to bind to"
19
+ option :production, type: :boolean, default: false, desc: "Run in production mode"
20
+ option :disable_reload, type: :boolean, default: false, desc: "Disable the reload button in the UI"
21
+ option :enable_logging, type: :boolean, default: nil, desc: "Enable request logging (default: false in dev, true in prod)"
15
22
  def web
16
23
  configure_resources
17
24
  require "archsight/web/application"
25
+
26
+ env = options[:production] ? :production : :development
27
+ Archsight::Web::Application.configure_environment!(env, logging: options[:enable_logging])
28
+ Archsight::Web::Application.set :reload_enabled, !options[:disable_reload]
18
29
  Archsight::Web::Application.setup_mcp!
19
30
  Archsight::Web::Application.run!(port: options[:port], bind: options[:host])
31
+ rescue Archsight::ResourceError => e
32
+ display_error_with_context(e.to_s)
33
+ exit 1
20
34
  end
21
35
 
22
36
  desc "lint", "Validate architecture resources"
@@ -71,6 +85,80 @@ module Archsight
71
85
  binding.irb
72
86
  end
73
87
 
88
+ desc "import", "Execute pending imports"
89
+ option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Verbose output"
90
+ option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "Show execution plan without running"
91
+ option :filter, aliases: "-f", type: :string, desc: "Filter imports by name (regex pattern)"
92
+ option :force, aliases: "-F", type: :boolean, default: false, desc: "Ignore cache and re-run all imports"
93
+ def import
94
+ configure_resources
95
+ require "archsight/database"
96
+ require "archsight/import/executor"
97
+
98
+ resources_dir = Archsight.resources_dir
99
+
100
+ # Load all handlers
101
+ require_import_handlers
102
+
103
+ # Create database that loads from resources directory
104
+ # Only load Import resources to avoid validation errors on incomplete resources
105
+ db = Archsight::Database.new(resources_dir, verbose: options[:verbose], only_kinds: ["Import"], verify: false)
106
+
107
+ if options[:dry_run]
108
+ puts "Execution Plan:"
109
+ executor = Archsight::Import::Executor.new(
110
+ database: db,
111
+ resources_dir: resources_dir,
112
+ verbose: true,
113
+ filter: options[:filter],
114
+ force: options[:force]
115
+ )
116
+ executor.execution_plan
117
+ else
118
+ executor = Archsight::Import::Executor.new(
119
+ database: db,
120
+ resources_dir: resources_dir,
121
+ verbose: options[:verbose],
122
+ filter: options[:filter],
123
+ force: options[:force]
124
+ )
125
+ executor.run!
126
+ puts "All imports completed successfully."
127
+ end
128
+ rescue Archsight::Import::InterruptedError
129
+ # Graceful shutdown already handled by executor
130
+ exit 130
131
+ rescue Archsight::Import::DeadlockError => e
132
+ puts "Error: #{e.message}"
133
+ exit 1
134
+ rescue Archsight::Import::ImportError => e
135
+ puts "Error: #{e.message}"
136
+ exit 1
137
+ rescue Archsight::ResourceError => e
138
+ display_error_with_context(e.to_s)
139
+ exit 1
140
+ end
141
+
142
+ desc "analyze", "Execute analysis scripts"
143
+ option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Verbose output"
144
+ option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "List analyses without running"
145
+ option :filter, aliases: "-f", type: :string, desc: "Filter analyses by name (regex pattern)"
146
+ def analyze
147
+ configure_resources
148
+ require "archsight/database"
149
+ require "archsight/analysis"
150
+
151
+ db = load_database_for_analysis
152
+ analyses = filter_analyses(db)
153
+
154
+ return puts("No analyses found#{" matching '#{options[:filter]}'" if options[:filter]}.") if analyses.empty?
155
+ return print_analysis_dry_run(analyses) if options[:dry_run]
156
+
157
+ results = execute_analyses(db, analyses)
158
+ print_analysis_results(results)
159
+ exit 1 if results.any?(&:failed?)
160
+ end
161
+
74
162
  desc "version", "Show version"
75
163
  def version
76
164
  puts "archsight #{Archsight::VERSION}"
@@ -84,6 +172,81 @@ module Archsight
84
172
  Archsight.resources_dir = options[:resources] if options[:resources]
85
173
  end
86
174
 
175
+ def require_import_handlers
176
+ handlers_dir = File.expand_path("import/handlers", __dir__)
177
+ Dir.glob(File.join(handlers_dir, "*.rb")).each do |handler_file|
178
+ require handler_file
179
+ end
180
+ end
181
+
182
+ def load_database_for_analysis
183
+ db = Archsight::Database.new(Archsight.resources_dir, verbose: options[:verbose])
184
+ db.reload!
185
+ db
186
+ rescue Archsight::ResourceError => e
187
+ display_error_with_context(e.to_s)
188
+ exit 1
189
+ end
190
+
191
+ def filter_analyses(db)
192
+ analyses = db.instances_by_kind("Analysis").values
193
+ analyses = analyses.select { |a| Regexp.new(options[:filter], Regexp::IGNORECASE).match?(a.name) } if options[:filter]
194
+ analyses.reject { |a| a.annotations["analysis/enabled"] == "false" }
195
+ end
196
+
197
+ def print_analysis_dry_run(analyses)
198
+ puts "Analyses to run#{" (filter: #{options[:filter]})" if options[:filter]}:"
199
+ analyses.sort_by(&:name).each_with_index do |analysis, idx|
200
+ timeout = analysis.annotations["analysis/timeout"] || "30s"
201
+ desc = analysis.annotations["analysis/description"] || "(no description)"
202
+ puts " #{idx + 1}. #{analysis.name} [#{timeout}]"
203
+ puts " #{desc}"
204
+ end
205
+ end
206
+
207
+ def execute_analyses(db, analyses)
208
+ executor = Archsight::Analysis::Executor.new(db)
209
+ analyses.map { |analysis| executor.execute(analysis) }
210
+ end
211
+
212
+ def print_analysis_results(results)
213
+ require "tty-markdown"
214
+
215
+ results.each do |result|
216
+ print_single_result(result)
217
+ puts ""
218
+ end
219
+
220
+ summary_md = build_analysis_summary_markdown(results)
221
+ puts TTY::Markdown.parse(summary_md)
222
+ end
223
+
224
+ def print_single_result(result)
225
+ # Print status header
226
+ header = "# #{result.status_emoji} #{result.name}"
227
+ header += " (#{result.duration_str})" unless result.duration_str.empty?
228
+ puts TTY::Markdown.parse(header)
229
+
230
+ # Print error if failed
231
+ puts TTY::Markdown.parse(result.error_markdown(verbose: options[:verbose])) if result.failed?
232
+
233
+ # Print script output
234
+ output = result.to_s(verbose: options[:verbose])
235
+ puts output unless output.empty?
236
+ end
237
+
238
+ def build_analysis_summary_markdown(results)
239
+ passed = results.count(&:success?)
240
+ failed = results.count(&:failed?)
241
+ with_findings = results.count(&:has_findings?)
242
+
243
+ lines = ["---", "", "# Summary", ""]
244
+ lines << "- ✅ **#{passed}** passed"
245
+ lines << "- ❌ **#{failed}** failed" if failed.positive?
246
+ lines << "- ⚠️ **#{with_findings}** with findings" if with_findings.positive?
247
+ lines.join("\n")
248
+ end
249
+
87
250
  def list_kinds
88
251
  puts "Available resource kinds:\n\n"
89
252
  Archsight::Resources.resource_classes.each_key { |kind| puts " - #{kind}" }
@@ -43,13 +43,14 @@ module Archsight
43
43
  # of the structure. The loading and parsing of files will raise errors
44
44
  # if invalid data is passed.
45
45
  class Database
46
- attr_accessor :instances, :verbose, :verify, :compute_annotations
46
+ attr_accessor :instances, :verbose, :verify, :compute_annotations, :only_kinds
47
47
 
48
- def initialize(path, verbose: false, verify: true, compute_annotations: true)
48
+ def initialize(path, verbose: false, verify: true, compute_annotations: true, only_kinds: nil)
49
49
  @path = path
50
50
  @verbose = verbose
51
51
  @verify = verify
52
52
  @compute_annotations = compute_annotations
53
+ @only_kinds = only_kinds
53
54
  @instances = {}
54
55
  end
55
56
 
@@ -127,6 +128,9 @@ module Archsight
127
128
  obj = node.to_ruby
128
129
  next unless obj # skip empty / unknown documents
129
130
 
131
+ # Skip resources that don't match only_kinds filter
132
+ next if @only_kinds && !@only_kinds.include?(obj["kind"])
133
+
130
134
  self << create_valid_instance(obj)
131
135
  end
132
136
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+
5
+ module Archsight
6
+ module Helpers
7
+ # AnalysisRenderer provides HTML rendering for analysis result sections
8
+ module AnalysisRenderer
9
+ module_function
10
+
11
+ # HTML escape helper
12
+ def escape_html(text)
13
+ Rack::Utils.escape_html(text.to_s)
14
+ end
15
+
16
+ # Render a single analysis section to HTML
17
+ # @param section [Hash] Section data with :type and content
18
+ # @param markdown_renderer [Proc, nil] Optional proc to render markdown
19
+ def render_analysis_section(section, markdown_renderer: nil)
20
+ case section[:type]
21
+ when :heading
22
+ render_heading(section)
23
+ when :text
24
+ render_text(section, markdown_renderer)
25
+ when :message
26
+ render_message(section)
27
+ when :table
28
+ render_table(section)
29
+ when :list
30
+ render_list(section)
31
+ when :code
32
+ render_code(section)
33
+ else
34
+ ""
35
+ end
36
+ end
37
+
38
+ # Render analysis table section to HTML
39
+ def render_analysis_table(section)
40
+ render_table(section)
41
+ end
42
+
43
+ # Private rendering methods
44
+
45
+ def render_heading(section)
46
+ %(<div class="analysis-heading level-#{section[:level]}">#{escape_html(section[:text])}</div>)
47
+ end
48
+
49
+ def render_text(section, markdown_renderer)
50
+ content = markdown_renderer ? markdown_renderer.call(section[:content]) : escape_html(section[:content])
51
+ %(<div class="analysis-text">#{content}</div>)
52
+ end
53
+
54
+ def render_message(section)
55
+ icon = case section[:level]
56
+ when :error then "xmark-circle"
57
+ when :warning then "warning-triangle"
58
+ else "info-circle"
59
+ end
60
+ %(<div class="analysis-message message-#{section[:level]}"><i class="iconoir-#{icon}"></i> #{escape_html(section[:message])}</div>)
61
+ end
62
+
63
+ def render_table(section)
64
+ headers = section[:headers].map { |h| "<th>#{escape_html(h)}</th>" }.join
65
+ rows = section[:rows].map do |row|
66
+ cells = row.map { |cell| "<td>#{escape_html(cell.to_s)}</td>" }.join
67
+ "<tr>#{cells}</tr>"
68
+ end.join
69
+ %(<div class="analysis-table-wrapper"><table><thead><tr>#{headers}</tr></thead><tbody>#{rows}</tbody></table></div>)
70
+ end
71
+
72
+ def render_list(section)
73
+ items = section[:items].map { |item| "<li>#{escape_html(item)}</li>" }.join
74
+ %(<ul class="analysis-list">#{items}</ul>)
75
+ end
76
+
77
+ def render_code(section)
78
+ lang_class = section[:lang] ? " class=\"language-#{section[:lang]}\"" : ""
79
+ %(<pre class="code"><code#{lang_class}>#{escape_html(section[:content])}</code></pre>)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Helpers
5
+ # Formatting provides string and number formatting utilities
6
+ module Formatting
7
+ module_function
8
+
9
+ # Convert string to class name format
10
+ def classify(val)
11
+ val.to_s.split("-").map(&:capitalize).join
12
+ end
13
+
14
+ # Format number as euro currency
15
+ def to_euro(num)
16
+ rounded = (num * 100).round / 100.0
17
+ parts = format("%.2f", rounded).split(".")
18
+ parts[0] = parts[0].reverse.scan(/\d{1,3}/).join(",").reverse
19
+ "€#{parts.join(".")}"
20
+ end
21
+
22
+ # AI-adjusted project estimate configuration
23
+ # Source values stored separately for easy adjustment
24
+ AI_ESTIMATE_CONFIG = {
25
+ cocomo_salary: 150_000, # COCOMO assumes US salary in USD
26
+ target_salary: 80_000, # Target salary in EUR
27
+ ai_cost_multiplier: 3.0, # AI productivity boost for cost
28
+ ai_schedule_multiplier: 2.5, # AI productivity boost for schedule
29
+ ai_team_multiplier: 3.0 # AI productivity boost for team size
30
+ }.freeze
31
+
32
+ # Apply AI adjustment factors to project estimates
33
+ # @param type [Symbol] :cost, :schedule, or :team
34
+ # @param value [Numeric, nil] Raw estimate value
35
+ # @return [Numeric, nil] Adjusted value
36
+ def ai_adjusted_estimate(type, value)
37
+ return nil if value.nil?
38
+
39
+ cfg = AI_ESTIMATE_CONFIG
40
+ salary_ratio = cfg[:target_salary].to_f / cfg[:cocomo_salary]
41
+
42
+ adjusted = case type
43
+ when :cost
44
+ value.to_f * salary_ratio / cfg[:ai_cost_multiplier]
45
+ when :schedule
46
+ value.to_f / cfg[:ai_schedule_multiplier]
47
+ when :team
48
+ (value.to_f / cfg[:ai_team_multiplier]).ceil
49
+ else
50
+ raise ArgumentError, "Unknown estimate type: #{type}"
51
+ end
52
+
53
+ type == :team ? adjusted.to_i : adjusted
54
+ end
55
+
56
+ # Convert git URL to HTTPS URL
57
+ def http_git(repo_url)
58
+ repo_url.gsub(/.git$/, "")
59
+ .gsub(":", "/")
60
+ .gsub("git@", "https://")
61
+ end
62
+
63
+ # Format number with thousands delimiter
64
+ def number_with_delimiter(num)
65
+ num.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
66
+ end
67
+
68
+ # Convert timestamp to human-readable relative time
69
+ def time_ago(timestamp)
70
+ return nil unless timestamp
71
+
72
+ time = timestamp.is_a?(Time) ? timestamp : Time.parse(timestamp.to_s)
73
+ seconds = (Time.now - time).to_i
74
+
75
+ units = [
76
+ [60, "second"],
77
+ [60, "minute"],
78
+ [24, "hour"],
79
+ [7, "day"],
80
+ [4, "week"],
81
+ [12, "month"],
82
+ [Float::INFINITY, "year"]
83
+ ]
84
+
85
+ value = seconds
86
+ units.each do |divisor, unit|
87
+ return "just now" if unit == "second" && value < 10
88
+ return "#{value} #{unit}#{"s" if value != 1} ago" if value < divisor
89
+
90
+ value /= divisor
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "helpers/formatting"
4
+ require_relative "helpers/analysis_renderer"
5
+
3
6
  module Archsight
4
7
  # Helpers provides utility functions for the architecture tool
5
8
  module Helpers
9
+ # Include sub-modules for direct access
10
+ extend Formatting
11
+ extend AnalysisRenderer
12
+
6
13
  module_function
7
14
 
8
15
  # Make path relative to resources directory
@@ -45,10 +52,6 @@ module Archsight
45
52
  end
46
53
  end
47
54
 
48
- def classify(val)
49
- val.to_s.split("-").map(&:capitalize).join
50
- end
51
-
52
55
  def deep_merge(hash1, hash2)
53
56
  hash1.dup.merge(hash2) do |_, old_value, new_value|
54
57
  if old_value.is_a?(Hash) && new_value.is_a?(Hash)
@@ -206,5 +209,18 @@ module Archsight
206
209
  (val_a || "").to_s.downcase <=> (val_b || "").to_s.downcase
207
210
  end
208
211
  end
212
+
213
+ # Delegate formatting methods
214
+ def classify(val) = Formatting.classify(val)
215
+ def to_euro(num) = Formatting.to_euro(num)
216
+ def ai_adjusted_estimate(type, value) = Formatting.ai_adjusted_estimate(type, value)
217
+ def http_git(repo_url) = Formatting.http_git(repo_url)
218
+ def number_with_delimiter(num) = Formatting.number_with_delimiter(num)
219
+ def time_ago(timestamp) = Formatting.time_ago(timestamp)
220
+
221
+ # Delegate analysis renderer methods
222
+ def escape_html(text) = AnalysisRenderer.escape_html(text)
223
+ def render_analysis_section(section, **) = AnalysisRenderer.render_analysis_section(section, **)
224
+ def render_analysis_table(section) = AnalysisRenderer.render_analysis_table(section)
209
225
  end
210
226
  end