From 3f9ae8ccdb4a3b912ff4ed976d00fb80ad3bbc3c 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/5] 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 98ef469e78bb069037095bd86f3a4523999578cc 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/5] 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 0d827b83293cbba3a8baa61a1ef05fc0250ce911 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/5] 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 3985c8dfe21370790141168c62b115a02089283c 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/5] 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 2b5e5b22aac1ff22285374887156f883146583da 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/5] 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 af23d49..f5047b2 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