skunk 0.5.4 → 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.
@@ -0,0 +1,366 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Skunk Report</title>
7
+ <style>
8
+ /* Modern CSS Reset */
9
+ *, *::before, *::after {
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ * {
14
+ margin: 0;
15
+ padding: 0;
16
+ }
17
+
18
+ html, body {
19
+ height: 100%;
20
+ }
21
+
22
+ body {
23
+ line-height: 1.5;
24
+ -webkit-font-smoothing: antialiased;
25
+ }
26
+
27
+ img, picture, video, canvas, svg {
28
+ display: block;
29
+ max-width: 100%;
30
+ }
31
+
32
+ p, h1, h2, h3, h4, h5, h6 {
33
+ overflow-wrap: break-word;
34
+ }
35
+ /* End Modern CSS Reset */
36
+
37
+ :root {
38
+ /* Color Palette */
39
+ --primary-gradient-start:rgb(8, 58, 19);
40
+ --primary-gradient-end: rgb(8, 98, 44);;
41
+ --background-color: #f5f5f5;
42
+ --text-color: #333;
43
+ --text-color-light: #666;
44
+ --white: #ffffff;
45
+ --border-color: #eee;
46
+ --shadow-color: rgba(0,0,0,0.1);
47
+
48
+ --cards-background-color: #f8f9fa;
49
+ --card-background-color: #ffffff;
50
+
51
+ /* Skunk Score Colors */
52
+ --score-high: #e74c3c;
53
+ --score-medium: #f39c12;
54
+ --score-low: #27ae60;
55
+ }
56
+
57
+ body {
58
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
59
+ margin: 0;
60
+ padding: 2rem;
61
+ background-color: var(--background-color);
62
+ color: var(--text-color);
63
+ }
64
+ .container {
65
+ margin: 0 auto;
66
+ background: var(--white);
67
+ border-radius: 2rem;
68
+ box-shadow: 0 2px 10px var(--shadow-color);
69
+ overflow: hidden;
70
+ }
71
+ .header {
72
+ background: linear-gradient(135deg, var(--primary-gradient-start) 0%, var(--primary-gradient-end) 100%);
73
+ color: var(--white);
74
+ padding: 2rem;
75
+ text-align: center;
76
+ display: flex;
77
+ flex-direction: column;
78
+ align-items: center;
79
+ justify-content: center;
80
+ }
81
+ .header h1 {
82
+ font-size: 3rem;
83
+ font-weight: 300;
84
+ }
85
+ .header p {
86
+ font-size: 1.5rem;
87
+ }
88
+ .cards {
89
+ display: grid;
90
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
91
+ gap: 2rem;
92
+ padding: 2rem;
93
+ background: var(--cards-background-color);
94
+ }
95
+
96
+ .card {
97
+ background: var(--card-background-color);
98
+ padding: 2rem;
99
+ border-radius: 2rem;
100
+ text-align: center;
101
+ display: flex;
102
+ flex-direction: column;
103
+ justify-content: center;
104
+ }
105
+
106
+ .value {
107
+ font-size: clamp(1rem, 2vw, 1.5rem);
108
+ font-weight: bold;
109
+ color: var(--text-color);
110
+ margin-bottom: 0.5rem;
111
+ }
112
+
113
+ .label {
114
+ color: var(--text-color-light);
115
+ font-size: clamp(1rem, 2vw, 1.5rem);
116
+ text-transform: uppercase;
117
+ letter-spacing: 0.5px;
118
+ }
119
+
120
+ .footer {
121
+ background: var(--cards-background-color);
122
+ padding: 1rem 2rem;
123
+ text-align: center;
124
+ color: var(--text-color-light);
125
+ border-top: 1px solid var(--border-color);
126
+ font-size: 1rem;
127
+ }
128
+
129
+ .table-section {
130
+ padding: 2rem;
131
+ background: var(--white);
132
+ }
133
+
134
+ .table-section h2 {
135
+ margin-bottom: 1.5rem;
136
+ color: var(--text-color);
137
+ font-size: clamp(1.5rem, 3vw, 2rem);
138
+ }
139
+
140
+ /* Responsive Table Styles */
141
+ .skunk-results {
142
+ width: 100%;
143
+ border-collapse: collapse;
144
+ background: var(--white);
145
+ border-radius: 1rem;
146
+ overflow: hidden;
147
+ box-shadow: 0 2px 8px var(--shadow-color);
148
+ }
149
+
150
+ .skunk-results thead {
151
+ background: var(--primary-gradient-end);
152
+ color: var(--white);
153
+ }
154
+
155
+ .skunk-results th {
156
+ padding: 1rem;
157
+ text-align: right;
158
+ font-weight: 600;
159
+ font-size: 1rem;
160
+ }
161
+
162
+ .skunk-results th:first-child {
163
+ text-align: left;
164
+ }
165
+
166
+ .table-row {
167
+ border-bottom: 1px solid var(--border-color);
168
+ transition: background-color 0.2s ease;
169
+ }
170
+
171
+ .table-row:hover {
172
+ background-color: var(--cards-background-color);
173
+ }
174
+
175
+ .table-row td {
176
+ padding: 1rem;
177
+ border: none;
178
+ text-align: right;
179
+ }
180
+
181
+ .table-row .filename {
182
+ text-align: left;
183
+ }
184
+
185
+ .table-row td label {
186
+ display: none;
187
+ }
188
+
189
+ .filename {
190
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
191
+ word-break: break-all;
192
+ }
193
+
194
+ .score {
195
+ font-weight: bold;
196
+ }
197
+
198
+ /* Sortable Table Headers */
199
+ .table-header {
200
+ cursor: pointer;
201
+ user-select: none;
202
+ }
203
+
204
+ .table-header:hover {
205
+ text-decoration: underline;
206
+ }
207
+
208
+ .table-header-asc::after {
209
+ content: " ▲";
210
+ }
211
+
212
+ .table-header-desc::after {
213
+ content: " ▼";
214
+ }
215
+
216
+ /* Mobile Card Layout */
217
+ @media screen and (max-width: 800px) {
218
+ .skunk-results thead {
219
+ display: none;
220
+ }
221
+
222
+ .skunk-results {
223
+ box-shadow: none;
224
+ }
225
+
226
+ .table-row {
227
+ display: grid;
228
+ grid-template-columns: 1fr;
229
+ height: auto;
230
+ margin-bottom: 1rem;
231
+ border: 1px solid var(--border-color);
232
+ border-radius: 1rem;
233
+ background: var(--card-background-color);
234
+ box-shadow: 0 2px 4px var(--shadow-color);
235
+ }
236
+
237
+ .table-row .filename {
238
+ background: var(--cards-background-color);
239
+ }
240
+
241
+ .table-row td {
242
+ display: flex;
243
+ flex-direction: row;
244
+ justify-content: space-between;
245
+ padding: 0.5rem;
246
+ }
247
+
248
+ .table-row td label {
249
+ display: inline-block;
250
+ }
251
+ }
252
+ </style>
253
+ </head>
254
+ <body>
255
+ <div class="container">
256
+ <div class="header">
257
+ <h1>Skunk Report</h1>
258
+ <p>Code Quality vs. Code Coverage Analysis</p>
259
+ </div>
260
+
261
+ <section class="cards">
262
+ <div class="card">
263
+ <div class="value"><%= analysed_modules_count %></div>
264
+ <div class="label">Modules Analysed</div>
265
+ </div>
266
+ <div class="card">
267
+ <div class="value"><%= skunk_score_total %></div>
268
+ <div class="label">Total Skunk Score</div>
269
+ </div>
270
+ <div class="card">
271
+ <div class="value"><%= skunk_score_average %></div>
272
+ <div class="label">Average Skunk Score</div>
273
+ </div>
274
+ </section>
275
+
276
+ <section class="table-section">
277
+ <h2>Skunk Analysis</h2>
278
+ <table id="skunkTable" class="skunk-results">
279
+ <thead>
280
+ <tr>
281
+ <th class="table-header">File</th>
282
+ <th class="table-header table-header-desc">Skunk Score</th>
283
+ <th class="table-header">Churn &times; Cost</th>
284
+ <th class="table-header">Churn</th>
285
+ <th class="table-header">Cost</th>
286
+ <th class="table-header">Coverage</th>
287
+ </tr>
288
+ </thead>
289
+ <tbody>
290
+ <% files.each do |item| %>
291
+ <tr class="table-row">
292
+ <td class="filename">
293
+ <span><%= item.file %></span>
294
+ </td>
295
+ <td class="score">
296
+ <label>Skunk Score</label>
297
+ <span><%= item.skunk_score %></span>
298
+ </td>
299
+ <td>
300
+ <label>Churn &times; Cost</label>
301
+ <span><%= item.churn_times_cost %></span>
302
+ </td>
303
+ <td>
304
+ <label>Churn</label>
305
+ <span><%= item.churn %></span>
306
+ </td>
307
+ <td>
308
+ <label>Cost</label>
309
+ <span><%= item.cost %></span>
310
+ </td>
311
+ <td>
312
+ <label>Coverage</label>
313
+ <span><%= item.coverage %></span>
314
+ </td>
315
+ </tr>
316
+ <% end %>
317
+ </tbody>
318
+ </table>
319
+ </section>
320
+
321
+ <footer class="footer">
322
+ <p>Generated with Skunk v<%= @skunk_version %> on <%= @generated_at %></p>
323
+ </footer>
324
+ </div>
325
+
326
+ <script>
327
+ document.addEventListener("DOMContentLoaded", () => {
328
+ const table = document.getElementById("skunkTable");
329
+ const headers = table.querySelectorAll(".table-header");
330
+ const tbody = table.querySelector("tbody");
331
+ const numericColumns = [1, 2, 3, 4, 5];
332
+
333
+ const cellValue = (row, columnIndex) =>
334
+ row.cells[columnIndex].querySelector("span:last-child").textContent.trim();
335
+
336
+ const compareRows = (rowA, rowB, columnIndex, ascending) => {
337
+ const valueA = cellValue(rowA, columnIndex);
338
+ const valueB = cellValue(rowB, columnIndex);
339
+
340
+ if (numericColumns.includes(columnIndex)) {
341
+ return ascending ? Number(valueA) - Number(valueB) : Number(valueB) - Number(valueA);
342
+ }
343
+
344
+ return ascending ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
345
+ };
346
+
347
+ headers.forEach((header, columnIndex) => {
348
+ header.addEventListener("click", () => {
349
+ const ascending = !header.classList.contains("table-header-asc");
350
+ const rows = Array.from(tbody.querySelectorAll(".table-row"));
351
+
352
+ headers.forEach((otherHeader) => {
353
+ otherHeader.classList.remove("table-header-asc", "table-header-desc");
354
+ });
355
+
356
+ header.classList.add(ascending ? "table-header-asc" : "table-header-desc");
357
+
358
+ rows
359
+ .sort((rowA, rowB) => compareRows(rowA, rowB, columnIndex, ascending))
360
+ .forEach((row) => tbody.appendChild(row));
361
+ });
362
+ });
363
+ });
364
+ </script>
365
+ </body>
366
+ </html>
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require "rubycritic/browser"
6
+ require "rubycritic/generators/html_report"
7
+
8
+ require "skunk/generators/html/overview"
9
+
10
+ module Skunk
11
+ module Generator
12
+ # Generates an HTML report for the analysed modules.
13
+ class HtmlReport < RubyCritic::Generator::HtmlReport
14
+ def initialize(analysed_modules)
15
+ super
16
+ @analysed_modules = analysed_modules
17
+ end
18
+
19
+ def generate_report
20
+ create_directories_and_files
21
+ puts "#{report_name} generated at #{report_location}"
22
+ browser.open unless RubyCritic::Config.no_browser
23
+ end
24
+
25
+ def create_directories_and_files
26
+ Array(generators).each do |generator|
27
+ FileUtils.mkdir_p(generator.file_directory)
28
+ File.write(generator.file_pathname, generator.render)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def generators
35
+ @generators ||= [
36
+ overview_generator
37
+ ]
38
+ end
39
+
40
+ def overview_generator
41
+ @overview_generator ||= Skunk::Generator::Html::Overview.new(@analysed_modules)
42
+ end
43
+
44
+ def report_name
45
+ self.class.name.split("::").last
46
+ .gsub(/([a-z])([A-Z])/, '\1 \2')
47
+ .downcase
48
+ .capitalize
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,56 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rubycritic/generators/json/simple"
3
+ require "pathname"
4
+
5
+ require "skunk/config"
6
+ require "skunk/rubycritic/analysed_modules_collection"
4
7
 
5
8
  module Skunk
6
9
  module Generator
7
10
  module Json
8
11
  # Generates a JSON report for the analysed modules.
9
- class Simple < RubyCritic::Generator::Json::Simple
10
- def data
11
- {
12
- analysed_modules_count: analysed_modules_count,
13
- skunk_score_average: skunk_score_average,
14
- skunk_score_total: skunk_score_total,
15
- worst_pathname: worst&.pathname,
16
- worst_score: worst&.skunk_score,
17
- files: files
18
- }
19
- end
20
-
21
- private
22
-
23
- def analysed_modules_count
24
- @analysed_modules_count ||= non_test_modules.count
12
+ class Simple
13
+ def initialize(analysed_modules)
14
+ @analysed_modules = analysed_modules
25
15
  end
26
16
 
27
- def skunk_score_average
28
- return 0 if analysed_modules_count.zero?
17
+ FILE_NAME = "skunk_report.json"
29
18
 
30
- (skunk_score_total.to_d / analysed_modules_count).to_f.round(2)
19
+ def render
20
+ JSON.dump(data)
31
21
  end
32
22
 
33
- def skunk_score_total
34
- @skunk_score_total ||= non_test_modules.sum(&:skunk_score)
35
- end
36
-
37
- def non_test_modules
38
- @non_test_modules ||= @analysed_modules.reject do |a_module|
39
- module_path = a_module.pathname.dirname.to_s
40
- module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec")
41
- end
42
- end
43
-
44
- def worst
45
- @worst ||= sorted_modules.first
23
+ def data
24
+ @analysed_modules.to_hash
46
25
  end
47
26
 
48
- def sorted_modules
49
- @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse!
27
+ def file_directory
28
+ @file_directory ||= Pathname.new(Skunk::Config.root)
50
29
  end
51
30
 
52
- def files
53
- @files ||= sorted_modules.map(&:to_hash)
31
+ def file_pathname
32
+ file_directory.join(FILE_NAME)
54
33
  end
55
34
  end
56
35
  end
@@ -1,23 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rubycritic/generators/json_report"
4
-
5
3
  require "skunk/generators/json/simple"
6
4
 
7
5
  module Skunk
8
6
  module Generator
9
7
  # Generates a JSON report for the analysed modules.
10
- class JsonReport < RubyCritic::Generator::JsonReport
8
+ class JsonReport
11
9
  def initialize(analysed_modules)
12
- super
13
10
  @analysed_modules = analysed_modules
14
11
  end
15
12
 
13
+ def generate_report
14
+ FileUtils.mkdir_p(generator.file_directory)
15
+ puts "#{report_name} generated at #{file_path}"
16
+ File.write(file_path, generator.render)
17
+ end
18
+
16
19
  private
17
20
 
18
21
  def generator
19
22
  Skunk::Generator::Json::Simple.new(@analysed_modules)
20
23
  end
24
+
25
+ def report_name
26
+ self.class.name.split("::").last
27
+ .gsub(/([a-z])([A-Z])/, '\1 \2')
28
+ .downcase
29
+ .capitalize
30
+ end
31
+
32
+ def file_path
33
+ generator.file_pathname
34
+ end
21
35
  end
22
36
  end
23
37
  end
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "skunk/config"
4
+
3
5
  module Skunk
4
6
  # Pick the right report generator based on the format specified in the
5
7
  # configuration. If the format is not supported, it will default to ConsoleReport.
6
8
  module Reporter
7
- REPORT_GENERATOR_CLASS_FORMATS = %i[json].freeze
8
-
9
9
  def self.generate_report(analysed_modules)
10
- RubyCritic::Config.formats.uniq.each do |format|
10
+ Config.formats.uniq.each do |format|
11
11
  report_generator_class(format).new(analysed_modules).generate_report
12
12
  end
13
13
  end
14
14
 
15
15
  def self.report_generator_class(config_format)
16
- if REPORT_GENERATOR_CLASS_FORMATS.include? config_format
16
+ if Config.supported_format?(config_format)
17
17
  require "skunk/generators/#{config_format}_report"
18
18
  Generator.const_get("#{config_format.capitalize}Report")
19
19
  else
@@ -3,15 +3,100 @@
3
3
  require "rubycritic/core/analysed_modules_collection"
4
4
 
5
5
  module RubyCritic
6
- # nodoc #
6
+ # Extends RubyCritic::AnalysedModulesCollection to add Skunk analysis methods
7
7
  class AnalysedModulesCollection
8
+ # Returns the count of non-test modules
9
+ # @return [Integer]
10
+ def analysed_modules_count
11
+ @analysed_modules_count ||= non_test_modules.count
12
+ end
13
+
14
+ # Returns the total Skunk score across all non-test modules
15
+ # @return [Float]
16
+ def skunk_score_total
17
+ @skunk_score_total ||= non_test_modules.sum(&:skunk_score)
18
+ end
19
+
20
+ # Returns the average Skunk score across all non-test modules
21
+ # @return [Float]
8
22
  def skunk_score_average
9
- num_modules = @modules.size
10
- if num_modules.positive?
11
- sum(&:skunk_score) / num_modules.to_f
12
- else
13
- 0.0
23
+ return 0.0 if analysed_modules_count.zero?
24
+
25
+ (skunk_score_total.to_d / analysed_modules_count).to_f.round(2)
26
+ end
27
+
28
+ # Returns the total churn times cost across all non-test modules
29
+ # @return [Float]
30
+ def total_churn_times_cost
31
+ @total_churn_times_cost ||= non_test_modules.sum(&:churn_times_cost)
32
+ end
33
+
34
+ # Returns the module with the highest Skunk score (worst performing)
35
+ # @return [RubyCritic::AnalysedModule, nil]
36
+ def worst_module
37
+ @worst_module ||= sorted_modules.first
38
+ end
39
+
40
+ # Returns modules sorted by Skunk score in descending order (worst first)
41
+ # @return [Array<RubyCritic::AnalysedModule>]
42
+ def sorted_modules
43
+ @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse!
44
+ end
45
+
46
+ # Returns only non-test modules (excludes test and spec directories)
47
+ # @return [Array<RubyCritic::AnalysedModule>]
48
+ def non_test_modules
49
+ @non_test_modules ||= reject do |a_module|
50
+ test_module?(a_module)
14
51
  end
15
52
  end
53
+
54
+ # Returns a hash representation of the analysis results
55
+ # @return [Hash]
56
+ def to_hash
57
+ {
58
+ analysed_modules_count: analysed_modules_count,
59
+ skunk_score_total: skunk_score_total,
60
+ skunk_score_average: skunk_score_average,
61
+ total_churn_times_cost: total_churn_times_cost,
62
+ worst_pathname: worst_module&.pathname,
63
+ worst_score: worst_module&.skunk_score,
64
+ files: files_as_hash
65
+ }
66
+ end
67
+
68
+ # Returns files as an array of hashes (for JSON serialization)
69
+ # @return [Array<Hash>]
70
+ def files_as_hash
71
+ @files_as_hash ||= sorted_modules.map(&:to_hash)
72
+ end
73
+
74
+ private
75
+
76
+ # Determines if a module is a test module based on its path
77
+ # @param a_module [RubyCritic::AnalysedModule] The module to check
78
+ # @return [Boolean]
79
+ def test_module?(a_module)
80
+ pathname = a_module.pathname
81
+ directory_is_test?(pathname) || filename_is_test?(pathname)
82
+ end
83
+
84
+ # Checks if the directory path indicates a test module
85
+ # @param pathname [Pathname] The pathname to check
86
+ # @return [Boolean]
87
+ # :reek:UtilityFunction
88
+ def directory_is_test?(pathname)
89
+ module_path = pathname.dirname.to_s
90
+ module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec")
91
+ end
92
+
93
+ # Checks if the filename indicates a test module
94
+ # @param pathname [Pathname] The pathname to check
95
+ # @return [Boolean]
96
+ # :reek:UtilityFunction
97
+ def filename_is_test?(pathname)
98
+ filename = pathname.basename.to_s
99
+ filename.end_with?("_test.rb", "_spec.rb")
100
+ end
16
101
  end
17
102
  end
data/lib/skunk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Skunk
4
- VERSION = "0.5.4"
4
+ VERSION = "0.6.0"
5
5
  end
data/skunk.gemspec CHANGED
@@ -38,19 +38,19 @@ Gem::Specification.new do |spec|
38
38
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
39
39
  spec.require_paths = ["lib"]
40
40
 
41
+ spec.add_dependency "ostruct", "~> 0.6"
42
+ spec.add_dependency "path_expander", "< 2.0"
41
43
  spec.add_dependency "rubycritic", ">= 4.5.2", "< 5.0"
42
44
  spec.add_dependency "terminal-table", "~> 3.0"
43
45
 
44
46
  spec.add_development_dependency "codecov", "~> 0.1.16"
45
- spec.add_development_dependency "debug"
46
47
  spec.add_development_dependency "minitest", "< 6"
47
48
  spec.add_development_dependency "minitest-around", "~> 0.5.0"
48
49
  spec.add_development_dependency "minitest-stub_any_instance", "~> 1.0.2"
49
50
  spec.add_development_dependency "minitest-stub-const", "~> 0.6"
50
51
  spec.add_development_dependency "rake", "~> 13.0"
51
52
  spec.add_development_dependency "reek"
52
- spec.add_development_dependency "rubocop"
53
- spec.add_development_dependency "simplecov", "~> 0.18"
53
+ spec.add_development_dependency "simplecov", ">= 0.18", "< 2"
54
54
  spec.add_development_dependency "simplecov-console", "0.5.0"
55
- spec.add_development_dependency "webmock", "~> 3.10.0"
55
+ spec.add_development_dependency "webmock", "~> 3.20.0"
56
56
  end