churn_vs_complexity 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2efc73253aa94e1fe4de49723e2d7238857aa1a1f0b37fa6c39cb3e6cd1a133a
4
- data.tar.gz: '09be5e6af924ce5d32cc81e918f88e943f13e3c80e78bffaeb799c957d300c78'
3
+ metadata.gz: d748bfb7d21087a5fbcb360dbc05dcd46b5ec05bf2c2fcc542e2e25368084d78
4
+ data.tar.gz: 0d160c582aee2696823061dd7623cf17e9b537d798ae31502eb30b065a61391d
5
5
  SHA512:
6
- metadata.gz: 72fb9a5556e813e7999ca5134e91566e9c2b64e9050076c170578db470cb98c68bd53c440fb2b15b68db60abddab9af40026723deb543cf44b6a83acaeeed83e
7
- data.tar.gz: 17adf306ab68cf12ed73b733f2810df28761f726077cd6825ddb393fa89cf1fa264c66fd98eccd8c7bdfafb24af953e04f8b05a276615a2c0ae8b6c59be1c02b
6
+ metadata.gz: f521d39fddad8e8adf05ab0f21d8b8adcc2460f9a57c553b90c73eb60451aafed19ab11977768d494987ff72838c856dd04c3910781ec0da34f53d82d18d9d4e
7
+ data.tar.gz: 3ab17db013540128bd428edb54cf0615f47ce50caa19d1ad418261c20efde494bb36ea8e84408e0ca330f7721013a9e8958f3cd5a16565636186508e59bf7360
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
1
  ## [1.0.0] - 2024-06-07
2
2
 
3
- - Initial release
3
+ - Initial release
4
+
5
+ ## [1.1.0] - 2024-09-20
6
+
7
+ - Introduce `--summary` flag to output summary statistics for churn and complexity
8
+ - Introduce `--month`, `--quarter`, and `--year` short-hand flags to calculate churn for different time periods relative to the most recent commit
9
+
10
+ ## [1.2.0] - 2024-09-20
11
+
12
+ - Fix bug in CLI where new flags and `--since` would not be recognized
13
+ - Improve selection of observations included in the output
14
+ - Fixed calculation of churn that would never be zero
data/README.md CHANGED
@@ -31,13 +31,17 @@ In order to use the `--java` flag, you must first install PMD manually, and the
31
31
  Execute the `churn_vs_complexity` with the applicable arguments. Output in the requested format will be directed to stdout.
32
32
 
33
33
  ```
34
- churn_vs_complexity [options] folder
34
+ Usage: churn_vs_complexity [options] folder
35
35
  --java Check complexity of java classes
36
36
  --ruby Check complexity of ruby files
37
37
  --csv Format output as CSV
38
38
  --graph Format output as HTML page with Churn vs Complexity graph
39
+ --summary Output summary statistics (mean and median) for churn and complexity
39
40
  --excluded PATTERN Exclude file paths including this string. Can be used multiple times.
40
41
  --since YYYY-MM-DD Calculate churn after this date
42
+ -m, --month Calculate churn for the month leading up to the most recent commit
43
+ -q, --quarter Calculate churn for the quarter leading up to the most recent commit
44
+ -y, --year Calculate churn for the year leading up to the most recent commit
41
45
  -h, --help Display help
42
46
  ```
43
47
 
@@ -47,6 +51,7 @@ churn_vs_complexity [options] folder
47
51
 
48
52
  `churn_vs_complexity --java --graph --exclude generated-sources --exclude generated-test-sources --since 2023-01-01 my_java_project > ~/Desktop/java-demo.html`
49
53
 
54
+ `churn_vs_complexity --ruby --summary -m my_ruby_project >> ~/Desktop/monthly-report.txt`
50
55
 
51
56
 
52
57
  ## Development
@@ -7,20 +7,18 @@ module ChurnVsComplexity
7
7
  module GitCalculator
8
8
  class << self
9
9
  def calculate(folder:, file:, since:)
