churn_vs_complexity 1.0.0 → 1.2.0

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: 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: []