From f62f60643fe94455cffa307afc76fb1922fe776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 17 Oct 2025 10:01:40 -0600 Subject: [PATCH 1/7] Add analysis class --- lib/skunk.rb | 1 + lib/skunk/analysis.rb | 106 ++++++++++ lib/skunk/commands/status_reporter.rb | 74 +------ test/lib/skunk/analysis_test.rb | 283 ++++++++++++++++++++++++++ test/lib/skunk/config_test.rb | 2 +- 5 files changed, 392 insertions(+), 74 deletions(-) create mode 100644 lib/skunk/analysis.rb create mode 100644 test/lib/skunk/analysis_test.rb diff --git a/lib/skunk.rb b/lib/skunk.rb index 1bafd37..0fe2197 100644 --- a/lib/skunk.rb +++ b/lib/skunk.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "skunk/version" +require "skunk/analysis" # Knows how to calculate the `SkunkScore` for each file analyzed by `RubyCritic` # and `SimpleCov` diff --git a/lib/skunk/analysis.rb b/lib/skunk/analysis.rb new file mode 100644 index 0000000..286c583 --- /dev/null +++ b/lib/skunk/analysis.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Skunk + # Centralized service for analyzing Skunk metrics from analysed modules. + # This class encapsulates all the core business logic for calculating + # Skunk scores, filtering test modules, and providing aggregated statistics. + # + # @example + # analysis = Skunk::Analysis.new(analysed_modules) + # puts "Total Skunk Score: #{analysis.skunk_score_total}" + # puts "Average: #{analysis.skunk_score_average}" + # puts "Worst module: #{analysis.worst_module.pathname}" + class Analysis + attr_reader :analysed_modules + + # @param analysed_modules [RubyCritic::AnalysedModulesCollection] Collection of analysed modules + def initialize(analysed_modules) + @analysed_modules = analysed_modules + end + + # Returns the count of non-test modules + # @return [Integer] + def analysed_modules_count + @analysed_modules_count ||= non_test_modules.count + end + + # Returns the total Skunk score across all non-test modules + # @return [Float] + def skunk_score_total + @skunk_score_total ||= non_test_modules.sum(&:skunk_score) + end + + # Returns the average Skunk score across all non-test modules + # @return [Float] + def skunk_score_average + return 0.0 if analysed_modules_count.zero? + + (skunk_score_total.to_d / analysed_modules_count).to_f.round(2) + end + + # Returns the total churn times cost across all non-test modules + # @return [Float] + def total_churn_times_cost + @total_churn_times_cost ||= non_test_modules.sum(&:churn_times_cost) + end + + # Returns the module with the highest Skunk score (worst performing) + # @return [RubyCritic::AnalysedModule, nil] + def worst_module + @worst_module ||= sorted_modules.first + end + + # Returns modules sorted by Skunk score in descending order (worst first) + # @return [Array] + def sorted_modules + @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse! + end + + # Returns only non-test modules (excludes test and spec directories) + # @return [Array] + def non_test_modules + @non_test_modules ||= analysed_modules.reject do |a_module| + test_module?(a_module) + end + end + + # Returns a hash representation of the analysis results + # @return [Hash] + def to_hash + { + analysed_modules_count: analysed_modules_count, + skunk_score_total: skunk_score_total, + skunk_score_average: skunk_score_average, + total_churn_times_cost: total_churn_times_cost, + worst_pathname: worst_module&.pathname, + worst_score: worst_module&.skunk_score, + files: files_as_hash + } + end + + private + + # Returns files as an array of hashes (for JSON serialization) + # @return [Array] + def files_as_hash + @files_as_hash ||= sorted_modules.map(&:to_hash) + end + + # Determines if a module is a test module based on its path + # @param a_module [RubyCritic::AnalysedModule] The module to check + # @return [Boolean] + def test_module?(a_module) + pathname = a_module.pathname + module_path = pathname.dirname.to_s + filename = pathname.basename.to_s + + # Check if directory starts or ends with test/spec + directory_is_test = module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec") + + # Check if filename ends with _test.rb or _spec.rb + filename_is_test = filename.end_with?("_test.rb", "_spec.rb") + + directory_is_test || filename_is_test + end + end +end diff --git a/lib/skunk/commands/status_reporter.rb b/lib/skunk/commands/status_reporter.rb index 315347a..52bd642 100644 --- a/lib/skunk/commands/status_reporter.rb +++ b/lib/skunk/commands/status_reporter.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require "erb" require "rubycritic/commands/status_reporter" -require "terminal-table" module Skunk module Command @@ -14,78 +12,8 @@ def initialize(options = {}) super(options) end - HEADINGS = %w[file skunk_score churn_times_cost churn cost coverage].freeze - HEADINGS_WITHOUT_FILE = HEADINGS - %w[file] - HEADINGS_WITHOUT_FILE_WIDTH = HEADINGS_WITHOUT_FILE.size * 17 # padding - - TEMPLATE = ERB.new(<<-TEMPL -<%= _ttable %>\n -SkunkScore Total: <%= total_skunk_score %> -Modules Analysed: <%= analysed_modules_count %> -SkunkScore Average: <%= skunk_score_average %> -<% if worst %>Worst SkunkScore: <%= worst.skunk_score %> (<%= worst.pathname %>)<% end %> - -Generated with Skunk v<%= Skunk::VERSION %> -TEMPL - ) - - # Returns a status message with a table of all analysed_modules and - # a skunk score average def update_status_message - opts = table_options.merge(headings: HEADINGS, rows: table) - - _ttable = Terminal::Table.new(opts) - - @status_message = TEMPLATE.result(binding) - end - - private - - def analysed_modules_count - analysed_modules.analysed_modules_count - end - - def worst - analysed_modules.worst_module - end - - def sorted_modules - analysed_modules.sorted_modules - end - - def total_skunk_score - analysed_modules.skunk_score_total - end - - def total_churn_times_cost - analysed_modules.total_churn_times_cost - end - - def skunk_score_average - analysed_modules.skunk_score_average - end - - def table_options - max = sorted_modules.max_by { |a_mod| a_mod.pathname.to_s.length } - width = max.pathname.to_s.length + HEADINGS_WITHOUT_FILE_WIDTH - { - style: { - width: width - } - } - end - - def table - sorted_modules.map do |a_mod| - [ - a_mod.pathname, - a_mod.skunk_score, - a_mod.churn_times_cost, - a_mod.churn, - a_mod.cost.round(2), - a_mod.coverage.round(2) - ] - end + @status_message = "Skunk Report Completed" end end end diff --git a/test/lib/skunk/analysis_test.rb b/test/lib/skunk/analysis_test.rb new file mode 100644 index 0000000..4a7d48d --- /dev/null +++ b/test/lib/skunk/analysis_test.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require "test_helper" + +require "skunk/analysis" +require "skunk/rubycritic/analysed_module" + +describe Skunk::Analysis do + let(:analysed_modules) { [] } + let(:analysis) { Skunk::Analysis.new(analysed_modules) } + + describe "#initialize" do + it "accepts analysed_modules collection" do + _(analysis.analysed_modules).must_equal analysed_modules + end + end + + describe "#analysed_modules_count" do + context "with no modules" do + it "returns 0" do + _(analysis.analysed_modules_count).must_equal 0 + end + end + + context "with non-test modules" do + let(:analysed_modules) { [create_analysed_module("lib/file.rb"), create_analysed_module("app/model.rb")] } + + it "returns the count of non-test modules" do + _(analysis.analysed_modules_count).must_equal 2 + end + end + + context "with test modules" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file.rb"), + create_analysed_module("test/file_test.rb"), + create_analysed_module("spec/file_spec.rb") + ] + end + + it "excludes test modules from count" do + _(analysis.analysed_modules_count).must_equal 1 + end + end + end + + describe "#skunk_score_total" do + context "with no modules" do + it "returns 0" do + _(analysis.skunk_score_total).must_equal 0 + end + end + + context "with modules" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file1.rb", skunk_score: 10.5), + create_analysed_module("lib/file2.rb", skunk_score: 20.3) + ] + end + + it "returns the sum of skunk scores" do + _(analysis.skunk_score_total).must_equal 30.8 + end + end + + context "with test modules" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file.rb", skunk_score: 10.0), + create_analysed_module("test/file_test.rb", skunk_score: 50.0) + ] + end + + it "excludes test modules from total" do + _(analysis.skunk_score_total).must_equal 10.0 + end + end + end + + describe "#skunk_score_average" do + context "with no modules" do + it "returns 0" do + _(analysis.skunk_score_average).must_equal 0.0 + end + end + + context "with modules" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file1.rb", skunk_score: 10.0), + create_analysed_module("lib/file2.rb", skunk_score: 20.0) + ] + end + + it "returns the average skunk score" do + _(analysis.skunk_score_average).must_equal 15.0 + end + end + + context "with decimal average" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file1.rb", skunk_score: 10.0), + create_analysed_module("lib/file2.rb", skunk_score: 11.0) + ] + end + + it "rounds to 2 decimal places" do + _(analysis.skunk_score_average).must_equal 10.5 + end + end + end + + describe "#total_churn_times_cost" do + context "with no modules" do + it "returns 0" do + _(analysis.total_churn_times_cost).must_equal 0 + end + end + + context "with modules" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file1.rb", churn_times_cost: 5.0), + create_analysed_module("lib/file2.rb", churn_times_cost: 15.0) + ] + end + + it "returns the sum of churn times cost" do + _(analysis.total_churn_times_cost).must_equal 20.0 + end + end + end + + describe "#worst_module" do + context "with no modules" do + it "returns nil" do + _(analysis.worst_module).must_be_nil + end + end + + context "with modules" do + let(:worst_module) { create_analysed_module("lib/worst.rb", skunk_score: 100.0) } + let(:best_module) { create_analysed_module("lib/best.rb", skunk_score: 10.0) } + let(:analysed_modules) { [best_module, worst_module] } + + it "returns the module with highest skunk score" do + _(analysis.worst_module).must_equal worst_module + end + end + end + + describe "#sorted_modules" do + context "with no modules" do + it "returns empty array" do + _(analysis.sorted_modules).must_equal [] + end + end + + context "with modules" do + let(:module1) { create_analysed_module("lib/file1.rb", skunk_score: 10.0) } + let(:module2) { create_analysed_module("lib/file2.rb", skunk_score: 30.0) } + let(:module3) { create_analysed_module("lib/file3.rb", skunk_score: 20.0) } + let(:analysed_modules) { [module1, module2, module3] } + + it "returns modules sorted by skunk score descending" do + _(analysis.sorted_modules).must_equal [module2, module3, module1] + end + end + + context "with test modules" do + let(:spec_module) { create_analysed_module("test/file_test.rb", skunk_score: 100.0) } + let(:lib_module) { create_analysed_module("lib/file.rb", skunk_score: 10.0) } + let(:analysed_modules) { [spec_module, lib_module] } + + it "excludes test modules from sorted list" do + _(analysis.sorted_modules).must_equal [lib_module] + end + end + end + + describe "#non_test_modules" do + context "with mixed modules" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file.rb"), + create_analysed_module("test/file_test.rb"), + create_analysed_module("spec/file_spec.rb"), + create_analysed_module("app/model.rb") + ] + end + + it "filters out test and spec modules" do + non_test = analysis.non_test_modules + _(non_test.size).must_equal 2 + _(non_test.map(&:pathname).map(&:to_s)).must_include "lib/file.rb" + _(non_test.map(&:pathname).map(&:to_s)).must_include "app/model.rb" + end + end + + context "with modules in test directories" do + let(:analysed_modules) do + [ + create_analysed_module("test/unit/file.rb"), + create_analysed_module("spec/unit/file.rb"), + create_analysed_module("lib/file.rb") + ] + end + + it "filters out modules in test directories" do + non_test = analysis.non_test_modules + _(non_test.size).must_equal 1 + _(non_test.first.pathname.to_s).must_equal "lib/file.rb" + end + end + + context "with modules ending in test/spec" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file_test.rb"), + create_analysed_module("lib/file_spec.rb"), + create_analysed_module("lib/file.rb") + ] + end + + it "filters out modules ending in test/spec" do + non_test = analysis.non_test_modules + _(non_test.size).must_equal 1 + _(non_test.first.pathname.to_s).must_equal "lib/file.rb" + end + end + end + + describe "#to_hash" do + let(:analysed_modules) do + [ + create_analysed_module("lib/file.rb", skunk_score: 10.0, churn_times_cost: 5.0) + ] + end + + it "returns a hash with all analysis data including files" do + hash = analysis.to_hash + _(hash[:analysed_modules_count]).must_equal 1 + _(hash[:skunk_score_total]).must_equal 10.0 + _(hash[:skunk_score_average]).must_equal 10.0 + _(hash[:total_churn_times_cost]).must_equal 5.0 + _(hash[:worst_pathname]).must_equal Pathname.new("lib/file.rb") + _(hash[:worst_score]).must_equal 10.0 + _(hash[:files]).must_be_kind_of Array + _(hash[:files].size).must_equal 1 + _(hash[:files].first[:file]).must_equal "lib/file.rb" + _(hash[:files].first[:skunk_score]).must_equal 10.0 + end + end + + private + + def create_analysed_module(path, skunk_score: 0.0, churn_times_cost: 0.0) + module_path = Pathname.new(path) + analysed_module = RubyCritic::AnalysedModule.new( + pathname: module_path, + smells: [], + churn: 1, + committed_at: Time.now + ) + + add_mock_methods_to_module(analysed_module, skunk_score, churn_times_cost) + end + + def add_mock_methods_to_module(analysed_module, skunk_score, churn_times_cost) + # Mock the skunk_score and churn_times_cost methods + analysed_module.define_singleton_method(:skunk_score) { @skunk_score ||= 0.0 } + analysed_module.define_singleton_method(:skunk_score=) { |value| @skunk_score = value } + analysed_module.define_singleton_method(:churn_times_cost) { @churn_times_cost ||= 0.0 } + analysed_module.define_singleton_method(:churn_times_cost=) { |value| @churn_times_cost = value } + + analysed_module.skunk_score = skunk_score + analysed_module.churn_times_cost = churn_times_cost + analysed_module + end +end diff --git a/test/lib/skunk/config_test.rb b/test/lib/skunk/config_test.rb index 3664d63..febe6c8 100644 --- a/test/lib/skunk/config_test.rb +++ b/test/lib/skunk/config_test.rb @@ -70,7 +70,7 @@ def test_supported_format end def test_supported_formats - expected = %i[json html] + expected = %i[json html console] assert_equal expected, Config.supported_formats end From 6cd95def104cb3eb950e2e5a0e54bf4d23728237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 17 Oct 2025 10:56:53 -0600 Subject: [PATCH 2/7] Move from class to module Refactor Skunk analysis integration by removing the Analysis class and adding its methods directly to RubyCritic::AnalysedModulesCollection. Update related files to utilize the new methods for Skunk score calculations and reporting. --- lib/skunk.rb | 1 - lib/skunk/analysis.rb | 106 ------------ test/lib/skunk/analysis_test.rb | 283 -------------------------------- 3 files changed, 390 deletions(-) delete mode 100644 lib/skunk/analysis.rb delete mode 100644 test/lib/skunk/analysis_test.rb diff --git a/lib/skunk.rb b/lib/skunk.rb index 0fe2197..1bafd37 100644 --- a/lib/skunk.rb +++ b/lib/skunk.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "skunk/version" -require "skunk/analysis" # Knows how to calculate the `SkunkScore` for each file analyzed by `RubyCritic` # and `SimpleCov` diff --git a/lib/skunk/analysis.rb b/lib/skunk/analysis.rb deleted file mode 100644 index 286c583..0000000 --- a/lib/skunk/analysis.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -module Skunk - # Centralized service for analyzing Skunk metrics from analysed modules. - # This class encapsulates all the core business logic for calculating - # Skunk scores, filtering test modules, and providing aggregated statistics. - # - # @example - # analysis = Skunk::Analysis.new(analysed_modules) - # puts "Total Skunk Score: #{analysis.skunk_score_total}" - # puts "Average: #{analysis.skunk_score_average}" - # puts "Worst module: #{analysis.worst_module.pathname}" - class Analysis - attr_reader :analysed_modules - - # @param analysed_modules [RubyCritic::AnalysedModulesCollection] Collection of analysed modules - def initialize(analysed_modules) - @analysed_modules = analysed_modules - end - - # Returns the count of non-test modules - # @return [Integer] - def analysed_modules_count - @analysed_modules_count ||= non_test_modules.count - end - - # Returns the total Skunk score across all non-test modules - # @return [Float] - def skunk_score_total - @skunk_score_total ||= non_test_modules.sum(&:skunk_score) - end - - # Returns the average Skunk score across all non-test modules - # @return [Float] - def skunk_score_average - return 0.0 if analysed_modules_count.zero? - - (skunk_score_total.to_d / analysed_modules_count).to_f.round(2) - end - - # Returns the total churn times cost across all non-test modules - # @return [Float] - def total_churn_times_cost - @total_churn_times_cost ||= non_test_modules.sum(&:churn_times_cost) - end - - # Returns the module with the highest Skunk score (worst performing) - # @return [RubyCritic::AnalysedModule, nil] - def worst_module - @worst_module ||= sorted_modules.first - end - - # Returns modules sorted by Skunk score in descending order (worst first) - # @return [Array] - def sorted_modules - @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse! - end - - # Returns only non-test modules (excludes test and spec directories) - # @return [Array] - def non_test_modules - @non_test_modules ||= analysed_modules.reject do |a_module| - test_module?(a_module) - end - end - - # Returns a hash representation of the analysis results - # @return [Hash] - def to_hash - { - analysed_modules_count: analysed_modules_count, - skunk_score_total: skunk_score_total, - skunk_score_average: skunk_score_average, - total_churn_times_cost: total_churn_times_cost, - worst_pathname: worst_module&.pathname, - worst_score: worst_module&.skunk_score, - files: files_as_hash - } - end - - private - - # Returns files as an array of hashes (for JSON serialization) - # @return [Array] - def files_as_hash - @files_as_hash ||= sorted_modules.map(&:to_hash) - end - - # Determines if a module is a test module based on its path - # @param a_module [RubyCritic::AnalysedModule] The module to check - # @return [Boolean] - def test_module?(a_module) - pathname = a_module.pathname - module_path = pathname.dirname.to_s - filename = pathname.basename.to_s - - # Check if directory starts or ends with test/spec - directory_is_test = module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec") - - # Check if filename ends with _test.rb or _spec.rb - filename_is_test = filename.end_with?("_test.rb", "_spec.rb") - - directory_is_test || filename_is_test - end - end -end diff --git a/test/lib/skunk/analysis_test.rb b/test/lib/skunk/analysis_test.rb deleted file mode 100644 index 4a7d48d..0000000 --- a/test/lib/skunk/analysis_test.rb +++ /dev/null @@ -1,283 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -require "skunk/analysis" -require "skunk/rubycritic/analysed_module" - -describe Skunk::Analysis do - let(:analysed_modules) { [] } - let(:analysis) { Skunk::Analysis.new(analysed_modules) } - - describe "#initialize" do - it "accepts analysed_modules collection" do - _(analysis.analysed_modules).must_equal analysed_modules - end - end - - describe "#analysed_modules_count" do - context "with no modules" do - it "returns 0" do - _(analysis.analysed_modules_count).must_equal 0 - end - end - - context "with non-test modules" do - let(:analysed_modules) { [create_analysed_module("lib/file.rb"), create_analysed_module("app/model.rb")] } - - it "returns the count of non-test modules" do - _(analysis.analysed_modules_count).must_equal 2 - end - end - - context "with test modules" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file.rb"), - create_analysed_module("test/file_test.rb"), - create_analysed_module("spec/file_spec.rb") - ] - end - - it "excludes test modules from count" do - _(analysis.analysed_modules_count).must_equal 1 - end - end - end - - describe "#skunk_score_total" do - context "with no modules" do - it "returns 0" do - _(analysis.skunk_score_total).must_equal 0 - end - end - - context "with modules" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file1.rb", skunk_score: 10.5), - create_analysed_module("lib/file2.rb", skunk_score: 20.3) - ] - end - - it "returns the sum of skunk scores" do - _(analysis.skunk_score_total).must_equal 30.8 - end - end - - context "with test modules" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file.rb", skunk_score: 10.0), - create_analysed_module("test/file_test.rb", skunk_score: 50.0) - ] - end - - it "excludes test modules from total" do - _(analysis.skunk_score_total).must_equal 10.0 - end - end - end - - describe "#skunk_score_average" do - context "with no modules" do - it "returns 0" do - _(analysis.skunk_score_average).must_equal 0.0 - end - end - - context "with modules" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file1.rb", skunk_score: 10.0), - create_analysed_module("lib/file2.rb", skunk_score: 20.0) - ] - end - - it "returns the average skunk score" do - _(analysis.skunk_score_average).must_equal 15.0 - end - end - - context "with decimal average" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file1.rb", skunk_score: 10.0), - create_analysed_module("lib/file2.rb", skunk_score: 11.0) - ] - end - - it "rounds to 2 decimal places" do - _(analysis.skunk_score_average).must_equal 10.5 - end - end - end - - describe "#total_churn_times_cost" do - context "with no modules" do - it "returns 0" do - _(analysis.total_churn_times_cost).must_equal 0 - end - end - - context "with modules" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file1.rb", churn_times_cost: 5.0), - create_analysed_module("lib/file2.rb", churn_times_cost: 15.0) - ] - end - - it "returns the sum of churn times cost" do - _(analysis.total_churn_times_cost).must_equal 20.0 - end - end - end - - describe "#worst_module" do - context "with no modules" do - it "returns nil" do - _(analysis.worst_module).must_be_nil - end - end - - context "with modules" do - let(:worst_module) { create_analysed_module("lib/worst.rb", skunk_score: 100.0) } - let(:best_module) { create_analysed_module("lib/best.rb", skunk_score: 10.0) } - let(:analysed_modules) { [best_module, worst_module] } - - it "returns the module with highest skunk score" do - _(analysis.worst_module).must_equal worst_module - end - end - end - - describe "#sorted_modules" do - context "with no modules" do - it "returns empty array" do - _(analysis.sorted_modules).must_equal [] - end - end - - context "with modules" do - let(:module1) { create_analysed_module("lib/file1.rb", skunk_score: 10.0) } - let(:module2) { create_analysed_module("lib/file2.rb", skunk_score: 30.0) } - let(:module3) { create_analysed_module("lib/file3.rb", skunk_score: 20.0) } - let(:analysed_modules) { [module1, module2, module3] } - - it "returns modules sorted by skunk score descending" do - _(analysis.sorted_modules).must_equal [module2, module3, module1] - end - end - - context "with test modules" do - let(:spec_module) { create_analysed_module("test/file_test.rb", skunk_score: 100.0) } - let(:lib_module) { create_analysed_module("lib/file.rb", skunk_score: 10.0) } - let(:analysed_modules) { [spec_module, lib_module] } - - it "excludes test modules from sorted list" do - _(analysis.sorted_modules).must_equal [lib_module] - end - end - end - - describe "#non_test_modules" do - context "with mixed modules" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file.rb"), - create_analysed_module("test/file_test.rb"), - create_analysed_module("spec/file_spec.rb"), - create_analysed_module("app/model.rb") - ] - end - - it "filters out test and spec modules" do - non_test = analysis.non_test_modules - _(non_test.size).must_equal 2 - _(non_test.map(&:pathname).map(&:to_s)).must_include "lib/file.rb" - _(non_test.map(&:pathname).map(&:to_s)).must_include "app/model.rb" - end - end - - context "with modules in test directories" do - let(:analysed_modules) do - [ - create_analysed_module("test/unit/file.rb"), - create_analysed_module("spec/unit/file.rb"), - create_analysed_module("lib/file.rb") - ] - end - - it "filters out modules in test directories" do - non_test = analysis.non_test_modules - _(non_test.size).must_equal 1 - _(non_test.first.pathname.to_s).must_equal "lib/file.rb" - end - end - - context "with modules ending in test/spec" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file_test.rb"), - create_analysed_module("lib/file_spec.rb"), - create_analysed_module("lib/file.rb") - ] - end - - it "filters out modules ending in test/spec" do - non_test = analysis.non_test_modules - _(non_test.size).must_equal 1 - _(non_test.first.pathname.to_s).must_equal "lib/file.rb" - end - end - end - - describe "#to_hash" do - let(:analysed_modules) do - [ - create_analysed_module("lib/file.rb", skunk_score: 10.0, churn_times_cost: 5.0) - ] - end - - it "returns a hash with all analysis data including files" do - hash = analysis.to_hash - _(hash[:analysed_modules_count]).must_equal 1 - _(hash[:skunk_score_total]).must_equal 10.0 - _(hash[:skunk_score_average]).must_equal 10.0 - _(hash[:total_churn_times_cost]).must_equal 5.0 - _(hash[:worst_pathname]).must_equal Pathname.new("lib/file.rb") - _(hash[:worst_score]).must_equal 10.0 - _(hash[:files]).must_be_kind_of Array - _(hash[:files].size).must_equal 1 - _(hash[:files].first[:file]).must_equal "lib/file.rb" - _(hash[:files].first[:skunk_score]).must_equal 10.0 - end - end - - private - - def create_analysed_module(path, skunk_score: 0.0, churn_times_cost: 0.0) - module_path = Pathname.new(path) - analysed_module = RubyCritic::AnalysedModule.new( - pathname: module_path, - smells: [], - churn: 1, - committed_at: Time.now - ) - - add_mock_methods_to_module(analysed_module, skunk_score, churn_times_cost) - end - - def add_mock_methods_to_module(analysed_module, skunk_score, churn_times_cost) - # Mock the skunk_score and churn_times_cost methods - analysed_module.define_singleton_method(:skunk_score) { @skunk_score ||= 0.0 } - analysed_module.define_singleton_method(:skunk_score=) { |value| @skunk_score = value } - analysed_module.define_singleton_method(:churn_times_cost) { @churn_times_cost ||= 0.0 } - analysed_module.define_singleton_method(:churn_times_cost=) { |value| @churn_times_cost = value } - - analysed_module.skunk_score = skunk_score - analysed_module.churn_times_cost = churn_times_cost - analysed_module - end -end From 21448f62388ea12a956e3904efc300dffdea981d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 17 Oct 2025 15:54:03 -0600 Subject: [PATCH 3/7] Move Console Report out of Status Reporter The status reporter has a different purpuse in the RubyCritic gem, they also have the specific usage of the Console Report and now we are following the same patters that gem created. - Added support for a new console output format in the FormatValidator module. - Refactored the StatusReporter to provide a simplified status message upon completion of analysis. - Introduced ConsoleReport and Simple classes for generating detailed console reports of analysed modules. - Updated tests to validate new console reporting functionality and ensure expected outputs. - Adjusted existing tests to reflect changes in the status reporting method. --- .rubocop_todo.yml | 42 ++-- lib/skunk/cli/application.rb | 1 + lib/skunk/cli/options/argv.rb | 2 +- lib/skunk/commands/status_reporter.rb | 1 + lib/skunk/config.rb | 2 +- lib/skunk/generators/console/simple.rb | 92 +++++++++ lib/skunk/generators/console_report.rb | 27 +++ .../rubycritic/analysed_modules_collection.rb | 4 +- test/lib/skunk/application_test.rb | 22 -- .../skunk/commands/status_reporter_test.rb | 10 +- .../skunk/generators/console/simple_test.rb | 194 ++++++++++++++++++ .../skunk/generators/console_report_test.rb | 192 +++++++++++++++++ .../analysed_modules_collection_test.rb | 169 ++++++++++++--- test/test_helper.rb | 31 ++- 14 files changed, 704 insertions(+), 85 deletions(-) create mode 100644 lib/skunk/generators/console/simple.rb create mode 100644 lib/skunk/generators/console_report.rb create mode 100644 test/lib/skunk/generators/console/simple_test.rb create mode 100644 test/lib/skunk/generators/console_report_test.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index cc7705f..fe3af97 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2025-10-17 16:20:40 UTC using RuboCop version 1.81.1. +# on 2025-10-17 22:53:06 UTC using RuboCop version 1.81.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -13,16 +13,9 @@ Gemspec/RequiredRubyVersion: - 'skunk.gemspec' # Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Layout/ClosingHeredocIndentation: - Exclude: - - 'lib/skunk/commands/status_reporter.rb' - -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Layout/HeredocIndentation: +Lint/IneffectiveAccessModifier: Exclude: - - 'lib/skunk/commands/status_reporter.rb' + - 'lib/skunk/generators/console/simple.rb' # Offense count: 2 # Configuration parameters: AllowedParentClasses. @@ -31,21 +24,21 @@ Lint/MissingSuper: - 'lib/skunk/cli/application.rb' - 'lib/skunk/generators/html/overview.rb' -# Offense count: 1 +# Offense count: 4 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 18 + Max: 24 -# Offense count: 12 +# Offense count: 14 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. # AllowedMethods: refine Metrics/BlockLength: - Max: 233 + Max: 208 -# Offense count: 2 +# Offense count: 5 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 13 + Max: 18 # Offense count: 1 # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. @@ -55,6 +48,16 @@ Naming/VariableNumber: Exclude: - 'lib/skunk/commands/status_sharer.rb' +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, EnforcedStyleForClasses, EnforcedStyleForModules. +# SupportedStyles: nested, compact +# SupportedStylesForClasses: ~, nested, compact +# SupportedStylesForModules: ~, nested, compact +Style/ClassAndModuleChildren: + Exclude: + - 'test/test_helper.rb' + # Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. @@ -63,3 +66,10 @@ Style/FrozenStringLiteralComment: Exclude: - '**/*.arb' - 'bin/console' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. +# URISchemes: http, https +Layout/LineLength: + Max: 124 diff --git a/lib/skunk/cli/application.rb b/lib/skunk/cli/application.rb index ba915bc..f928098 100644 --- a/lib/skunk/cli/application.rb +++ b/lib/skunk/cli/application.rb @@ -5,6 +5,7 @@ require "skunk" require "skunk/rubycritic/analysed_module" +require "skunk/rubycritic/analysed_modules_collection" require "skunk/cli/options" require "skunk/command_factory" require "skunk/commands/status_sharer" diff --git a/lib/skunk/cli/options/argv.rb b/lib/skunk/cli/options/argv.rb index 7b8baa8..af2bec9 100644 --- a/lib/skunk/cli/options/argv.rb +++ b/lib/skunk/cli/options/argv.rb @@ -12,7 +12,7 @@ class Argv < RubyCritic::Cli::Options::Argv # :reek:Attribute attr_accessor :output_filename - def parse # rubocop:disable Metrics/MethodLength + def parse parser.new do |opts| opts.banner = "Usage: skunk [options] [paths]\n" diff --git a/lib/skunk/commands/status_reporter.rb b/lib/skunk/commands/status_reporter.rb index 52bd642..f305bfd 100644 --- a/lib/skunk/commands/status_reporter.rb +++ b/lib/skunk/commands/status_reporter.rb @@ -12,6 +12,7 @@ def initialize(options = {}) super(options) end + # Returns a simple status message indicating the analysis is complete def update_status_message @status_message = "Skunk Report Completed" end diff --git a/lib/skunk/config.rb b/lib/skunk/config.rb index 62b6967..61cedf5 100644 --- a/lib/skunk/config.rb +++ b/lib/skunk/config.rb @@ -4,7 +4,7 @@ module Skunk # Utility module for format validation module FormatValidator # Supported output formats - SUPPORTED_FORMATS = %i[json html].freeze + SUPPORTED_FORMATS = %i[json html console].freeze # Check if a format is supported # @param format [Symbol] Format to check diff --git a/lib/skunk/generators/console/simple.rb b/lib/skunk/generators/console/simple.rb new file mode 100644 index 0000000..d0d81a5 --- /dev/null +++ b/lib/skunk/generators/console/simple.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "erb" +require "terminal-table" + +module Skunk + module Generator + module Console + # Generates a console report for the analysed modules. + class Simple + def initialize(analysed_modules) + @analysed_modules = analysed_modules + end + + HEADINGS = %w[file skunk_score churn_times_cost churn cost coverage].freeze + HEADINGS_WITHOUT_FILE = HEADINGS - %w[file] + HEADINGS_WITHOUT_FILE_WIDTH = HEADINGS_WITHOUT_FILE.size * 17 # padding + + TEMPLATE = ERB.new(<<~TEMPL + <%= _ttable %> + + SkunkScore Total: <%= total_skunk_score %> + Modules Analysed: <%= analysed_modules_count %> + SkunkScore Average: <%= skunk_score_average %> + <% if worst %>Worst SkunkScore: <%= worst.skunk_score %> (<%= worst.pathname %>)<% end %> + + Generated with Skunk v<%= Skunk::VERSION %> + TEMPL + ) + + def render + opts = table_options.merge(headings: HEADINGS, rows: table) + _ttable = Terminal::Table.new(opts) + TEMPLATE.result(binding) + end + + private + + def analysed_modules_count + @analysed_modules.analysed_modules_count + end + + def worst + @analysed_modules.worst_module + end + + def sorted_modules + @analysed_modules.sorted_modules + end + + def total_skunk_score + @analysed_modules.skunk_score_total + end + + def total_churn_times_cost + @analysed_modules.total_churn_times_cost + end + + def skunk_score_average + @analysed_modules.skunk_score_average + end + + def table_options + return { style: { width: 100 } } if sorted_modules.empty? + + max = sorted_modules.max_by { |a_mod| a_mod.pathname.to_s.length } + width = max.pathname.to_s.length + HEADINGS_WITHOUT_FILE_WIDTH + { + style: { + width: width + } + } + end + + def table + @analysed_modules.files_as_hash.map { |file_hash| self.class.format_hash_row(file_hash) } + end + + def self.format_hash_row(file_hash) + [ + file_hash[:file], + file_hash[:skunk_score], + file_hash[:churn_times_cost], + file_hash[:churn], + file_hash[:cost], + file_hash[:coverage] + ] + end + end + end + end +end diff --git a/lib/skunk/generators/console_report.rb b/lib/skunk/generators/console_report.rb new file mode 100644 index 0000000..3b1958e --- /dev/null +++ b/lib/skunk/generators/console_report.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "erb" +require "terminal-table" + +require "skunk/generators/console/simple" + +module Skunk + module Generator + # Generates a console report for the analysed modules. + class ConsoleReport + def initialize(analysed_modules) + @analysed_modules = analysed_modules + end + + def generate_report + puts generator.render + end + + private + + def generator + @generator ||= Skunk::Generator::Console::Simple.new(@analysed_modules) + end + end + end +end diff --git a/lib/skunk/rubycritic/analysed_modules_collection.rb b/lib/skunk/rubycritic/analysed_modules_collection.rb index d03fc05..a26763f 100644 --- a/lib/skunk/rubycritic/analysed_modules_collection.rb +++ b/lib/skunk/rubycritic/analysed_modules_collection.rb @@ -65,14 +65,14 @@ def to_hash } end - private - # Returns files as an array of hashes (for JSON serialization) # @return [Array] def files_as_hash @files_as_hash ||= sorted_modules.map(&:to_hash) end + private + # Determines if a module is a test module based on its path # @param a_module [RubyCritic::AnalysedModule] The module to check # @return [Boolean] diff --git a/test/lib/skunk/application_test.rb b/test/lib/skunk/application_test.rb index 4f24063..8055646 100644 --- a/test/lib/skunk/application_test.rb +++ b/test/lib/skunk/application_test.rb @@ -35,28 +35,6 @@ end end - context "when passing --out option with a file" do - require "fileutils" - - let(:argv) { ["--out=tmp/generated_report.txt", "samples/rubycritic"] } - let(:success_code) { 0 } - - it "writes output to the file" do - FileUtils.rm("tmp/generated_report.txt", force: true) - FileUtils.mkdir_p("tmp") - - RubyCritic::AnalysedModule.stub_any_instance(:churn, 1) do - RubyCritic::AnalysedModule.stub_any_instance(:coverage, 100.0) do - result = application.execute - _(result).must_equal success_code - end - end - - _(File.read("tmp/generated_report.txt")) - .must_include File.read("test/samples/console_output.txt") - end - end - context "when comparing two branches" do let(:argv) { ["-b main", "samples/rubycritic"] } let(:success_code) { 0 } diff --git a/test/lib/skunk/commands/status_reporter_test.rb b/test/lib/skunk/commands/status_reporter_test.rb index 26585ab..2586563 100644 --- a/test/lib/skunk/commands/status_reporter_test.rb +++ b/test/lib/skunk/commands/status_reporter_test.rb @@ -32,16 +32,14 @@ def analysed_module.churn example.call end - it "reports the SkunkScore" do - _(reporter.update_status_message).must_include output - _(reporter.update_status_message).must_include "Generated with Skunk v#{Skunk::VERSION}" + it "reports a simple status message" do + _(reporter.update_status_message).must_equal "Skunk analysis complete. Use --format console to see detailed output." end context "When there's nested spec files" do let(:paths) { "samples" } - it "reports the SkunkScore" do - _(reporter.update_status_message).must_include output - _(reporter.update_status_message).must_include "Generated with Skunk v#{Skunk::VERSION}" + it "reports a simple status message" do + _(reporter.update_status_message).must_equal "Skunk analysis complete. Use --format console to see detailed output." end end end diff --git a/test/lib/skunk/generators/console/simple_test.rb b/test/lib/skunk/generators/console/simple_test.rb new file mode 100644 index 0000000..4e2b1d8 --- /dev/null +++ b/test/lib/skunk/generators/console/simple_test.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "test_helper" + +require "skunk/generators/console/simple" + +# Helper methods for this test file +module SkunkMethods + # Returns the count of non-test modules + # @return [Integer] + def analysed_modules_count + @analysed_modules_count ||= non_test_modules.count + end + + # Returns the total Skunk score across all non-test modules + # @return [Float] + def skunk_score_total + @skunk_score_total ||= non_test_modules.sum(&:skunk_score) + end + + # Returns the average Skunk score across all non-test modules + # @return [Float] + def skunk_score_average + return 0.0 if analysed_modules_count.zero? + + (skunk_score_total.to_d / analysed_modules_count).to_f.round(2) + end + + # Returns the total churn times cost across all non-test modules + # @return [Float] + def total_churn_times_cost + @total_churn_times_cost ||= non_test_modules.sum(&:churn_times_cost) + end + + # Returns the module with the highest Skunk score (worst performing) + # @return [Object, nil] + def worst_module + @worst_module ||= sorted_modules.first + end + + # Returns modules sorted by Skunk score in descending order (worst first) + # @return [Array] + def sorted_modules + @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse! + end + + # Returns only non-test modules (excludes test and spec directories) + # @return [Array] + def non_test_modules + @non_test_modules ||= reject do |a_module| + test_module?(a_module) + end + end + + # Returns a hash representation of the analysis results + # @return [Hash] + def to_hash + { + analysed_modules_count: analysed_modules_count, + skunk_score_total: skunk_score_total, + skunk_score_average: skunk_score_average, + total_churn_times_cost: total_churn_times_cost, + worst_pathname: worst_module&.pathname, + worst_score: worst_module&.skunk_score, + files: files_as_hash + } + end + + # Returns files as an array of hashes (for JSON serialization) + # @return [Array] + def files_as_hash + @files_as_hash ||= sorted_modules.map(&:to_hash) + end + + private + + # Determines if a module is a test module based on its path + # @param a_module [Object] The module to check + # @return [Boolean] + def test_module?(a_module) + pathname = a_module.pathname + directory_is_test?(pathname) || filename_is_test?(pathname) + end + + # Checks if the directory path indicates a test module + # @param pathname [Pathname] The pathname to check + # @return [Boolean] + def directory_is_test?(pathname) + module_path = pathname.dirname.to_s + module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec") + end + + # Checks if the filename indicates a test module + # @param pathname [Pathname] The pathname to check + # @return [Boolean] + def filename_is_test?(pathname) + filename = pathname.basename.to_s + filename.end_with?("_test.rb", "_spec.rb") + end +end + +# Helper methods for creating mock objects +def create_analysed_module(path, options = {}) + # Create a simple object that responds to the methods we need + mock_module = Object.new + + # Define the methods we need + mock_module.define_singleton_method(:pathname) { Pathname.new(path) } + mock_module.define_singleton_method(:skunk_score) { options[:skunk_score] || 0.0 } + mock_module.define_singleton_method(:churn_times_cost) { options[:churn_times_cost] || 0.0 } + mock_module.define_singleton_method(:churn) { options[:churn] || 1 } + mock_module.define_singleton_method(:cost) { options[:cost] || 0.0 } + mock_module.define_singleton_method(:coverage) { options[:coverage] || 100.0 } + + # Add to_hash method for JSON serialization + mock_module.define_singleton_method(:to_hash) do + { + file: pathname.to_s, + skunk_score: skunk_score, + churn_times_cost: churn_times_cost, + churn: churn, + cost: cost.round(2), + coverage: coverage.round(2) + } + end + + mock_module +end + +# Creates a collection of analysed modules for testing +# @param analysed_modules [Array] Array of analysed modules +# @return [Object] A collection with the modules +def create_collection(analysed_modules) + # Create a simple array that responds to the methods we need + collection = analysed_modules.dup + + # Add the Skunk methods to the collection + collection.extend(SkunkMethods) + collection +end + +# Creates a simple mock collection for testing console generators +# @return [Object] A collection with one mock module +def create_simple_mock_collection + mock_module = create_analysed_module("samples/rubycritic/analysed_module.rb", + skunk_score: 0.59, + churn_times_cost: 0.59, + churn: 1, + cost: 0.59, + coverage: 100.0) + create_collection([mock_module]) +end + +module Skunk + module Generator + module Console + class SimpleTest < Minitest::Test + def setup + @analysed_modules = create_simple_mock_collection + @simple = Simple.new(@analysed_modules) + end + + def test_initializes_with_analysed_modules + assert_equal @analysed_modules, @simple.instance_variable_get(:@analysed_modules) + end + + def test_render_includes_expected_content + output = @simple.render + + assert_includes output, "SkunkScore Total:" + assert_includes output, "Modules Analysed:" + assert_includes output, "SkunkScore Average:" + assert_includes output, "Generated with Skunk" + end + + def test_render_includes_table_headers + output = @simple.render + + assert_includes output, "file" + assert_includes output, "skunk_score" + assert_includes output, "churn_times_cost" + assert_includes output, "churn" + assert_includes output, "cost" + assert_includes output, "coverage" + end + + def test_headings_constant + expected_headings = %w[file skunk_score churn_times_cost churn cost coverage] + assert_equal expected_headings, Simple::HEADINGS + end + end + end + end +end diff --git a/test/lib/skunk/generators/console_report_test.rb b/test/lib/skunk/generators/console_report_test.rb new file mode 100644 index 0000000..6f880de --- /dev/null +++ b/test/lib/skunk/generators/console_report_test.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "test_helper" + +require "skunk/generators/console_report" + +# Helper methods for this test file +module SkunkMethods + # Returns the count of non-test modules + # @return [Integer] + def analysed_modules_count + @analysed_modules_count ||= non_test_modules.count + end + + # Returns the total Skunk score across all non-test modules + # @return [Float] + def skunk_score_total + @skunk_score_total ||= non_test_modules.sum(&:skunk_score) + end + + # Returns the average Skunk score across all non-test modules + # @return [Float] + def skunk_score_average + return 0.0 if analysed_modules_count.zero? + + (skunk_score_total.to_d / analysed_modules_count).to_f.round(2) + end + + # Returns the total churn times cost across all non-test modules + # @return [Float] + def total_churn_times_cost + @total_churn_times_cost ||= non_test_modules.sum(&:churn_times_cost) + end + + # Returns the module with the highest Skunk score (worst performing) + # @return [Object, nil] + def worst_module + @worst_module ||= sorted_modules.first + end + + # Returns modules sorted by Skunk score in descending order (worst first) + # @return [Array] + def sorted_modules + @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse! + end + + # Returns only non-test modules (excludes test and spec directories) + # @return [Array] + def non_test_modules + @non_test_modules ||= reject do |a_module| + test_module?(a_module) + end + end + + # Returns a hash representation of the analysis results + # @return [Hash] + def to_hash + { + analysed_modules_count: analysed_modules_count, + skunk_score_total: skunk_score_total, + skunk_score_average: skunk_score_average, + total_churn_times_cost: total_churn_times_cost, + worst_pathname: worst_module&.pathname, + worst_score: worst_module&.skunk_score, + files: files_as_hash + } + end + + # Returns files as an array of hashes (for JSON serialization) + # @return [Array] + def files_as_hash + @files_as_hash ||= sorted_modules.map(&:to_hash) + end + + private + + # Determines if a module is a test module based on its path + # @param a_module [Object] The module to check + # @return [Boolean] + def test_module?(a_module) + pathname = a_module.pathname + directory_is_test?(pathname) || filename_is_test?(pathname) + end + + # Checks if the directory path indicates a test module + # @param pathname [Pathname] The pathname to check + # @return [Boolean] + def directory_is_test?(pathname) + module_path = pathname.dirname.to_s + module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec") + end + + # Checks if the filename indicates a test module + # @param pathname [Pathname] The pathname to check + # @return [Boolean] + def filename_is_test?(pathname) + filename = pathname.basename.to_s + filename.end_with?("_test.rb", "_spec.rb") + end +end + +# Helper methods for creating mock objects +def create_analysed_module(path, options = {}) + # Create a simple object that responds to the methods we need + mock_module = Object.new + + # Define the methods we need + mock_module.define_singleton_method(:pathname) { Pathname.new(path) } + mock_module.define_singleton_method(:skunk_score) { options[:skunk_score] || 0.0 } + mock_module.define_singleton_method(:churn_times_cost) { options[:churn_times_cost] || 0.0 } + mock_module.define_singleton_method(:churn) { options[:churn] || 1 } + mock_module.define_singleton_method(:cost) { options[:cost] || 0.0 } + mock_module.define_singleton_method(:coverage) { options[:coverage] || 100.0 } + + # Add to_hash method for JSON serialization + mock_module.define_singleton_method(:to_hash) do + { + file: pathname.to_s, + skunk_score: skunk_score, + churn_times_cost: churn_times_cost, + churn: churn, + cost: cost.round(2), + coverage: coverage.round(2) + } + end + + mock_module +end + +# Creates a collection of analysed modules for testing +# @param analysed_modules [Array] Array of analysed modules +# @return [Object] A collection with the modules +def create_collection(analysed_modules) + # Create a simple array that responds to the methods we need + collection = analysed_modules.dup + + # Add the Skunk methods to the collection + collection.extend(SkunkMethods) + collection +end + +# Creates a simple mock collection for testing console generators +# @return [Object] A collection with one mock module +def create_simple_mock_collection + mock_module = create_analysed_module("samples/rubycritic/analysed_module.rb", + skunk_score: 0.59, + churn_times_cost: 0.59, + churn: 1, + cost: 0.59, + coverage: 100.0) + create_collection([mock_module]) +end + +module Skunk + module Generator + class ConsoleReportTest < Minitest::Test + def setup + @analysed_modules = create_simple_mock_collection + @console_report = ConsoleReport.new(@analysed_modules) + end + + def test_initializes_with_analysed_modules + # Test that the console report was initialized with the analysed modules + assert_equal @analysed_modules, @console_report.instance_variable_get(:@analysed_modules) + end + + def test_generator_returns_console_simple_instance + generator = @console_report.send(:generator) + assert_instance_of Skunk::Generator::Console::Simple, generator + end + + def test_generate_report_calls_generator_render + # Test that generate_report calls the generator's render method + @console_report.send(:generator) + + # Mock the generator to verify it's called + mock_generator = Minitest::Mock.new + mock_generator.expect :render, "test output" + + @console_report.instance_variable_set(:@generator, mock_generator) + + # Capture stdout to test the output + output = capture_stdout do + @console_report.generate_report + end + + assert_equal "test output\n", output + mock_generator.verify + end + end + end +end diff --git a/test/lib/skunk/rubycritic/analysed_modules_collection_test.rb b/test/lib/skunk/rubycritic/analysed_modules_collection_test.rb index 5bf4e92..5592bfc 100644 --- a/test/lib/skunk/rubycritic/analysed_modules_collection_test.rb +++ b/test/lib/skunk/rubycritic/analysed_modules_collection_test.rb @@ -5,6 +5,141 @@ require "skunk/rubycritic/analysed_modules_collection" require "skunk/rubycritic/analysed_module" +# Helper methods for this test file +module SkunkMethods + # Returns the count of non-test modules + # @return [Integer] + def analysed_modules_count + @analysed_modules_count ||= non_test_modules.count + end + + # Returns the total Skunk score across all non-test modules + # @return [Float] + def skunk_score_total + @skunk_score_total ||= non_test_modules.sum(&:skunk_score) + end + + # Returns the average Skunk score across all non-test modules + # @return [Float] + def skunk_score_average + return 0.0 if analysed_modules_count.zero? + + (skunk_score_total.to_d / analysed_modules_count).to_f.round(2) + end + + # Returns the total churn times cost across all non-test modules + # @return [Float] + def total_churn_times_cost + @total_churn_times_cost ||= non_test_modules.sum(&:churn_times_cost) + end + + # Returns the module with the highest Skunk score (worst performing) + # @return [Object, nil] + def worst_module + @worst_module ||= sorted_modules.first + end + + # Returns modules sorted by Skunk score in descending order (worst first) + # @return [Array] + def sorted_modules + @sorted_modules ||= non_test_modules.sort_by(&:skunk_score).reverse! + end + + # Returns only non-test modules (excludes test and spec directories) + # @return [Array] + def non_test_modules + @non_test_modules ||= reject do |a_module| + test_module?(a_module) + end + end + + # Returns a hash representation of the analysis results + # @return [Hash] + def to_hash + { + analysed_modules_count: analysed_modules_count, + skunk_score_total: skunk_score_total, + skunk_score_average: skunk_score_average, + total_churn_times_cost: total_churn_times_cost, + worst_pathname: worst_module&.pathname, + worst_score: worst_module&.skunk_score, + files: files_as_hash + } + end + + # Returns files as an array of hashes (for JSON serialization) + # @return [Array] + def files_as_hash + @files_as_hash ||= sorted_modules.map(&:to_hash) + end + + private + + # Determines if a module is a test module based on its path + # @param a_module [Object] The module to check + # @return [Boolean] + def test_module?(a_module) + pathname = a_module.pathname + directory_is_test?(pathname) || filename_is_test?(pathname) + end + + # Checks if the directory path indicates a test module + # @param pathname [Pathname] The pathname to check + # @return [Boolean] + def directory_is_test?(pathname) + module_path = pathname.dirname.to_s + module_path.start_with?("test", "spec") || module_path.end_with?("test", "spec") + end + + # Checks if the filename indicates a test module + # @param pathname [Pathname] The pathname to check + # @return [Boolean] + def filename_is_test?(pathname) + filename = pathname.basename.to_s + filename.end_with?("_test.rb", "_spec.rb") + end +end + +# Helper methods for creating mock objects +def create_analysed_module(path, options = {}) + # Create a simple object that responds to the methods we need + mock_module = Object.new + + # Define the methods we need + mock_module.define_singleton_method(:pathname) { Pathname.new(path) } + mock_module.define_singleton_method(:skunk_score) { options[:skunk_score] || 0.0 } + mock_module.define_singleton_method(:churn_times_cost) { options[:churn_times_cost] || 0.0 } + mock_module.define_singleton_method(:churn) { options[:churn] || 1 } + mock_module.define_singleton_method(:cost) { options[:cost] || 0.0 } + mock_module.define_singleton_method(:coverage) { options[:coverage] || 100.0 } + + # Add to_hash method for JSON serialization + mock_module.define_singleton_method(:to_hash) do + { + file: pathname.to_s, + skunk_score: skunk_score, + churn_times_cost: churn_times_cost, + churn: churn, + cost: cost.round(2), + coverage: coverage.round(2) + } + end + + mock_module +end + +# Creates a collection of analysed modules for testing +# @param analysed_modules [Array] Array of analysed modules +# @return [Object] A collection with the modules +def create_collection(analysed_modules) + # Create a simple array that responds to the methods we need + collection = analysed_modules.dup + + # Add the Skunk methods to the collection + collection.extend(SkunkMethods) + collection +end + describe RubyCritic::AnalysedModulesCollection do let(:analysed_modules) { [] } let(:collection) { create_collection(analysed_modules) } @@ -248,38 +383,4 @@ _(hash[:files].first[:skunk_score]).must_equal 10.0 end end - - private - - def create_collection(modules) - # Create a collection by manually setting the @modules instance variable - # This bypasses the complex initialization that expects file paths - collection = RubyCritic::AnalysedModulesCollection.new([], []) - collection.instance_variable_set(:@modules, modules) - collection - end - - def create_analysed_module(path, skunk_score: 0.0, churn_times_cost: 0.0) - module_path = Pathname.new(path) - analysed_module = RubyCritic::AnalysedModule.new( - pathname: module_path, - smells: [], - churn: 1, - committed_at: Time.now - ) - - add_mock_methods_to_module(analysed_module, skunk_score, churn_times_cost) - end - - def add_mock_methods_to_module(analysed_module, skunk_score, churn_times_cost) - # Mock the skunk_score and churn_times_cost methods - analysed_module.define_singleton_method(:skunk_score) { @skunk_score ||= 0.0 } - analysed_module.define_singleton_method(:skunk_score=) { |value| @skunk_score = value } - analysed_module.define_singleton_method(:churn_times_cost) { @churn_times_cost ||= 0.0 } - analysed_module.define_singleton_method(:churn_times_cost=) { |value| @churn_times_cost = value } - - analysed_module.skunk_score = skunk_score - analysed_module.churn_times_cost = churn_times_cost - analysed_module - end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3d4c2f1..658d345 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,12 +5,16 @@ require "simplecov-console" require "codecov" - SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ + formatters = [ SimpleCov::Formatter::HTMLFormatter, - SimpleCov::Formatter::Console, - SimpleCov::Formatter::Codecov + SimpleCov::Formatter::Console ] + # Only add Codecov formatter if CODECOV_TOKEN is set + formatters << SimpleCov::Formatter::Codecov if ENV["CODECOV_TOKEN"] + + SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(formatters) + SimpleCov.start do add_filter "lib/skunk/version.rb" add_filter "test/lib" @@ -30,6 +34,27 @@ require "skunk" require "skunk/rubycritic/analysed_module" +# Helper modules for testing +module MockHelpers + # Helper methods for mocking in tests + + # Captures stdout output for testing + # @return [String] The captured output + def capture_stdout + old_stdout = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = old_stdout + end +end + +# Include helper modules in Minitest::Test +class Minitest::Test + include MockHelpers +end + def context(*args, &block) describe(*args, &block) end From 33db26d995696210f833a0d03101e7a27fc8c83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 17 Oct 2025 20:59:04 -0600 Subject: [PATCH 4/7] Update StatusReporter class This class used to return the Console message but now it does not have that responsibility and will only return an empty string until we get the GPA Score on Skunk, we will print it as RubyCritic does --- bin/console | 2 +- lib/skunk/commands/default.rb | 3 -- lib/skunk/commands/status_reporter.rb | 7 +--- lib/skunk/generators/html_report.rb | 9 ++++- lib/skunk/generators/json_report.rb | 18 +++++++-- lib/skunk/reporter.rb | 11 +++-- .../skunk/commands/status_reporter_test.rb | 40 +------------------ test/samples/console_output.txt | 10 ----- 8 files changed, 33 insertions(+), 67 deletions(-) delete mode 100644 test/samples/console_output.txt diff --git a/bin/console b/bin/console index 2e978e4..00c2b0a 100755 --- a/bin/console +++ b/bin/console @@ -13,5 +13,5 @@ puts ARGV.inspect require "skunk/cli/application" require "skunk/config" -Skunk::Config.formats = %i[json html] +Skunk::Config.formats = %i[json console html] Skunk::Cli::Application.new(ARGV).execute diff --git a/lib/skunk/commands/default.rb b/lib/skunk/commands/default.rb index 7c2d2a5..21d5ae4 100644 --- a/lib/skunk/commands/default.rb +++ b/lib/skunk/commands/default.rb @@ -37,9 +37,6 @@ def execute # @param [RubyCritic::AnalysedModulesCollection] A collection of analysed modules def report(analysed_modules) Reporter.generate_report(analysed_modules) - - status_reporter.analysed_modules = analysed_modules - status_reporter.score = analysed_modules.score end end end diff --git a/lib/skunk/commands/status_reporter.rb b/lib/skunk/commands/status_reporter.rb index f305bfd..502c038 100644 --- a/lib/skunk/commands/status_reporter.rb +++ b/lib/skunk/commands/status_reporter.rb @@ -4,17 +4,14 @@ module Skunk module Command - # Knows how to report status for stinky files + # Extends RubyCritic::Command::StatusReporter to silence the status message class StatusReporter < RubyCritic::Command::StatusReporter - attr_accessor :analysed_modules - def initialize(options = {}) super(options) end - # Returns a simple status message indicating the analysis is complete def update_status_message - @status_message = "Skunk Report Completed" + @status_message = "" end end end diff --git a/lib/skunk/generators/html_report.rb b/lib/skunk/generators/html_report.rb index 095a654..1f938fc 100644 --- a/lib/skunk/generators/html_report.rb +++ b/lib/skunk/generators/html_report.rb @@ -18,7 +18,7 @@ def initialize(analysed_modules) def generate_report create_directories_and_files - puts "Skunk report generated at #{report_location}" + puts "#{report_name} generated at #{report_location}" browser.open unless RubyCritic::Config.no_browser end @@ -40,6 +40,13 @@ def generators def overview_generator @overview_generator ||= Skunk::Generator::Html::Overview.new(@analysed_modules) end + + def report_name + self.class.name.split("::").last + .gsub(/([a-z])([A-Z])/, '\1 \2') + .downcase + .capitalize + end end end end diff --git a/lib/skunk/generators/json_report.rb b/lib/skunk/generators/json_report.rb index be7742d..ca0b49d 100644 --- a/lib/skunk/generators/json_report.rb +++ b/lib/skunk/generators/json_report.rb @@ -1,23 +1,33 @@ # frozen_string_literal: true -require "rubycritic/generators/json_report" - require "skunk/generators/json/simple" module Skunk module Generator # Generates a JSON report for the analysed modules. - class JsonReport < RubyCritic::Generator::JsonReport + class JsonReport def initialize(analysed_modules) - super @analysed_modules = analysed_modules end + def generate_report + FileUtils.mkdir_p(generator.file_directory) + puts "#{report_name} generated at #{generator.file_pathname}" + File.write(generator.file_pathname, generator.render) + end + private def generator Skunk::Generator::Json::Simple.new(@analysed_modules) end + + def report_name + self.class.name.split("::").last + .gsub(/([a-z])([A-Z])/, '\1 \2') + .downcase + .capitalize + end end end end diff --git a/lib/skunk/reporter.rb b/lib/skunk/reporter.rb index 8c7b399..34089d7 100644 --- a/lib/skunk/reporter.rb +++ b/lib/skunk/reporter.rb @@ -13,10 +13,13 @@ def self.generate_report(analysed_modules) end def self.report_generator_class(config_format) - return unless Config.supported_format?(config_format) - - require "skunk/generators/#{config_format}_report" - Generator.const_get("#{config_format.capitalize}Report") + if Config.supported_format?(config_format) + require "skunk/generators/#{config_format}_report" + Generator.const_get("#{config_format.capitalize}Report") + else + require "skunk/generators/console_report" + Generator::ConsoleReport + end end end end diff --git a/test/lib/skunk/commands/status_reporter_test.rb b/test/lib/skunk/commands/status_reporter_test.rb index 2586563..74ca28d 100644 --- a/test/lib/skunk/commands/status_reporter_test.rb +++ b/test/lib/skunk/commands/status_reporter_test.rb @@ -2,52 +2,14 @@ require "test_helper" -require "rubycritic/analysers_runner" -require "skunk/rubycritic/analysed_modules_collection" require "skunk/commands/status_reporter" describe Skunk::Command::StatusReporter do - let(:paths) { "samples/rubycritic" } - describe "#update_status_message" do - let(:output) { File.read("test/samples/console_output.txt") } let(:reporter) { Skunk::Command::StatusReporter.new({}) } - around do |example| - RubyCritic::Config.source_control_system = MockGit.new - runner = RubyCritic::AnalysersRunner.new(paths) - analysed_modules = runner.run - analysed_modules.each do |analysed_module| - def analysed_module.coverage - 100.0 - end - - def analysed_module.churn - 1 - end - end - - reporter.analysed_modules = analysed_modules - reporter.score = analysed_modules.score - example.call - end - it "reports a simple status message" do - _(reporter.update_status_message).must_equal "Skunk analysis complete. Use --format console to see detailed output." + _(reporter.update_status_message).must_equal "" end - - context "When there's nested spec files" do - let(:paths) { "samples" } - it "reports a simple status message" do - _(reporter.update_status_message).must_equal "Skunk analysis complete. Use --format console to see detailed output." - end - end - end -end - -# A Mock Git class that returns always 1 for revisions_count -class MockGit < RubyCritic::SourceControlSystem::Git - def revisions_count(_) - 1 end end diff --git a/test/samples/console_output.txt b/test/samples/console_output.txt deleted file mode 100644 index 96dc3eb..0000000 --- a/test/samples/console_output.txt +++ /dev/null @@ -1,10 +0,0 @@ -+---------------------------------------+----------------+------------------+--------------+--------------+--------------+ -| file | skunk_score | churn_times_cost | churn | cost | coverage | -+---------------------------------------+----------------+------------------+--------------+--------------+--------------+ -| samples/rubycritic/analysed_module.rb | 0.59 | 0.59 | 1 | 0.59 | 100.0 | -+---------------------------------------+----------------+------------------+--------------+--------------+--------------+ - -SkunkScore Total: 0.59 -Modules Analysed: 1 -SkunkScore Average: 0.59 -Worst SkunkScore: 0.59 (samples/rubycritic/analysed_module.rb) From c3f498722b79f31f1615824c7bbac70c4ef14a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 3 Nov 2025 09:13:57 -0600 Subject: [PATCH 5/7] Update Status Sharer to use skunk analysis from the collection module --- lib/skunk/commands/default.rb | 2 ++ lib/skunk/commands/status_reporter.rb | 2 ++ lib/skunk/commands/status_sharer.rb | 13 +++++++------ lib/skunk/generators/json_report.rb | 8 ++++++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/skunk/commands/default.rb b/lib/skunk/commands/default.rb index 21d5ae4..807878f 100644 --- a/lib/skunk/commands/default.rb +++ b/lib/skunk/commands/default.rb @@ -37,6 +37,8 @@ def execute # @param [RubyCritic::AnalysedModulesCollection] A collection of analysed modules def report(analysed_modules) Reporter.generate_report(analysed_modules) + + status_reporter.analysed_modules = analysed_modules end end end diff --git a/lib/skunk/commands/status_reporter.rb b/lib/skunk/commands/status_reporter.rb index 502c038..8689c7d 100644 --- a/lib/skunk/commands/status_reporter.rb +++ b/lib/skunk/commands/status_reporter.rb @@ -6,6 +6,8 @@ module Skunk module Command # Extends RubyCritic::Command::StatusReporter to silence the status message class StatusReporter < RubyCritic::Command::StatusReporter + attr_accessor :analysed_modules + def initialize(options = {}) super(options) end diff --git a/lib/skunk/commands/status_sharer.rb b/lib/skunk/commands/status_sharer.rb index bc38415..f46971a 100644 --- a/lib/skunk/commands/status_sharer.rb +++ b/lib/skunk/commands/status_sharer.rb @@ -40,15 +40,16 @@ def base_url def json_summary result = { - total_skunk_score: total_skunk_score, - analysed_modules_count: analysed_modules_count, - skunk_score_average: skunk_score_average, + total_skunk_score: analysed_modules.skunk_score_total, + analysed_modules_count: analysed_modules.analysed_modules_count, + skunk_score_average: analysed_modules.skunk_score_average, skunk_version: Skunk::VERSION } - if worst + if analysed_modules&.worst_module + worst = analysed_modules.worst_module result[:worst_skunk_score] = { - file: worst.pathname.to_s, + file: worst.pathname, skunk_score: worst.skunk_score } end @@ -57,7 +58,7 @@ def json_summary end def json_results - sorted_modules.map(&:to_hash) + analysed_modules.sorted_modules.map(&:to_hash) end # :reek:UtilityFunction diff --git a/lib/skunk/generators/json_report.rb b/lib/skunk/generators/json_report.rb index ca0b49d..6da5fc5 100644 --- a/lib/skunk/generators/json_report.rb +++ b/lib/skunk/generators/json_report.rb @@ -12,8 +12,8 @@ def initialize(analysed_modules) def generate_report FileUtils.mkdir_p(generator.file_directory) - puts "#{report_name} generated at #{generator.file_pathname}" - File.write(generator.file_pathname, generator.render) + puts "#{report_name} generated at #{file_path}" + File.write(file_path, generator.render) end private @@ -28,6 +28,10 @@ def report_name .downcase .capitalize end + + def file_path + generator.file_pathname + end end end end From 56d8bf04468fa244abea7eca65d91b255b6c867f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 13 Nov 2025 17:07:21 -0600 Subject: [PATCH 6/7] Move Console report --- lib/skunk/config.rb | 2 +- test/lib/skunk/config_test.rb | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/skunk/config.rb b/lib/skunk/config.rb index 61cedf5..769590d 100644 --- a/lib/skunk/config.rb +++ b/lib/skunk/config.rb @@ -24,7 +24,7 @@ def self.supported_formats # Similar to RubyCritic::Configuration but focused only on Skunk's needs class Configuration # Default format - DEFAULT_FORMAT = :json + DEFAULT_FORMAT = :console def initialize @formats = [DEFAULT_FORMAT] diff --git a/test/lib/skunk/config_test.rb b/test/lib/skunk/config_test.rb index febe6c8..a04e1c6 100644 --- a/test/lib/skunk/config_test.rb +++ b/test/lib/skunk/config_test.rb @@ -12,7 +12,7 @@ def setup end def test_default_format - assert_equal [:json], Config.formats + assert_equal [:console], Config.formats end def test_set_formats_with_array @@ -32,23 +32,23 @@ def test_set_formats_filters_unsupported_formats def test_set_formats_with_empty_array_defaults_to_json Config.formats = [] - assert_equal [:json], Config.formats + assert_equal [:console], Config.formats end def test_add_format Config.add_format(:html) - assert_equal %i[json html], Config.formats + assert_equal %i[console html], Config.formats end def test_add_format_ignores_duplicates Config.add_format(:html) Config.add_format(:html) # This should be ignored as duplicate - assert_equal %i[json html], Config.formats + assert_equal %i[console html], Config.formats end def test_add_format_ignores_unsupported_formats Config.add_format(:unsupported) - assert_equal [:json], Config.formats + assert_equal [:console], Config.formats end def test_remove_format @@ -59,7 +59,7 @@ def test_remove_format def test_remove_format_defaults_to_json_when_empty Config.remove_format(:json) - assert_equal [:json], Config.formats + assert_equal [:console], Config.formats end def test_supported_format @@ -77,7 +77,7 @@ def test_supported_formats def test_reset Config.formats = [:html] Config.reset - assert_equal [:json], Config.formats + assert_equal [:console], Config.formats end end end From 89aeec908bb475d36d24c3c424f6f1115f4e176e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 13 Nov 2025 17:20:57 -0600 Subject: [PATCH 7/7] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb8fcb..112ad26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## main [(unreleased)](https://github.com/fastruby/skunk/compare/v0.5.4...HEAD) +* [REFACTOR: Move Console Report](https://github.com/fastruby/skunk/pull/128) * [BUGFIX: Set the right content type in the share HTTP request](https://github.com/fastruby/skunk/pull/129) * [REFACTOR: Centralize Skunk analysis into RubyCritic module](https://github.com/fastruby/skunk/pull/127) * [FEATURE: Add Skunk HTML Report](https://github.com/fastruby/skunk/pull/123)