10
- with_follow = calculate_with_follow(folder, file, since)
11
- with_follow.zero? ? repo(folder).log.path(file).size : with_follow
10
+ git_dir = File.join(folder, '.git')
11
+ formatted_date = since.strftime('%Y-%m-%d')
12
+ cmd = %Q(git --git-dir #{git_dir} --work-tree #{folder} log --format="%H" --follow --since="#{formatted_date}" -- #{file} | wc -l)
13
+ `#{cmd}`.to_i
12
14
  end
13
15
 
14
- private
15
-
16
- def calculate_with_follow(folder, file, since)
17
- # Format the date as "YYYY-MM-DD"
18
- formatted_date = since.strftime('%Y-%m-%d')
19
- # git log --follow --oneline --since="YYYY-MM-DD" <file_path> | wc -l
20
- `git --git-dir #{File.join(folder,
21
- '.git',)} --work-tree #{folder} log --follow --oneline --since=#{formatted_date} #{file} | wc -l`.to_i
16
+ def date_of_latest_commit(folder:)
17
+ repo(folder).log.first.date
22
18
  end
23
19
 
20
+ private
21
+
24
22
  def repo(folder)
25
23
  repos[folder] ||= Git.open(folder)
26
24
  end
@@ -9,7 +9,6 @@ module ChurnVsComplexity
9
9
  def self.run!
10
10
  # Create an options hash to store parsed options
11
11
  options = { excluded: [] }
12
- since = nil
13
12
 
14
13
  # Initialize OptionParser
15
14
  OptionParser.new do |opts|
@@ -31,13 +30,34 @@ module ChurnVsComplexity
31
30
  options[:serializer] = :graph
32
31
  end
33
32
 
33
+ opts.on('--summary', 'Output summary statistics (mean and median) for churn and complexity') do
34
+ options[:serializer] = :summary
35
+ end
36
+
34
37
  opts.on('--excluded PATTERN',
35
38
  'Exclude file paths including this string. Can be used multiple times.',) do |value|
36
39
  options[:excluded] << value
37
40
  end
38
41
 
39
42
  opts.on('--since YYYY-MM-DD', 'Calculate churn after this date') do |value|
40
- since = value
43
+ options[:since] = value
44
+ end
45
+
46
+ opts.on('-m', '--month', 'Calculate churn for the month leading up to the most recent commit') do
47
+ options[:since] = :month
48
+ end
49
+
50
+ opts.on('-q', '--quarter', 'Calculate churn for the quarter leading up to the most recent commit') do
51
+ options[:since] = :quarter
52
+ end
53
+
54
+ opts.on('-y', '--year', 'Calculate churn for the year leading up to the most recent commit') do
55
+ options[:since] = :year
56
+ end
57
+
58
+ opts.on('--dry-run', 'Echo the chosen options from the CLI') do
59
+ puts options
60
+ exit
41
61
  end
42
62
 
43
63
  opts.on('-h', '--help', 'Display help') do
@@ -56,24 +76,11 @@ module ChurnVsComplexity
56
76
 
57
77
  raise Error, 'No options selected. Use --help for usage information.' if options.empty?
58
78
 
59
- begin
60
- if since.nil?
61
- since = Time.at(0).to_date
62
- options[:graph_title] = 'Churn vs Complexity'
63
- else
64
- date_string = since
65
- since = Date.strptime(since, '%Y-%m-%d')
66
- options[:graph_title] = "Churn vs Complexity since #{date_string}"
67
- end
68
- rescue StandardError
69
- raise Error, "Invalid date #{since}, please use correct format, YYYY-MM-DD"
70
- end
71
-
72
79
  config = Config.new(**options)
73
80
 
74
81
  config.validate!
75
82
 
76
- puts config.to_engine.check(folder:, since:)
83
+ puts config.to_engine.check(folder:)
77
84
  end
78
85
  end
79
86
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'etc'
4
+
3
5
  module ChurnVsComplexity
4
6
  class ConcurrentCalculator
5
7
  CONCURRENCY = Etc.nprocessors
@@ -11,7 +13,9 @@ module ChurnVsComplexity
11
13
  end
12
14
 
13
15
  def calculate(folder:, files:, since:)
14
- schedule_churn_calculation(folder, files, since)
16
+ latest_commit_date = @churn.date_of_latest_commit(folder:)
17
+ @git_period = GitDate.git_period(since, latest_commit_date)
18
+ schedule_churn_calculation(folder, files[:included], @git_period.effective_start_date)
15
19
  calculate_complexity(folder, files)
16
20
  await_results
17
21
  combine_results
@@ -22,9 +26,11 @@ module ChurnVsComplexity
22
26
  def calculate_complexity(folder, files)
23
27
  @complexity_results =
24
28
  if @complexity.folder_based?
25
- @complexity.calculate(folder:)
29
+ result = @complexity.calculate(folder:)
30
+ files[:explicitly_excluded].each { |file| result.delete(file) }
31
+ result
26
32
  else
27
- files.each_with_object({}) do |file, acc|
33
+ files[:included].each_with_object({}) do |file, acc|
28
34
  acc.merge!(@complexity.calculate(file:))
29
35
  end
30
36
  end
@@ -51,10 +57,14 @@ module ChurnVsComplexity
51
57
  end
52
58
 
53
59
  def combine_results
54
- @churn_results.to_h do |file, churn|
55
- complexity = @complexity_results[file] || -1
56
- [file, [churn, complexity]]
60
+ result = {}
61
+ result[:values_by_file] = @complexity_results.keys.each_with_object({}) do |file, acc|
62
+ # File with complexity score might not have churned in queried period,
63
+ # set zero churn on miss
64
+ acc[file] = [@churn_results[file] || 0, @complexity_results[file]]
57
65
  end
66
+ result[:git_period] = @git_period
67
+ result
58
68
  end
59
69
  end
60
70
  end
@@ -6,21 +6,23 @@ module ChurnVsComplexity
6
6
  language:,
7
7
  serializer:,
8
8
  excluded: [],
9
- graph_title: nil,
10
- complexity_validator: ComplexityValidator
9
+ since: nil,
10
+ complexity_validator: ComplexityValidator,
11
+ since_validator: SinceValidator
11
12
  )
