attractor 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00c69c27486cc5730d2524b7c25c69a3aecc58b6ba1bf516e34390e802deea2b
4
- data.tar.gz: c5056b6a4046b02d10702d33a4dd38c5b4de6c5c87f92e04e9c96c9eecf07fc5
3
+ metadata.gz: f92fa88c85c572827d8a18feba00fcbd156c70a4642bbb9544beb90d7242af8f
4
+ data.tar.gz: d146f48f098cfb0e12ea135ed05279e1accf7ad44be1dbdd7009f8a1441efe60
5
5
  SHA512:
6
- metadata.gz: 4cb863d6c362c6a9e98c2ca968bcd7fee4d565d1cc843c461ed0572a20d30c6e684e350138692bb632f285ce671fbe6a9ed3bffa85936426b3b80a1006f841ff
7
- data.tar.gz: e8202453d0bdee8e9f009b229c834656d5aa00014c47de97c4c258735be51974b2be76195070d23a8737ab2d6b82adc68bb97bb02715091eebb2d8915c3094e9
6
+ metadata.gz: 379f288757d88603c45617ffc626c14498bcefc6e14c6442b1b95adf85afef07bb8b064833b2ceed36ebd582f6b0fafe2799fe972593f56f8755a757c330cf90
7
+ data.tar.gz: c89dcdb9f88391c072bec3af06f2efa2c962b70e95e04bbcc7899ba5dc61473d0461c7d23d9ebf59479c60a99d5c3d2e5dac6394cc427a352c96f4881ac56a25
data/README.md CHANGED
@@ -238,6 +238,30 @@ attractor calc
238
238
  --config path/to/.attractor.yml
239
239
  ```
240
240
 
241
+ Calculate the complexity delta between two git refs:
242
+
243
+ ```sh
244
+ attractor diff
245
+ --base main
246
+ --head HEAD
247
+ --file_prefix|-p app/models
248
+ --type|-t rb|js
249
+ --start_ago|-s (e.g. 5y, 3m, 7w)
250
+ --minimum_churn|-c (minimum times a file must have changed to be processed)
251
+ --ignore|-i 'spec/*_spec.rb,db/schema.rb,tmp'
252
+ --files 'app/models/user.rb,app/models/post.rb'
253
+ --files -
254
+ --format|-f table|markdown|json
255
+ --config path/to/.attractor.yml
256
+ ```
257
+
258
+ The `markdown` format produces a PR/MR-ready summary with Stats, Trends, and To-dos sections, followed by the full per-file table wrapped in a collapsible `<details>` block. Pipe it into a GitHub or GitLab comment:
259
+
260
+ ```sh
261
+ attractor diff --base main --head HEAD --format markdown | gh pr comment 123 --body-file -
262
+ attractor diff --base main --head HEAD --format markdown | glab mr note 45 --message "$(cat)"
263
+ ```
264
+
241
265
  Generate a full report
242
266
 
243
267
  ```sh
@@ -34,7 +34,10 @@ module Attractor
34
34
  end
35
35
 
36
36
  target_paths = if @files && !@files.empty?
