skunk 0.5.3 → 0.6.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.
@@ -1,96 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "erb"
4
3
  require "rubycritic/commands/status_reporter"
5
- require "terminal-table"
6
4
 
7
5
  module Skunk
8
6
  module Command
9
- # Knows how to report status for stinky files
7
+ # Extends RubyCritic::Command::StatusReporter to silence the status message
10
8
  class StatusReporter < RubyCritic::Command::StatusReporter
11
9
  attr_accessor :analysed_modules
12
10
 
13
- HEADINGS = %w[file skunk_score churn_times_cost churn cost coverage].freeze
14
- HEADINGS_WITHOUT_FILE = HEADINGS - %w[file]
15
- HEADINGS_WITHOUT_FILE_WIDTH = HEADINGS_WITHOUT_FILE.size * 17 # padding
16
-
17
- TEMPLATE = ERB.new(<<-TEMPL
18
- <%= _ttable %>\n
19
- SkunkScore Total: <%= total_skunk_score %>
20
- Modules Analysed: <%= analysed_modules_count %>
21
- SkunkScore Average: <%= skunk_score_average %>
22
- <% if worst %>Worst SkunkScore: <%= worst.skunk_score %> (<%= worst.pathname %>)<% end %>
23
-
24
- Generated with Skunk v<%= Skunk::VERSION %>
25
- TEMPL
26
- )
27
-
28
- # Returns a status message with a table of all analysed_modules and
29
- # a skunk score average
30
- def update_status_message
31
- opts = table_options.merge(headings: HEADINGS, rows: table)
32
-
33
- _ttable = Terminal::Table.new(opts)
34
-
35
- @status_message = TEMPLATE.result(binding)
36
- end
37
-
38
- private
39
-
40
- def analysed_modules_count
41
- @analysed_modules_count ||= non_test_modules.count
42
- end
43
-
44
- def non_test_modules
45
- @non_test_modules ||= analysed_modules.reject do |a_module|
46
- module_path = a_module.pathname.dirname.to_s
47
- module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec")
48
- end
49
- end
50
-
51
- def worst
52
- @worst ||= sorted_modules.first
11
+ def initialize(options = {})
12
+ super(options)
53
13
  end
54
14
 
55
- def sorted_modules
56
- @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse!
57
- end
58
-
59
- def total_skunk_score
60
- @total_skunk_score ||= non_test_modules.sum(&:skunk_score)
61
- end
62
-
63
- def total_churn_times_cost
64
- non_test_modules.sum(&:churn_times_cost)
65
- end
66
-
67
- def skunk_score_average
68
- return 0 if analysed_modules_count.zero?
69
-
70
- (total_skunk_score.to_d / analysed_modules_count).to_f.round(2)
71
- end
72
-
73
- def table_options
74
- max = sorted_modules.max_by { |a_mod| a_mod.pathname.to_s.length }
75
- width = max.pathname.to_s.length + HEADINGS_WITHOUT_FILE_WIDTH
76
- {
77
- style: {
78
- width: width
79
- }
80
- }
81
- end
82
-
83
- def table
84
- sorted_modules.map do |a_mod|
85
- [
86
- a_mod.pathname,
87
- a_mod.skunk_score,
88
- a_mod.churn_times_cost,
89
- a_mod.churn,
90
- a_mod.cost.round(2),
91
- a_mod.coverage.round(2)
92
- ]
93
- end
15
+ def update_status_message
16
+ @status_message = ""
94
17
  end
95
18
  end
96
19
  end
@@ -23,7 +23,7 @@ module Skunk
23
23
  response = post_payload
24
24
 
25
25
  @status_message =
26
- if Net::HTTPOK === response
26
+ if response.is_a?(Net::HTTPOK)
27
27
  data = JSON.parse response.body
28
28
  "Shared at: #{File.join(base_url, data['id'])}"
29
29
  else
@@ -33,22 +33,22 @@ module Skunk
33
33
 
34
34
  private
35
35
 
36
- # :reek:UtilityFunction
37
36
  def base_url
38
- ENV["SHARE_URL"] || DEFAULT_URL
37
+ share_url_empty? ? DEFAULT_URL : share_url
39
38
  end
40
39
 
41
40
  def json_summary
42
41
  result = {
43
- total_skunk_score: total_skunk_score,
44
- analysed_modules_count: analysed_modules_count,
45
- skunk_score_average: skunk_score_average,
42
+ total_skunk_score: analysed_modules.skunk_score_total,
43
+ analysed_modules_count: analysed_modules.analysed_modules_count,
44
+ skunk_score_average: analysed_modules.skunk_score_average,
46
45
  skunk_version: Skunk::VERSION
47
46
  }