12
13
  @language = language
13
14
  @serializer = serializer
14
15
  @excluded = excluded
16
+ @since = since
15
17
  @complexity_validator = complexity_validator
16
- @graph_title = graph_title
18
+ @since_validator = since_validator
17
19
  end
18
20
 
19
21
  def validate!
20
22
  raise Error, "Unsupported language: #{@language}" unless %i[java ruby].include?(@language)
21
- raise Error, "Unsupported serializer: #{@serializer}" unless %i[none csv graph].include?(@serializer)
22
- raise Error, 'Please provide a title for the graph' if @serializer == :graph && @graph_title.nil?
23
+ raise Error, "Unsupported serializer: #{@serializer}" unless %i[none csv graph summary].include?(@serializer)
23
24
 
25
+ @since_validator.validate!(@since)
24
26
  @complexity_validator.validate!(@language)
25
27
  end
26
28
 
@@ -32,6 +34,7 @@ module ChurnVsComplexity
32
34
  churn:,
33
35
  file_selector: FileSelector::Java.excluding(@excluded),
34
36
  serializer:,
37
+ since: @since,
35
38
  )
36
39
  when :ruby
37
40
  Engine.concurrent(
@@ -39,6 +42,7 @@ module ChurnVsComplexity
39
42
  churn:,
40
43
  file_selector: FileSelector::Ruby.excluding(@excluded),
41
44
  serializer:,
45
+ since: @since,
42
46
  )
43
47
  end
44
48
  end
@@ -54,7 +58,9 @@ module ChurnVsComplexity
54
58
  when :csv
55
59
  Serializer::CSV
56
60
  when :graph
57
- Serializer::Graph.new(title: @graph_title)
61
+ Serializer::Graph.new
62
+ when :summary
63
+ Serializer::Summary
58
64
  end
59
65
  end
60
66
 
@@ -66,5 +72,24 @@ module ChurnVsComplexity
66
72
  end
67
73
  end
68
74
  end
75
+
76
+ module SinceValidator
77
+ def self.validate!(since)
78
+ # since can be nil, a date string or a keyword (:month, :quarter, :year)
79
+ return if since.nil?
80
+
81
+ if since.is_a?(Symbol)
82
+ raise Error, "Invalid since value #{since}" unless %i[month quarter year].include?(since)
83
+ elsif since.is_a?(String)
84
+ begin
85
+ Date.strptime(since, '%Y-%m-%d')
86
+ rescue StandardError
87
+ raise Error, "Invalid date #{since}, please use correct format, YYYY-MM-DD"
88
+ end
89
+ else
90
+ raise Error, "Invalid since value #{since}"
91
+ end
92
+ end
93
+ end
69
94
  end
70
95
  end
@@ -2,20 +2,21 @@
2
2
 
3
3
  module ChurnVsComplexity
4
4
  class Engine
5
- def initialize(file_selector:, calculator:, serializer:)
5
+ def initialize(file_selector:, calculator:, serializer:, since:)
6
6
  @file_selector = file_selector
7
7
  @calculator = calculator
8
8
  @serializer = serializer
9
+ @since = since
9
10
  end
10
11
 