37
- @files.uniq.select { |file_path| file_path.end_with?(".#{@file_extension}") }
37
+ # `file_extension` is a pattern, not a literal: the JavaScript calculator
38
+ # passes "(js|jsx)". `end_with?` would drop every file it lists.
39
+ extension = /\.(?:#{@file_extension})\z/
40
+ @files.uniq.select { |file_path| file_path.match?(extension) }
38
41
  else
39
42
  changes_by_path.keys
40
43
  end
@@ -62,12 +62,13 @@ module Attractor
62
62
  def build_diff(base_values, head_values, files)
63
63
  base_by_path = flatten_values(base_values)
64
64
  head_by_path = flatten_values(head_values)
65
+ file_types = build_file_types(base_values, head_values)
65
66
 
66
67
  base_refactor_files = refactor_files(base_by_path.values)
67
68
  head_refactor_files = refactor_files(head_by_path.values)
68
69
 
69
70
  rows = files.uniq.map do |file_path|
70
- build_row(file_path, base_by_path[file_path], head_by_path[file_path], base_refactor_files, head_refactor_files)
71
+ build_row(file_path, base_by_path[file_path], head_by_path[file_path], base_refactor_files, head_refactor_files, file_types[file_path])
71
72
  end
72
73
 
73
74
  rows.sort_by! { |row| -row[:delta].abs }
@@ -86,8 +87,20 @@ module Attractor
86
87
  end
87
88
 
88
89
  def flatten_values(values_by_type)
89
- values_by_type.values.flatten.compact.each_with_object({}) do |value, hash|
90
- hash[value.file_path] = value
90
+ values_by_type.each_with_object({}) do |(_type, values), hash|
91
+ Array(values).compact.each do |value|
92
+ hash[value.file_path] = value
93
+ end
94
+ end
95
+ end
96
+
97
+ def build_file_types(base_values, head_values)
98
+ [base_values, head_values].each_with_object({}) do |values_by_type, hash|
99
+ values_by_type.each do |type, values|
100
+ Array(values).compact.each do |value|
101
+ hash[value.file_path] ||= type
102
+ end
103
+ end
91
104
  end
92
105
  end
93
106
 
@@ -95,7 +108,7 @@ module Attractor
95
108
  Suggester.new(values).suggest.map(&:file_path)
96
109
  end
97
110
 
98
- def build_row(file_path, base_value, head_value, base_refactor_files, head_refactor_files)
111
+ def build_row(file_path, base_value, head_value, base_refactor_files, head_refactor_files, type)
99
112
  complexity_base = base_value&.complexity
100
113
  complexity_head = head_value&.complexity
101
114
 
@@ -112,10 +125,12 @@ module Attractor
112
125
 
113
126
  {
114
127
  file_path: file_path,
128
+ type: type,
115
129
  complexity_base: complexity_base,
116
130
  complexity_head: complexity_head,
117
131
  delta: delta,
118
132
  churn: head_value&.churn,
133
+ score_base: base_value&.score,
119
134
  score_head: head_value&.score,
120
135
  refactor_base: base_refactor_files.include?(file_path),
121
136
  refactor_head: head_refactor_files.include?(file_path),
@@ -9,6 +9,7 @@ module Attractor
9
9
  def call(report)
10
10
  report.metadata.merge(
11
11
  :title => report.title,
12
+ :summary => report.sections.empty? ? nil : report.sections,
12
13
  report.rows_key => rows_for(report),
13
14
  :footer => report.footer
14
15
  ).compact.to_json
@@ -6,7 +6,7 @@ module Attractor
6
6
  class Markdown
7
7
  def call(report)
8
8
  lines = []
9
- lines << "# #{report.title}"
9
+ lines << "#{title_prefix(report)} #{report.title}"
10
10
  lines << ""
11
11
 
12
12
  report.metadata.each do |key, value|
@@ -14,11 +14,24 @@ module Attractor
14
14
  end
15
15
  lines << "" unless report.metadata.empty?
16
16
 
17
- lines << "| #{report.columns.join(" | ")} |"
18
- lines << "| #{report.columns.map { |column| "-" * column.to_s.length }.join(" | ")} |"
17
+ report.sections.each do |section|
18
+ lines << "### #{section[:title]}"
19
+ lines << ""
20
+ lines += render_table(section[:columns], section[:rows])
21
+ lines << ""
22
+ end
19
23
 
20
- report.rows.each do |row|
21
- lines << "| #{report.columns.map { |column| row[column] }.join(" | ")} |"
24
+ if report.collapsed_table
25
+ lines << "<details>"
26
+ lines << "<summary>All files</summary>"
27
+ lines << ""
28
+ end
29
+
30
+ lines += render_table(report.columns, report.rows)
31
+
32
+ if report.collapsed_table
33
+ lines << ""
34
+ lines << "</details>"
22
35
  end
23
36
 
24
37
  if report.footer
@@ -31,6 +44,35 @@ module Attractor
31
44
 
32
45
  private
33
46
 
47
+ def render_table(columns, rows)
48
+ return [] if columns.empty?
49
+
50
+ lines = []
51
+ lines << "| #{columns.join(" | ")} |"
52
+ lines << "| #{columns.map { |column| "-" * column.to_s.length }.join(" | ")} |"
53
+
54
+ rows.each do |row|
55
+ lines << "| #{columns.map { |column| format_cell(column, row) }.join(" | ")} |"
56
+ end
57
+
58
+ lines
59
+ end
60
+
61
+ def format_cell(column, row)
62
+ value = row[column]
63
+
64
+ if column == :delta
65
+ return "new" if row[:complexity_base].nil? && !row[:complexity_head].nil?
66
+ return "deleted" if row[:complexity_head].nil? && !row[:complexity_base].nil?
67
+ end
68
+
69
+ value
70
+ end
71
+
72
+ def title_prefix(report)
73
+ (report.sections.any? || report.collapsed_table) ? "##" : "#"
74
+ end
75
+
34
76
  def format_key(key)
35
77
  key.to_s.split("_").map(&:capitalize).join(" ")
36
78
  end
@@ -3,9 +3,9 @@
3
3
  module Attractor
4
4
  module Formatters
5
5
  class Report
6
- attr_reader :title, :metadata, :columns, :rows, :footer, :group_by, :rows_key
6
+ attr_reader :title, :metadata, :columns, :rows, :footer, :group_by, :rows_key, :sections, :collapsed_table
7
7
 
8
- def initialize(title:, columns:, rows:, metadata: {}, footer: nil, group_by: nil, rows_key: :rows)
8
+ def initialize(title:, columns:, rows:, metadata: {}, footer: nil, group_by: nil, rows_key: :rows, sections: [], collapsed_table: false)
9
9
  @title = title
10
10
  @metadata = metadata
11
11
  @columns = columns
@@ -13,6 +13,8 @@ module Attractor
13
13
  @footer = footer
14
14
  @group_by = group_by
15
15
  @rows_key = rows_key
16
+ @sections = sections
17
+ @collapsed_table = collapsed_table
16
18
  end
17
19
  end
18
20
  end
@@ -7,18 +7,73 @@ module Attractor
7
7
  module Targets
8
8
  class Diff
9
9
  def call(data)
10
+ files = data[:files]
11
+
10
12
  Report.new(
11
- title: "Complexity diff between #{data[:base_ref]} and #{data[:head_ref]}",
12
- metadata: {
13
- total_score_base: data[:total_score_base],
14
- total_score_head: data[:total_score_head],
15
- trend: data[:trend]
16
- },
13
+ title: "Attractor: #{data[:base_ref]}..#{data[:head_ref]}",
17
14
  columns: %i[file_path complexity_base complexity_head delta churn score_head refactor_base refactor_head],
18
- rows: data[:files],
19
- rows_key: :files
15
+ rows: files,
16
+ rows_key: :files,
17
+ sections: [
18
+ {title: "Stats", columns: %i[language score trend], rows: stats_rows(files)},
19
+ {title: "Trends", columns: ["", :most_improved, :largest_declines], rows: trends_rows(files)},
20
+ {title: "To-dos", columns: ["", :new_refactoring_candidates, :refactored], rows: todos_rows(files)}
21
+ ],
22
+ collapsed_table: true
20
23
  )
21
24
  end
25
+
26
+ private
27
+
28
+ def stats_rows(files)
29
+ rows_by_type(files).map do |type, rows|
30
+ total_base = rows.sum { |row| row[:score_base].to_f }
31
+ total_head = rows.sum { |row| row[:score_head].to_f }
32
+ trend = total_head - total_base
33
+ percent = total_base.zero? ? 0.0 : (trend / total_base * 100)
34
+
35
+ {
36
+ language: type,
37
+ score: "#{format("%.1f", total_head)} (from #{format("%.1f", total_base)})",
38
+ trend: trend.zero? ? "0.0%" : "#{trend_emoji(trend)} #{format("%+.1f", percent)}%"
39
+ }
40
+ end
41
+ end
42
+
43
+ def trends_rows(files)
44
+ rows_by_type(files).map do |type, rows|
45
+ improved = rows.select { |row| row[:delta].negative? }.first(5)
46
+ declined = rows.select { |row| row[:delta].positive? }.first(5)
47
+
48
+ {
49
+ "" => type,
50
+ :most_improved => improved.empty? ? "none" : improved.map { |row| "#{row[:file_path]} (#{format("%.1f", row[:delta])})" }.join(", "),
51
+ :largest_declines => declined.empty? ? "none" : declined.map { |row| "#{row[:file_path]} (#{format("%+.1f", row[:delta])})" }.join(", ")
52
+ }
53
+ end
54
+ end
55
+
56
+ def todos_rows(files)
57
+ rows_by_type(files).map do |type, rows|
58
+ flipped = rows.select { |row| row[:refactor_base] != row[:refactor_head] }
59
+ new_candidates = flipped.select { |row| row[:refactor_head] }
60
+ refactored = flipped.reject { |row| row[:refactor_head] }
61
+
62
+ {
63
+ "" => type,
64
+ :new_refactoring_candidates => new_candidates.empty? ? "none" : new_candidates.map { |row| row[:file_path] }.join(", "),
65
+ :refactored => refactored.empty? ? "none" : refactored.map { |row| row[:file_path] }.join(", ")
66
+ }
67
+ end
68
+ end
69
+
70
+ def rows_by_type(files)
71
+ files.reject { |row| row[:type].nil? }.group_by { |row| row[:type] }
72
+ end
73
+
74
+ def trend_emoji(trend)
75
+ trend.positive? ? "📈" : "📉"
76
+ end
22
77
  end
23
78
  end
24
79
  end
@@ -5,6 +5,7 @@ require "attractor/formatters/targets/console"
5
5
  require "attractor/formatters/formats/json"
6
6
  require "attractor/formatters/formats/table"
7
7
  require "attractor/formatters/formats/csv"
8
+ require "attractor/formatters/formats/markdown"
8
9
  require "attractor/formatters/format_strategy"
9
10
 
10
11
  module Attractor
@@ -1,3 +1,3 @@
1
1
  module Attractor
2
- VERSION = "2.7.0"
2
+ VERSION = "2.7.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: attractor
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.0
4
+ version: 2.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Rubisch
@@ -211,14 +211,14 @@ dependencies:
211
211
  requirements:
212
212
  - - "~>"
213
213
  - !ruby/object:Gem::Version
214
- version: 0.3.0
214
+ version: 0.4.0
215
215
  type: :development
216
216
  prerelease: false
217
217
  version_requirements: !ruby/object:Gem::Requirement
218
218
  requirements:
219
219
  - - "~>"
220
220
  - !ruby/object:Gem::Version
221
- version: 0.3.0
221
+ version: 0.4.0
222
222
  - !ruby/object:Gem::Dependency
223
223
  name: attractor-ruby
224
224
  requirement: !ruby/object:Gem::Requirement