48
47
 
49
- if worst
48
+ if analysed_modules&.worst_module
49
+ worst = analysed_modules.worst_module
50
50
  result[:worst_skunk_score] = {
51
- file: worst.pathname.to_s,
51
+ file: worst.pathname,
52
52
  skunk_score: worst.skunk_score
53
53
  }
54
54
  end
@@ -57,12 +57,26 @@ module Skunk
57
57
  end
58
58
 
59
59
  def json_results
60
- sorted_modules.map(&:to_hash)
60
+ analysed_modules.sorted_modules.map(&:to_hash)
61
61
  end
62
62
 
63
63
  # :reek:UtilityFunction
64
64
  def not_sharing?
65
- ENV["SHARE"] != "true" && ENV["SHARE_URL"].to_s == ""
65
+ !share_enabled? && share_url_empty?
66
+ end
67
+
68
+ # @return [Boolean] Check if sharing is enabled via environment variable
69
+ def share_enabled?
70
+ ENV["SHARE"] == "true"
71
+ end
72
+
73
+ # @return [Boolean] Check if share URL is empty
74
+ def share_url_empty?
75
+ share_url.empty?
76
+ end
77
+
78
+ def share_url
79
+ @share_url ||= ENV["SHARE_URL"].to_s
66
80
  end
67
81
 
68
82
  def payload
@@ -79,6 +93,7 @@ module Skunk
79
93
  def post_payload
80
94
  req = Net::HTTP::Post.new(url)
81
95
  req.body = payload
96
+ req.content_type = "application/json"
82
97
 
83
98
  http = Net::HTTP.new(url.hostname, url.port)
