Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/calculate-code-quality-score.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ PATH
flay (>= 2.13.0)
flog (>= 4.6.5)
reek (>= 6.1.1)
ruby_parser

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -84,6 +85,7 @@ GEM
uri (0.13.0)

PLATFORMS
arm64-darwin-24
x86_64-darwin-19
x86_64-darwin-21

Expand Down
2 changes: 1 addition & 1 deletion exe/code_quality_score_comparison
Original file line number Diff line number Diff line change
Expand Up @@ -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)
63 changes: 62 additions & 1 deletion lib/code_quality_score/format_comparison.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
<details>
<summary>Files with more code smells than base (reek)</summary>

#{lines}

</details>
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
<details>
<summary>Files with higher complexity than base (flog)</summary>

#{lines}

</details>
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
<details>
<summary>New/worsened duplication (flay)</summary>

#{block_lines}

</details>
MD
end

sections.join
end

private_class_method :format_file_breakdown
end
end
66 changes: 56 additions & 10 deletions lib/code_quality_score/score_snapshot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
53 changes: 52 additions & 1 deletion spec/code_quality_score_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
106 changes: 106 additions & 0 deletions spec/format_comparison_spec.rb
Original file line number Diff line number Diff line change
@@ -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("<details>")
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("<details>")
expect(output).to include("<summary>")
expect(output).to include("</details>")
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("<details>")
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("<details>")
end
end
end
end