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