11
- def check(folder:, since:)
12
+ def check(folder:)
12
13
  files = @file_selector.select_files(folder)
13
- result = @calculator.calculate(folder:, files:, since:)
14
+ result = @calculator.calculate(folder:, files:, since: @since)
14
15
  @serializer.serialize(result)
15
16
  end
16
17
 
17
- def self.concurrent(complexity:, churn:, serializer: Serializer::None, file_selector: FileSelector::Any)
18
- Engine.new(file_selector:, serializer:, calculator: ConcurrentCalculator.new(complexity:, churn:))
18
+ def self.concurrent(since:, complexity:, churn:, serializer: Serializer::None, file_selector: FileSelector::Any)
19
+ Engine.new(since:, file_selector:, serializer:, calculator: ConcurrentCalculator.new(complexity:, churn:))
19
20
  end
20
21
  end
21
22
  end
@@ -4,7 +4,8 @@ module ChurnVsComplexity
4
4
  module FileSelector
5
5
  module Any
6
6
  def self.select_files(folder)
7
- Dir.glob("#{folder}/**/*").select { |f| File.file?(f) }
7
+ included = Dir.glob("#{folder}/**/*").select { |f| File.file?(f) }
8
+ { explicitly_excluded: [], included: }
8
9
  end
9
10
  end
10
11
 
@@ -15,9 +16,16 @@ module ChurnVsComplexity
15
16
  end
16
17
 
17
18
  def select_files(folder)
18
- Dir.glob("#{folder}/**/*").select do |f|
19
- !has_excluded_pattern?(f) && has_correct_extension?(f) && File.file?(f)
19
+ were_excluded = []
20
+ were_included = []
21
+ Dir.glob("#{folder}/**/*").each do |f|
22
+ if has_excluded_pattern?(f)
23
+ were_excluded << f
24
+ elsif has_correct_extension?(f) && File.file?(f)
25
+ were_included << f
26
+ end
20
27
  end
28
+ { explicitly_excluded: were_excluded, included: were_included }
21
29
  end
22
30
 
23
31
  private
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module GitDate
5
+ def self.git_period(cli_arg_since, latest_commit_date)
6
+ latest_commit_date = latest_commit_date.to_date
7
+ if cli_arg_since.nil?
8
+ NoStartGitPeriod.new(latest_commit_date)
9
+ elsif cli_arg_since.is_a?(Symbol)
10
+ AbsoluteGitPeriod.looking_back(relative_period: cli_arg_since, from: latest_commit_date)
11
+ elsif cli_arg_since.is_a?(String)
12
+ AbsoluteGitPeriod.between(cli_arg_since, latest_commit_date)
13
+ else
14
+ raise Error, "Unexpected since value #{cli_arg_since}"
15
+ end
16
+ end
17
+
18
+ class NoStartGitPeriod
19
+ attr_reader :end_date
20
+
21
+ def initialize(end_date)
22
+ @end_date = end_date
23
+ end
24
+
25
+ def effective_start_date = Time.at(0)
26
+
27
+ def requested_start_date = nil
28
+ end
29
+
30
+ class AbsoluteGitPeriod
31
+ attr_reader :end_date
32
+
33
+ def self.between(cli_arg_since, latest_commit_date)
34
+ start_date = Date.strptime(cli_arg_since, '%Y-%m-%d')
35
+ new(start_date, latest_commit_date)
36
+ end
37
+
38
+ def self.looking_back(relative_period:, from:)
39
+ shifter = case relative_period
40
+ when :month then 1
41
+ when :quarter then 3
42
+ when :year then 12
43
+ else raise Error, "Unexpected since value #{relative_period}"
44
+ end
45
+ start_date = from << shifter
46
+ new(start_date, from)
47
+ end
48
+
49
+ def initialize(start_date, end_date)
50
+ @start_date = start_date
51
+ @end_date = end_date
52
+ end
53
+
54
+ def effective_start_date = @start_date
55
+
56
+ def requested_start_date = @start_date
57
+ end
58
+ end
59
+ end
@@ -2,12 +2,55 @@
2
2
 
3
3
  module ChurnVsComplexity
4
4
  module Serializer
5
+ def self.title(result)
6
+ requested_start_date = result[:git_period].requested_start_date
7
+ end_date = result[:git_period].end_date
8
+ if requested_start_date.nil?
9
+ "Churn until #{end_date.strftime('%Y-%m-%d')} vs complexity"
10
+ else
11
+ "Churn between #{requested_start_date.strftime('%Y-%m-%d')} and #{end_date.strftime('%Y-%m-%d')} vs complexity"
12
+ end
13
+ end
14
+
5
15
  module None
