diff --git a/.github/workflows/calculate-code-quality-score.yml b/.github/workflows/calculate-code-quality-score.yml index d5caa71..6d768a3 100644 --- a/.github/workflows/calculate-code-quality-score.yml +++ b/.github/workflows/calculate-code-quality-score.yml @@ -26,7 +26,7 @@ jobs: run: | echo "source 'https://rubygems.org'" > ${{ github.workspace }}/CodeQuality.gemfile echo "git_source(:github) { |repo| \"https://github.com/#{repo}.git\" }" >> ${{ github.workspace }}/CodeQuality.gemfile - echo "gem 'code_quality_score', git: 'https://github.com/boost/code_quality_score'" >> ${{ github.workspace }}/CodeQuality.gemfile + echo "gem 'code_quality_score', git: 'https://github.com/boost/code_quality_score', branch: 'code-quality-file-list'" >> ${{ github.workspace }}/CodeQuality.gemfile echo "gem 'rexml'" >> ${{ github.workspace }}/CodeQuality.gemfile rm --force ${{ github.workspace }}/CodeQuality.gemfile.lock rm --force ${{ github.workspace }}/.bundle/config diff --git a/Gemfile.lock b/Gemfile.lock index e02d67f..6c0647b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,6 +6,7 @@ PATH flay (>= 2.13.0) flog (>= 4.6.5) reek (>= 6.1.1) + ruby_parser GEM remote: https://rubygems.org/ @@ -84,6 +85,7 @@ GEM uri (0.13.0) PLATFORMS + arm64-darwin-24 x86_64-darwin-19 x86_64-darwin-21 diff --git a/exe/code_quality_score_comparison b/exe/code_quality_score_comparison index 9451a49..7fe3402 100755 --- a/exe/code_quality_score_comparison +++ b/exe/code_quality_score_comparison @@ -12,4 +12,4 @@ base_score_snapshot = CodeQualityScore::ScoreSnapshot.new(repository_path: base_ pr_score_snapshot = CodeQualityScore::ScoreSnapshot.new(repository_path: pr_relative_path).calculate_score # format -CodeQualityScore::FormatComparison.format_as_markdown(base_score_snapshot, pr_score_snapshot) +puts CodeQualityScore::FormatComparison.format_as_markdown(base_score_snapshot, pr_score_snapshot) diff --git a/lib/code_quality_score/format_comparison.rb b/lib/code_quality_score/format_comparison.rb index 7ac76e9..33a0ab4 100644 --- a/lib/code_quality_score/format_comparison.rb +++ b/lib/code_quality_score/format_comparison.rb @@ -4,6 +4,8 @@ module CodeQualityScore class FormatComparison def self.format_as_markdown(base_result, pr_result) difference_result = base_result.map do |key, value| + next [key, value] unless value.is_a?(Numeric) + difference_value = (pr_result[key] - value).round(2) [key, difference_value] end.to_h @@ -38,7 +40,8 @@ def self.format_as_markdown(base_result, pr_result) HEREDOC - puts result + result += format_file_breakdown(base_result, pr_result) + result end def self.format_row(name, hash, with_emoji) @@ -56,5 +59,63 @@ def self.format_value(number, with_emoji) number.to_s end + + def self.format_file_breakdown(base_result, pr_result) + sections = [] + + reek_worse = (pr_result[:code_smells_per_file] || 0) > (base_result[:code_smells_per_file] || 0) + if reek_worse + base_reek = (base_result[:reek_files] || []).each_with_object({}) { |h, m| m[h[:file]] = h[:smells] } + worse_reek = (pr_result[:reek_files] || []).select { |h| h[:smells] > (base_reek[h[:file]] || 0) } + unless worse_reek.empty? + lines = worse_reek.map { |h| "- `#{h[:file]}` — #{h[:smells]} smells (+#{h[:smells] - (base_reek[h[:file]] || 0)})" }.join("\n") + sections << <<~MD +
+ Files with more code smells than base (reek) + + #{lines} + +
+ MD + end + end + + flog_worse = (pr_result[:abc_method_average] || 0) > (base_result[:abc_method_average] || 0) + if flog_worse + base_flog = (base_result[:flog_files] || []).each_with_object({}) { |h, m| m[h[:file]] = h[:score] } + worse_flog = (pr_result[:flog_files] || []).select { |h| h[:score] > (base_flog[h[:file]] || 0.0) } + unless worse_flog.empty? + lines = worse_flog.map { |h| "- `#{h[:file]}` — score: #{h[:score]} (+#{(h[:score] - (base_flog[h[:file]] || 0.0)).round(2)})" }.join("\n") + sections << <<~MD +
+ Files with higher complexity than base (flog) + + #{lines} + +
+ MD + end + end + + flay_worse = (pr_result[:similarity_score] || 0) > (base_result[:similarity_score] || 0) + if flay_worse && (pr_result[:flay_blocks] || []).any? + block_lines = (pr_result[:flay_blocks] || []).each_with_index.map do |block, i| + locs = block[:locations].map { |l| "- `#{l[:file]}` (line #{l[:line]})" }.join("\n") + "**Block #{i + 1}** — similarity mass: #{block[:mass]}\n#{locs}" + end.join("\n\n") + sections << <<~MD +
+ New/worsened duplication (flay) + + #{block_lines} + +
+ MD + end + + sections.join + end + + private_class_method :format_file_breakdown end end diff --git a/lib/code_quality_score/score_snapshot.rb b/lib/code_quality_score/score_snapshot.rb index 5324d1a..6d6925c 100644 --- a/lib/code_quality_score/score_snapshot.rb +++ b/lib/code_quality_score/score_snapshot.rb @@ -17,13 +17,20 @@ def calculate_score folders = find_folders.join(' ') ruby_file_count = count_ruby_files(folders) + flay_output = `flay #{folders}` + flog_output = `flog #{folders}` + reek_output = `reek #{folders}` + result = { - similarity_score: structural_similarity_score(folders), - abc_method_average: abc_method_average_score(folders), - code_smells_per_file: code_smells_per_file(folders, ruby_file_count) + similarity_score: structural_similarity_score(flay_output), + abc_method_average: abc_method_average_score(flog_output), + code_smells_per_file: code_smells_per_file(reek_output, ruby_file_count), + reek_files: parse_reek_files(reek_output), + flog_files: parse_flog_files(flog_output), + flay_blocks: parse_flay_blocks(flay_output) } - result[:total_score] = result.values.sum.round(2) + result[:total_score] = (result[:similarity_score] + result[:abc_method_average] + result[:code_smells_per_file]).round(2) result[:total_file_count] = count_files(folders) result[:ruby_file_count] = ruby_file_count @@ -46,26 +53,65 @@ def count_ruby_files(folders) Integer(`find #{folders} -type f -name "*.rb" | wc -l`) end - def structural_similarity_score(folders) - score_line = `flay #{folders} | head -n 1` + def structural_similarity_score(flay_output) + score_line = flay_output.lines.first score_number = Float(score_line.split(" ").last).round(2) weighted_score = score_number * @score_weights[:similarity_score] weighted_score.round(2) end - def abc_method_average_score(folders) - score_line = `flog #{folders} | head -n 2 | tail -1` + def abc_method_average_score(flog_output) + score_line = flog_output.lines[1] score = Float(score_line.split(":").first).round(2) weighted_score = score * @score_weights[:abc_method_average] weighted_score.round(2) end - def code_smells_per_file(folders, file_count) - score_line = `reek #{folders} | tail -1` + def code_smells_per_file(reek_output, file_count) + score_line = reek_output.lines.last score_number = Float(score_line.split(" ").first) score_per_file = (score_number / file_count).round(2) weighted_score = score_per_file * @score_weights[:code_smells_per_file] weighted_score.round(2) end + + def parse_reek_files(reek_output) + reek_output.lines.each_with_object([]) do |line, arr| + match = line.match(/^(.+\.rb) -- (\d+) warning/) + arr << { file: normalize_path(match[1]), smells: match[2].to_i } if match + end.sort_by { |h| -h[:smells] } + end + + def parse_flog_files(flog_output) + file_scores = Hash.new(0.0) + flog_output.lines.each do |line| + match = line.match(/^\s+([\d.]+):\s+\S+\s+(\S+\.rb):\d+/) + file_scores[normalize_path(match[2])] += match[1].to_f if match + end + file_scores.map { |file, score| { file: file, score: score.round(2) } } + .sort_by { |h| -h[:score] } + end + + def parse_flay_blocks(flay_output) + blocks = [] + current_block = nil + + flay_output.lines.each do |line| + if (match = line.match(/^\d+\).+mass = (\d+)/)) + blocks << current_block if current_block + current_block = { mass: match[1].to_i, locations: [] } + elsif current_block && (match = line.match(/^\s+(.+\.rb):(\d+)/)) + current_block[:locations] << { file: normalize_path(match[1]), line: match[2].to_i } + end + end + blocks << current_block if current_block + + blocks.sort_by { |b| -b[:mass] } + end + + def normalize_path(path) + match = path.match(%r{((?:app|lib)/.+\.rb)}) + match ? match[1] : path + end end end diff --git a/spec/code_quality_score_spec.rb b/spec/code_quality_score_spec.rb index a401a7a..a554890 100644 --- a/spec/code_quality_score_spec.rb +++ b/spec/code_quality_score_spec.rb @@ -27,7 +27,7 @@ end it "calculates scores for a repository as expected" do - expect(score_snapshot.calculate_score).to eq(expected_scores) + expect(score_snapshot.calculate_score).to include(expected_scores) end context "when custom score weights are passed" do @@ -50,4 +50,55 @@ end end end + + describe "file breakdown keys" do + subject(:result) { score_snapshot.calculate_score } + + it "returns reek_files as an array of hashes with file and smells keys" do + expect(result[:reek_files]).to be_an(Array) + result[:reek_files].each do |entry| + expect(entry).to include(:file, :smells) + expect(entry[:file]).to end_with('.rb') + expect(entry[:smells]).to be_a(Integer) + end + end + + it "returns reek_files sorted by smells descending" do + smells = result[:reek_files].map { |h| h[:smells] } + expect(smells).to eq(smells.sort.reverse) + end + + it "returns flog_files as an array of hashes with file and score keys" do + expect(result[:flog_files]).to be_an(Array) + result[:flog_files].each do |entry| + expect(entry).to include(:file, :score) + expect(entry[:file]).to end_with('.rb') + expect(entry[:score]).to be_a(Float) + end + end + + it "returns flog_files sorted by score descending" do + scores = result[:flog_files].map { |h| h[:score] } + expect(scores).to eq(scores.sort.reverse) + end + + it "returns flay_blocks as an array of hashes with mass and locations keys" do + expect(result[:flay_blocks]).to be_an(Array) + result[:flay_blocks].each do |block| + expect(block).to include(:mass, :locations) + expect(block[:mass]).to be_a(Integer) + expect(block[:locations]).to be_an(Array) + block[:locations].each do |loc| + expect(loc).to include(:file, :line) + expect(loc[:file]).to end_with('.rb') + expect(loc[:line]).to be_a(Integer) + end + end + end + + it "returns flay_blocks sorted by mass descending" do + masses = result[:flay_blocks].map { |b| b[:mass] } + expect(masses).to eq(masses.sort.reverse) + end + end end diff --git a/spec/format_comparison_spec.rb b/spec/format_comparison_spec.rb new file mode 100644 index 0000000..c2f3798 --- /dev/null +++ b/spec/format_comparison_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "code_quality_score/format_comparison" + +RSpec.describe CodeQualityScore::FormatComparison do + let(:base_result) do + { + similarity_score: 1.0, + abc_method_average: 1.0, + code_smells_per_file: 1.0, + total_score: 3.0, + total_file_count: 2, + ruby_file_count: 2, + reek_files: [{ file: "app/models/user.rb", smells: 3 }], + flog_files: [{ file: "app/models/user.rb", score: 10.0 }], + flay_blocks: [{ mass: 30, locations: [{ file: "app/models/user.rb", line: 1 }, { file: "lib/foo.rb", line: 5 }] }] + } + end + + let(:worse_pr_result) do + { + similarity_score: 2.0, + abc_method_average: 2.0, + code_smells_per_file: 2.0, + total_score: 6.0, + total_file_count: 2, + ruby_file_count: 2, + reek_files: [{ file: "app/models/user.rb", smells: 5 }, { file: "lib/bar.rb", smells: 2 }], + flog_files: [{ file: "app/models/user.rb", score: 20.0 }, { file: "lib/bar.rb", score: 5.0 }], + flay_blocks: [{ mass: 50, locations: [{ file: "app/models/user.rb", line: 1 }, { file: "lib/foo.rb", line: 5 }] }] + } + end + + let(:better_pr_result) do + { + similarity_score: 0.5, + abc_method_average: 0.5, + code_smells_per_file: 0.5, + total_score: 1.5, + total_file_count: 2, + ruby_file_count: 2, + reek_files: [{ file: "app/models/user.rb", smells: 1 }], + flog_files: [{ file: "app/models/user.rb", score: 5.0 }], + flay_blocks: [] + } + end + + describe ".format_as_markdown" do + context "when the PR is worse than base" do + subject(:output) { described_class.format_as_markdown(base_result, worse_pr_result) } + + it "includes a collapsible reek section listing files with more smells" do + expect(output).to include("
") + expect(output).to include("Files with more code smells than base (reek)") + expect(output).to include("`app/models/user.rb` — 5 smells") + expect(output).to include("`lib/bar.rb` — 2 smells") + end + + it "does not list reek files that stayed the same or improved" do + expect(output).not_to include("— 3 smells") + end + + it "includes a collapsible flog section listing files with higher complexity" do + expect(output).to include("Files with higher complexity than base (flog)") + expect(output).to include("`app/models/user.rb` — score: 20.0") + expect(output).to include("`lib/bar.rb` — score: 5.0") + end + + it "includes a collapsible flay section when similarity score worsened" do + expect(output).to include("New/worsened duplication (flay)") + expect(output).to include("similarity mass: 50") + expect(output).to include("`app/models/user.rb` (line 1)") + expect(output).to include("`lib/foo.rb` (line 5)") + end + + it "wraps sections in details/summary tags" do + expect(output).to include("
") + expect(output).to include("") + expect(output).to include("
") + end + end + + context "when the PR is better than base" do + subject(:output) { described_class.format_as_markdown(base_result, better_pr_result) } + + it "omits all details sections" do + expect(output).not_to include("
") + end + end + + context "when aggregate scores are unchanged but individual file scores shifted" do + subject(:output) do + pr_result = worse_pr_result.merge( + abc_method_average: base_result[:abc_method_average], + code_smells_per_file: base_result[:code_smells_per_file], + similarity_score: base_result[:similarity_score] + ) + described_class.format_as_markdown(base_result, pr_result) + end + + it "omits all details sections" do + expect(output).not_to include("
") + end + end + end +end