84
99
  if url.scheme == "https"
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubycritic/configuration"
4
+
5
+ module Skunk
6
+ # Utility module for format validation
7
+ module FormatValidator
8
+ # Supported output formats
9
+ SUPPORTED_FORMATS = %i[json html console].freeze
10
+
11
+ # Check if a format is supported
12
+ # @param format [Symbol] Format to check
13
+ # @return [Boolean] True if format is supported
14
+ def self.supported_format?(format)
15
+ SUPPORTED_FORMATS.include?(format)
16
+ end
17
+
18
+ # Get all supported formats
19
+ # @return [Array<Symbol>] All supported formats
20
+ def self.supported_formats
21
+ SUPPORTED_FORMATS.dup
22
+ end
23
+ end
24
+
25
+ # Configuration class for Skunk that supports formats
26
+ # Similar to RubyCritic::Configuration but focused only on Skunk's needs
27
+ class Configuration
28
+ # Default format
29
+ DEFAULT_FORMAT = :console
30
+
31
+ def initialize
32
+ @formats = [DEFAULT_FORMAT]
33
+ @root = nil
34
+ end
35
+
36
+ def set(options = {})
37
+ self.formats = options[:formats] if options.key?(:formats)
38
+ self.root = options[:root] if options.key?(:root)
39
+ end
40
+
41
+ # Get the configured formats
42
+ # @return [Array<Symbol>] Array of format symbols
43
+ attr_reader :formats
44
+
45
+ # Set the formats with validation
46
+ # @param format_list [Array<Symbol>, Symbol] Format(s) to set
47
+ def formats=(format_list)
48
+ format_array = Array(format_list)
49
+ @formats = format_array.select { |format| FormatValidator.supported_format?(format) }
50
+ @formats = [DEFAULT_FORMAT] if @formats.empty?
51
+ end
52
+
53
+ # Path where reports are written. An explicit value (from --out) wins;
54
+ # otherwise it follows RubyCritic::Config.root, which RubyCritic sets on
55
+ # every run, so a stale value from an earlier run is never reused.
56
+ def root
57
+ @root || RubyCritic::Config.root || File.expand_path("tmp/rubycritic", Dir.pwd)
58
+ end
59
+
60
+ # @return [Boolean] True when the output path was set explicitly
61
+ def root_set?
62
+ !!@root
63
+ end
64
+
65
+ def root=(path)
66
+ path_str = path.to_s
67
+ @root = path_str.empty? ? nil : File.expand_path(path_str)
68
+ end
69
+
70
+ # Add a format to the existing list
71
+ # @param format [Symbol] Format to add
72
+ def add_format(format)
73
+ return unless FormatValidator.supported_format?(format)
74
+
75
+ @formats << format unless @formats.include?(format)
76
+ end
77
+
78
+ # Remove a format from the list
79
+ # @param format [Symbol] Format to remove
80
+ def remove_format(format)
81
+ @formats.delete(format)
82
+ @formats = [DEFAULT_FORMAT] if @formats.empty?
83
+ end
84
+
85
+ # Check if a format is supported
86
+ # @param format [Symbol] Format to check
87
+ # @return [Boolean] True if format is supported
88
+ def supported_format?(format)
89
+ FormatValidator.supported_format?(format)
90
+ end
91
+
92
+ # Get all supported formats
93
+ # @return [Array<Symbol>] All supported formats
94
+ def supported_formats
95
+ FormatValidator.supported_formats
96
+ end
97
+
98
+ # Reset to default configuration
99
+ def reset
100
+ @formats = [DEFAULT_FORMAT]
101
+ @root = nil
102
+ end
103
+ end
104
+
105
+ # Config module that delegates to Configuration instance
106
+ # Similar to RubyCritic::Config pattern
107
+ module Config
108
+ def self.configuration
109
+ @configuration ||= Configuration.new
110
+ end
111
+
112
+ def self.set(options = {})
113
+ configuration.set(options)
114
+ end
115
+
116
+ def self.method_missing(method, *args, &block)
117
+ if configuration.respond_to?(method)
118
+ configuration.public_send(method, *args, &block)
119
+ else
120
+ super
121
+ end
122
+ end
123
+
124
+ def self.respond_to_missing?(symbol, include_private = false)
125
+ configuration.respond_to?(symbol, include_private) || super
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "terminal-table"
5
+
6
+ module Skunk
7
+ module Generator
8
+ module Console
9
+ # Generates a console report for the analysed modules.
10
+ class Simple
11
+ def initialize(analysed_modules)
12
+ @analysed_modules = analysed_modules
13
+ end
14
+
15
+ HEADINGS = %w[file skunk_score churn_times_cost churn cost coverage].freeze
16
+ HEADINGS_WITHOUT_FILE = HEADINGS - %w[file]
17
+ HEADINGS_WITHOUT_FILE_WIDTH = HEADINGS_WITHOUT_FILE.size * 17 # padding
18
+
19
+ TEMPLATE = ERB.new(<<~TEMPL
20
+ <%= _ttable %>
21
+
22
+ SkunkScore Total: <%= total_skunk_score %>
23
+ Modules Analysed: <%= analysed_modules_count %>
24
+ SkunkScore Average: <%= skunk_score_average %>
25
+ <% if worst %>Worst SkunkScore: <%= worst.skunk_score %> (<%= worst.pathname %>)<% end %>
26
+
27
+ Generated with Skunk v<%= Skunk::VERSION %>
28
+ TEMPL
29
+ )
30
+
31
+ def render
32
+ opts = table_options.merge(headings: HEADINGS, rows: table)
33
+ _ttable = Terminal::Table.new(opts)
34
+ TEMPLATE.result(binding)
35
+ end
36
+
37
+ private
38
+
39
+ def analysed_modules_count
40
+ @analysed_modules.analysed_modules_count
41
+ end
42
+
43
+ def worst
44
+ @analysed_modules.worst_module
45
+ end
46
+
47
+ def sorted_modules
48
+ @analysed_modules.sorted_modules
49
+ end
50
+
51
+ def total_skunk_score
52
+ @analysed_modules.skunk_score_total
53
+ end
54
+
55
+ def total_churn_times_cost
56
+ @analysed_modules.total_churn_times_cost
57
+ end
58
+
59
+ def skunk_score_average
60
+ @analysed_modules.skunk_score_average
61
+ end
62
+
63
+ def table_options
64
+ return { style: { width: 100 } } if sorted_modules.empty?
65
+
66
+ max = sorted_modules.max_by { |a_mod| a_mod.pathname.to_s.length }
67
+ width = max.pathname.to_s.length + HEADINGS_WITHOUT_FILE_WIDTH
68
+ {
69
+ style: {
70
+ width: width
71
+ }
72
+ }
73
+ end
74
+
75
+ def table
76
+ @analysed_modules.files_as_hash.map { |file_hash| self.class.format_hash_row(file_hash) }
77
+ end
78
+
79
+ def self.format_hash_row(file_hash)
80
+ [
81
+ file_hash[:file],
82
+ file_hash[:skunk_score],
83
+ file_hash[:churn_times_cost],
84
+ file_hash[:churn],
85
+ file_hash[:cost],
86
+ file_hash[:coverage]
87
+ ]
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "terminal-table"
5
+ require "pathname"
6
+ require "fileutils"
7
+
8
+ require "skunk/generators/console/simple"
9
+ require "skunk/config"
10
+
11
+ module Skunk
12
+ module Generator
13
+ # Generates a console report for the analysed modules.
14
+ class ConsoleReport
15
+ FILE_NAME = "skunk_report.txt"
16
+
17
+ def initialize(analysed_modules)
18
+ @analysed_modules = analysed_modules
19
+ end
20
+
21
+ def generate_report
22
+ content = generator.render
23
+ puts content
24
+ write_file(content) if Skunk::Config.root_set?
25
+ end
26
+
27
+ private
28
+
29
+ def generator
30
+ @generator ||= Skunk::Generator::Console::Simple.new(@analysed_modules)
31
+ end
32
+
33
+ # Only written when --out is given, so a plain `skunk` run stays free of filesystem side effects
34
+ def write_file(content)
35
+ FileUtils.mkdir_p(file_directory)
36
+ File.write(file_pathname, content)
37
+ end
38
+
39
+ def file_directory
40
+ @file_directory ||= Pathname.new(Skunk::Config.root)
41
+ end
42
+
43
+ def file_pathname
44
+ file_directory.join(FILE_NAME)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skunk
4
+ module Generator
5
+ module Html
6
+ # Data object for individual file information in the HTML overview report
7
+ class FileData
8
+ attr_reader :file, :skunk_score, :churn_times_cost, :churn, :cost, :coverage
9
+
10
+ def initialize(module_data)
11
+ @file = PathTruncator.truncate(module_data.pathname)
12
+ @skunk_score = module_data.skunk_score
13
+ @churn_times_cost = module_data.churn_times_cost
14
+ @churn = module_data.churn
15
+ @cost = module_data.cost.round(2)
16
+ @coverage = module_data.coverage.round(2)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubycritic/generators/html/base"
4
+
5
+ require "skunk/generators/html/path_truncator"
6
+ require "skunk/generators/html/file_data"
7
+ require "skunk/rubycritic/analysed_modules_collection"
8
+
9
+ module Skunk
10
+ module Generator
11
+ module Html
12
+ # Generates an HTML overview report for the analysed modules.
13
+ class Overview < RubyCritic::Generator::Html::Base
14
+ def self.erb_template(template_path)
15
+ ERB.new(File.read(File.join(TEMPLATES_DIR, template_path)))
16
+ end
17
+
18
+ TEMPLATES_DIR = File.expand_path("templates", __dir__)
19
+ TEMPLATE = erb_template("skunk_overview.html.erb")
20
+
21
+ def initialize(analysed_modules)
22
+ @analysed_modules = analysed_modules
23
+ @generated_at = Time.now.strftime("%Y-%m-%d %H:%M:%S")
24
+ @skunk_version = Skunk::VERSION
25
+ end
26
+
27
+ def file_name
28
+ "skunk_report.html"
29
+ end
30
+
31
+ def render
32
+ TEMPLATE.result(binding)
33
+ end
34
+
35
+ def analysed_modules_count
36
+ @analysed_modules.analysed_modules_count
37
+ end
38
+
39
+ def skunk_score_total
40
+ @analysed_modules.skunk_score_total
41
+ end
42
+
43
+ def skunk_score_average
44
+ @analysed_modules.skunk_score_average
45
+ end
46
+
47
+ def files
48
+ @files ||= @analysed_modules.sorted_modules.map do |module_data|
49
+ FileData.new(module_data)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def root_directory
56
+ @root_directory ||= Pathname.new(Skunk::Config.root)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skunk
4
+ module Generator
5
+ module Html
6
+ # Utility class for truncating file paths to show only the relevant project structure
7
+ class PathTruncator
8
+ attr_reader :file_path
9
+
10
+ # Common project folder names to truncate from
11
+ PROJECT_FOLDERS = %w[app lib src test spec features db].freeze
12
+
13
+ # Truncates a file path to show only the relevant project structure
14
+ # starting from the first project folder found
15
+ #
16
+ # @param file_path [String] The full file path to truncate
17
+ # @return [String] The truncated path starting from the project folder
18
+ # :reek:NilCheck
19
+ def self.truncate(file_path)
20
+ return file_path if file_path.nil?
21
+
22
+ new(file_path).truncate
23
+ end
24
+
25
+ def initialize(file_path)
26
+ @file_path = file_path.to_s
27
+ end
28
+
29
+ # :reek:TooManyStatements
30
+ def truncate
31
+ return file_path if file_path.empty?
32
+
33
+ path_parts = file_path.split("/")
34
+ folder_index = path_parts.find_index do |part|
35
+ PROJECT_FOLDERS.include?(part)
36
+ end
37
+
38
+ if folder_index
39
+ # rubocop:disable Style/SlicingWithRange
40
+ path_parts[folder_index..-1].join("/")
41
+ # rubocop:enable Style/SlicingWithRange
42
+ else
43
+ file_path
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end