6
- def self.serialize(values_by_file) = values_by_file
16
+ def self.serialize(result) = result
17
+ end
18
+
19
+ module Summary
20
+ def self.serialize(result)
21
+ values_by_file = result[:values_by_file]
22
+ churn_values = values_by_file.map { |_, values| values[0].to_f }
23
+ complexity_values = values_by_file.map { |_, values| values[1].to_f }
24
+
25
+ mean_churn = churn_values.sum / churn_values.size
26
+ median_churn = churn_values.sort[churn_values.size / 2]
27
+ mean_complexity = complexity_values.sum / complexity_values.size
28
+ median_complexity = complexity_values.sort[complexity_values.size / 2]
29
+
30
+ product = values_by_file.map { |_, values| values[0].to_f * values[1].to_f }
31
+ mean_product = product.sum / product.size
32
+ median_product = product.sort[product.size / 2]
33
+
34
+ <<~SUMMARY
35
+ #{Serializer.title(result)}
36
+
37
+ Number of observations: #{values_by_file.size}
38
+
39
+ Churn:
40
+ Mean #{mean_churn}, Median #{median_churn}
41
+
42
+ Complexity:
43
+ Mean #{mean_complexity}, Median #{median_complexity}
44
+
45
+ Product of churn and complexity:
46
+ Mean #{mean_product}, Median #{median_product}
47
+ SUMMARY
48
+ end
7
49
  end
8
50
 
9
51
  module CSV
10
- def self.serialize(values_by_file)
52
+ def self.serialize(result)
53
+ values_by_file = result[:values_by_file]
11
54
  values_by_file.map do |file, values|
12
55
  "#{file},#{values[0]},#{values[1]}\n"
13
56
  end.join
@@ -15,16 +58,16 @@ module ChurnVsComplexity
15
58
  end
16
59
 
17
60
  class Graph
18
- def initialize(title:, template: Graph.load_template_file)
61
+ def initialize(template: Graph.load_template_file)
19
62
  @template = template
20
- @title = title
21
63
  end
22
64
 
23
- def serialize(values_by_file)
24
- data = values_by_file.map do |file, values|
65
+ def serialize(result)
66
+ data = result[:values_by_file].map do |file, values|
25
67
  "{ file_path: '#{file}', churn: #{values[0]}, complexity: #{values[1]} }"
26
68
  end.join(",\n") + "\n"
27
- @template.gsub("// INSERT DATA\n", data).gsub('INSERT TITLE', @title)
69
+ title = Serializer.title(result)
70
+ @template.gsub("// INSERT DATA\n", data).gsub('INSERT TITLE', title)
28
71
  end
29
72
 
30
73
  def self.load_template_file
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChurnVsComplexity
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -12,6 +12,7 @@ require_relative 'churn_vs_complexity/churn'
12
12
  require_relative 'churn_vs_complexity/cli'
13
13
  require_relative 'churn_vs_complexity/config'
14
14
  require_relative 'churn_vs_complexity/serializer'
15
+ require_relative 'churn_vs_complexity/git_date'
15
16
 
16
17
  module ChurnVsComplexity
17
18
  class Error < StandardError; end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: churn_vs_complexity
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik T. Madsen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-07 00:00:00.000000000 Z
11
+ date: 2024-09-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: flog
@@ -68,6 +68,7 @@ files:
68
68
  - lib/churn_vs_complexity/config.rb
69
69
  - lib/churn_vs_complexity/engine.rb
70
70
  - lib/churn_vs_complexity/file_selector.rb
71
+ - lib/churn_vs_complexity/git_date.rb
71
72
  - lib/churn_vs_complexity/serializer.rb
72
73
  - lib/churn_vs_complexity/version.rb
73
74
  - tmp/pmd-support/ruleset.xml
@@ -89,7 +90,7 @@ licenses:
89
90
  - MIT
90
91
  metadata:
91
92
  source_code_uri: https://github.com/beatmadsen/churn_vs_complexity
92
- changelog_uri: https://github.com/beatmadsen/churn_vs_complexity/CHANGELOG.md
93
+ changelog_uri: https://github.com/beatmadsen/churn_vs_complexity/blob/main/CHANGELOG.md
93
94
  rubygems_mfa_required: 'true'
94
95
  post_install_message:
95
96
  rdoc_options: []