churn_vs_complexity 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +16 -2
  4. data/TODO +11 -0
  5. data/bin/churn_vs_complexity +5 -0
  6. data/lib/churn_vs_complexity/churn.rb +1 -1
  7. data/lib/churn_vs_complexity/cli.rb +18 -6
  8. data/lib/churn_vs_complexity/complexity/eslint_calculator.rb +3 -5
  9. data/lib/churn_vs_complexity/complexity/flog_calculator.rb +2 -2
  10. data/lib/churn_vs_complexity/complexity/pmd_calculator.rb +5 -2
  11. data/lib/churn_vs_complexity/concurrent_calculator.rb +1 -1
  12. data/lib/churn_vs_complexity/config.rb +74 -18
  13. data/lib/churn_vs_complexity/git_date.rb +7 -0
  14. data/lib/churn_vs_complexity/serializer/csv.rb +14 -0
  15. data/lib/churn_vs_complexity/serializer/graph.rb +24 -0
  16. data/lib/churn_vs_complexity/serializer/pass_through.rb +21 -0
  17. data/lib/churn_vs_complexity/serializer/summary.rb +27 -0
  18. data/lib/churn_vs_complexity/serializer/summary_hash.rb +54 -0
  19. data/lib/churn_vs_complexity/serializer/timetravel/quality_calculator.rb +38 -0
  20. data/lib/churn_vs_complexity/serializer/timetravel/stats_calculator.rb +60 -0
  21. data/lib/churn_vs_complexity/serializer/timetravel.rb +103 -0
  22. data/lib/churn_vs_complexity/serializer.rb +7 -60
  23. data/lib/churn_vs_complexity/timetravel/traveller.rb +66 -0
  24. data/lib/churn_vs_complexity/timetravel/worktree.rb +56 -0
  25. data/lib/churn_vs_complexity/timetravel.rb +70 -0
  26. data/lib/churn_vs_complexity/version.rb +1 -1
  27. data/lib/churn_vs_complexity.rb +2 -0
  28. data/tmp/template/graph.html +1 -4
  29. data/tmp/template/timetravel_graph.html +100 -0
  30. data/tmp/timetravel/.keep +0 -0
  31. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de43228c387df735f0bd01eff8f53cfac5bf752474be0f4e901d29d5847781f5
4
- data.tar.gz: d78475fd751dff01f1aafb45e995cdeabd2d0561348e86de5cf5f951dcc7b6f0
3
+ metadata.gz: 7484ff3a1c015738808226a78087017f6b7aff5ce42d15879023f32df5648717
4
+ data.tar.gz: ad3bdeff5ba32e9d7f414b45173d8928b1c463313557202b96aaf5fdcf059109
5
5
  SHA512:
6
- metadata.gz: 4297b81854c54d4b60672f89156977299549cc012519b178ce206f3108e3e7dc65b9a9d0341b0f218945b9be580f9cc46fb8b5d049a3e5508f4fb4d026e23f5f
7
- data.tar.gz: 1338de4b39496a4af2f9485c14178112558399ab308b03d76ad18294e8a5cb6f87f55f5a0990ea0963ae63d3e6a5c49758c5aed072390e2610e2f0e5b52c2723
6
+ metadata.gz: a56e26296acfff22e755c414cab9ee36923e4ccd7181d67691f55feb8f99b5aa738f5d2b7645006f25c632efa346fb535d7fe04923443dedf607772ce1a21323
7
+ data.tar.gz: f406ee696facf7708e792b67ed8b463f1554365b3939deb4bde40c9fab88dfe891997f611727559a283103c5b7234f71d78d5fe108731628cb3fd8a782a73ce5
data/CHANGELOG.md CHANGED
@@ -17,3 +17,9 @@
17
17
 
18
18
  - Add support for javascript and typescript complexity calculation using eslint
19
19
  - Fixed behavior when --since or short-hand flags were not provided
20
+
21
+ ## [1.4.0] - 2024-10-10
22
+
23
+ - Add timetravel mode to visualise code quality over time
24
+ - Add alpha, beta, and gamma scores to summaries
25
+ - Fixed broken Ruby complexity calculation
data/README.md CHANGED
@@ -4,7 +4,7 @@ A tool to visualise code complexity in a project and help direct refactoring eff
4
4
 
5
5
  Inspired by [Michael Feathers' article "Getting Empirical about Refactoring"](https://www.agileconnection.com/article/getting-empirical-about-refactoring) and the gem [turbulence](https://rubygems.org/gems/turbulence) by Chad Fowler and others.
6
6
 
7
- This gem was built primarily to support analysis of Java and Ruby repositories, but it can easily be extended.
7
+ This gem currently supports analysis of Java, Ruby, JavaScript, and TypeScript repositories, but it can easily be extended.
8
8
 
9
9
  ## Installation
10
10
 
@@ -40,14 +40,27 @@ Usage: churn_vs_complexity [options] folder
40
40
  --graph Format output as HTML page with Churn vs Complexity graph
41
41
  --summary Output summary statistics (mean and median) for churn and complexity
42
42
  --excluded PATTERN Exclude file paths including this string. Can be used multiple times.
43
- --since YYYY-MM-DD Calculate churn after this date
43
+ --since YYYY-MM-DD Normal mode: Calculate churn after this date. Timetravel mode: calculate summaries from this date
44
44
  -m, --month Calculate churn for the month leading up to the most recent commit
45
45
  -q, --quarter Calculate churn for the quarter leading up to the most recent commit
46
46
  -y, --year Calculate churn for the year leading up to the most recent commit
47
+ --timetravel N Calculate summary for all commits at intervals of N days throughout project history or from the date specified with --since
47
48
  --dry-run Echo the chosen options from the CLI
48
49
  -h, --help Display help
50
+
51
+
49
52
  ```
50
53
 
54
+ Note that when using the `--timetravel` mode, the semantics of some flags are subtly different from normal mode:
55
+
56
+ * `--since YYYY-MM-DD`: Calculate summaries from this date
57
+ * `--month`, `--quarter`, `--year`: Calculate churn for the period leading up to each commit being summarised
58
+
59
+ Timetravel analysis can take many minutes for old and large repositories.
60
+
61
+ Summaries in normal mode include a gamma score, which is an unnormalised harmonic mean of churn and complexity. This allows for comparison of summaries across different projects with the same language, or over time for a single project.
62
+
63
+ Summary points in timetravel mode instead include an alpha score, which is the same harmonic mean of churn and complexity, where churn and complexity values are normalised to a 0-1 range to avoid either churn or complexity dominating the score. The summary points also include a beta score, which is the geometric mean of the normalised churn and complexity values.
51
64
  ## Examples
52
65
 
53
66
  `churn_vs_complexity --ruby --csv my_ruby_project > ~/Desktop/ruby-demo.csv`
@@ -56,6 +69,7 @@ Usage: churn_vs_complexity [options] folder
56
69
 
57
70
  `churn_vs_complexity --ruby --summary -m my_ruby_project >> ~/Desktop/monthly-report.txt`
58
71
 
72
+ `churn_vs_complexity --java -m --since 2019-03-01 --timetravel 30 --graph my_java_project > ~/Desktop/timetravel-after-1st-march-2019.html`
59
73
 
60
74
  ## Development
61
75
 
data/TODO ADDED
@@ -0,0 +1,11 @@
1
+ TODO:
2
+
3
+ - Move Timetravel calculations away from serializer
4
+
5
+ - Database, where we can prepopulate the state of every file and every commit
6
+ - Populate incrementally from each commit in log.
7
+ - Only need to care about deltas between commits, and copy everything else from previous commit.
8
+ - processed_commit table with sha and version of processing logic
9
+ - Unit tests for simpler new classes
10
+ - Integration test for Timetravel
11
+ - Candlebars on mean dots in graph
@@ -6,6 +6,11 @@ require 'churn_vs_complexity'
6
6
 
7
7
  begin
8
8
  ChurnVsComplexity::CLI.run!
9
+ rescue ChurnVsComplexity::ValidationError => e
10
+ warn e.message
11
+ exit 1
9
12
  rescue StandardError => e
13
+ warn e.backtrace
10
14
  warn e.message
15
+ exit 2
11
16
  end
@@ -10,7 +10,7 @@ module ChurnVsComplexity
10
10
  git_dir = File.join(folder, '.git')
11
11
  earliest_date = [date_of_first_commit(folder:), since].max
12
12
  formatted_date = earliest_date.strftime('%Y-%m-%d')
13
- cmd = %Q(git --git-dir #{git_dir} --work-tree #{folder} log --format="%H" --follow --since="#{formatted_date}" -- #{file} | wc -l)
13
+ cmd = %(git --git-dir #{git_dir} --work-tree #{folder} log --format="%H" --follow --since="#{formatted_date}" -- #{file} | wc -l)
14
14
  `#{cmd}`.to_i
15
15
  end
16
16
 
@@ -22,7 +22,8 @@ module ChurnVsComplexity
22
22
  options[:language] = :ruby
23
23
  end
24
24
 
25
- opts.on('--js', '--ts', '--javascript', '--typescript', 'Check complexity of javascript and typescript files') do
25
+ opts.on('--js', '--ts', '--javascript', '--typescript',
26
+ 'Check complexity of javascript and typescript files',) do
26
27
  options[:language] = :javascript
27
28
  end
28
29
 
@@ -43,20 +44,27 @@ module ChurnVsComplexity
43
44
  options[:excluded] << value
44
45
  end
45
46
 
46
- opts.on('--since YYYY-MM-DD', 'Calculate churn after this date') do |value|
47
+ opts.on('--since YYYY-MM-DD',
48
+ 'Normal mode: Calculate churn after this date. Timetravel mode: calculate summaries from this date',) do |value|
47
49
  options[:since] = value
48
50
  end
49
51
 
50
52
  opts.on('-m', '--month', 'Calculate churn for the month leading up to the most recent commit') do
51
- options[:since] = :month
53
+ options[:relative_period] = :month
52
54
  end
53
55
 
54
56
  opts.on('-q', '--quarter', 'Calculate churn for the quarter leading up to the most recent commit') do
55
- options[:since] = :quarter
57
+ options[:relative_period] = :quarter
56
58
  end
57
59
 
58
60
  opts.on('-y', '--year', 'Calculate churn for the year leading up to the most recent commit') do
59
- options[:since] = :year
61
+ options[:relative_period] = :year
62
+ end
63
+
64
+ opts.on('--timetravel N',
65
+ 'Calculate summary for all commits at intervals of N days throughout project history or from the date specified with --since',) do |value|
66
+ options[:mode] = :timetravel
67
+ options[:jump_days] = value.to_i
60
68
  end
61
69
 
62
70
  opts.on('--dry-run', 'Echo the chosen options from the CLI') do
@@ -84,7 +92,11 @@ module ChurnVsComplexity
84
92
 
85
93
  config.validate!
86
94
 
87
- puts config.to_engine.check(folder:)
95
+ if options[:mode] == :timetravel
96
+ puts config.timetravel.go(folder:)
97
+ else
98
+ puts config.to_engine.check(folder:)
99
+ end
88
100
  end
89
101
  end
90
102
  end
@@ -5,20 +5,18 @@ module ChurnVsComplexity
5
5
  module ESLintCalculator
6
6
  class << self
7
7
  def folder_based? = false
8
-
8
+
9
9
  def calculate(files:)
10
10
  dir_path = File.join(gem_root, 'tmp', 'eslint-support')
11
11
  script_path = File.join(dir_path, 'complexity-calculator.js')
12
12
  install_command = "npm install --prefix '#{dir_path}'"
13
13
  `#{install_command}`
14
14
 
15
-
16
15
  command = "node #{script_path} '#{files.to_json}'"
17
16
  complexity = `#{command}`
18
17
 
19
- if complexity.empty?
20
- raise Error, "Failed to calculate complexity"
21
- end
18
+ raise Error, 'Failed to calculate complexity' if complexity.empty?
19
+
22
20
  all = JSON.parse(complexity)
23
21
  all.to_h do |abc|
24
22
  [abc['file'], abc['complexity']]
@@ -9,9 +9,9 @@ module ChurnVsComplexity
9
9
  def folder_based? = false
10
10
 
11
11
  def calculate(files:)
12
- flog = Flog.new
13
- # TODO: Run this concurrently
12
+ #  TODO: Run this concurrently
14
13
  files.to_h do |file|
14
+ flog = Flog.new
15
15
  flog.flog(file)
16
16
  [file, flog.total_score]
17
17
  end
@@ -9,7 +9,10 @@ module ChurnVsComplexity
9
9
  def folder_based? = true
10
10
 
11
11
  def calculate(folder:)
12
- output = `pmd check -d #{folder} -R #{resolve_ruleset_path} -f json -t #{CONCURRENCY} --cache #{resolve_cache_path}`
12
+ cache_path = resolve_cache_path
13
+ output = `pmd check -d #{folder} -R #{resolve_ruleset_path} -f json -t #{CONCURRENCY} --cache #{cache_path}`
14
+ File.delete(cache_path)
15
+
13
16
  Parser.new.parse(output)
14
17
  end
15
18
 
@@ -29,7 +32,7 @@ module ChurnVsComplexity
29
32
  end
30
33
 
31
34
  def resolve_cache_path
32
- File.join(gem_root, 'tmp', 'pmd-support', 'pmd-cache')
35
+ File.join(gem_root, 'tmp', 'pmd-support', "pmd-cache-#{Process.pid}")
33
36
  end
34
37
 
35
38
  def gem_root
@@ -57,7 +57,7 @@ module ChurnVsComplexity
57
57
  def combine_results
58
58
  result = {}
59
59
  result[:values_by_file] = @complexity_results.keys.each_with_object({}) do |file, acc|
60
- # File with complexity score might not have churned in queried period,
60
+ # File with complexity score might not have churned in queried period,
61
61
  # set zero churn on miss
62
62
  acc[file] = [@churn_results[file] || 0, @complexity_results[file]]
63
63
  end
@@ -7,25 +7,42 @@ module ChurnVsComplexity
7
7
  serializer:,
8
8
  excluded: [],
9
9
  since: nil,
10
+ relative_period: nil,
10
11
  complexity_validator: ComplexityValidator,
11
- since_validator: SinceValidator
12
+ since_validator: SinceValidator,
13
+ **options
12
14
  )
13
15
  @language = language
14
16
  @serializer = serializer
15
17
  @excluded = excluded
16
18
  @since = since
19
+ @relative_period = relative_period
17
20
  @complexity_validator = complexity_validator
18
21
  @since_validator = since_validator
22
+ @options = options
19
23
  end
20
24
 
21
25
  def validate!
22
- raise Error, "Unsupported language: #{@language}" unless %i[java ruby javascript].include?(@language)
23
- raise Error, "Unsupported serializer: #{@serializer}" unless %i[none csv graph summary].include?(@serializer)
26
+ raise ValidationError, "Unsupported language: #{@language}" unless %i[java ruby javascript].include?(@language)
24
27
 
25
- @since_validator.validate!(@since)
28
+ SerializerValidator.validate!(serializer: @serializer, mode: @options[:mode])
29
+
30
+ @since_validator.validate!(since: @since, relative_period: @relative_period, mode: @options[:mode])
31
+ RelativePeriodValidator.validate!(relative_period: @relative_period, mode: @options[:mode])
26
32
  @complexity_validator.validate!(@language)
27
33
  end
28
34
 
35
+ def timetravel
36
+ engine = timetravel_engine_config.to_engine
37
+ Timetravel::Traveller.new(
38
+ since: @since,
39
+ relative_period: @relative_period,
40
+ engine:,
41
+ jump_days: @options[:jump_days],
42
+ serializer: @serializer,
43
+ )
44
+ end
45
+
29
46
  def to_engine
30
47
  case @language
31
48
  when :java
@@ -34,7 +51,7 @@ module ChurnVsComplexity
34
51
  churn:,
35
52
  file_selector: FileSelector::Java.excluding(@excluded),
36
53
  serializer:,
37
- since: @since,
54
+ since: @since || @relative_period,
38
55
  )
39
56
  when :ruby
40
57
  Engine.concurrent(
@@ -42,7 +59,7 @@ module ChurnVsComplexity
42
59
  churn:,
43
60
  file_selector: FileSelector::Ruby.excluding(@excluded),
44
61
  serializer:,
45
- since: @since,
62
+ since: @since || @relative_period,
46
63
  )
47
64
  when :javascript
48
65
  Engine.concurrent(
@@ -50,13 +67,26 @@ module ChurnVsComplexity
50
67
  churn:,
51
68
  file_selector: FileSelector::JavaScript.excluding(@excluded),
52
69
  serializer:,
53
- since: @since,
70
+ since: @since || @relative_period,
54
71
  )
55
72
  end
56
73
  end
57
74
 
58
75
  private
59
76
 
77
+ def timetravel_engine_config
78
+ Config.new(
79
+ language: @language,
80
+ serializer: :pass_through,
81
+ excluded: @excluded,
82
+ since: nil, # since has a different meaning in timetravel mode
83
+ relative_period: @relative_period,
84
+ complexity_validator: @complexity_validator,
85
+ since_validator: @since_validator,
86
+ **@options,
87
+ )
88
+ end
89
+
60
90
  def churn = Churn::GitCalculator
61
91
 
62
92
  def serializer
@@ -69,6 +99,8 @@ module ChurnVsComplexity
69
99
  Serializer::Graph.new
70
100
  when :summary
71
101
  Serializer::Summary
102
+ when :pass_through
103
+ Serializer::PassThrough
72
104
  end
73
105
  end
74
106
 
@@ -81,21 +113,45 @@ module ChurnVsComplexity
81
113
  end
82
114
  end
83
115
 
116
+ # TODO: unit test
117
+ module SerializerValidator
118
+ def self.validate!(serializer:, mode:)
119
+ raise ValidationError, "Unsupported serializer: #{serializer}" \
120
+ unless %i[none csv graph summary].include?(serializer)
121
+ raise ValidationError, 'Does not support --summary in --timetravel mode' \
122
+ if serializer == :summary && mode == :timetravel
123
+ end
124
+ end
125
+
126
+ # TODO: unit test
127
+ module RelativePeriodValidator
128
+ def self.validate!(relative_period:, mode:)
129
+ if mode == :timetravel && relative_period.nil?
130
+ raise ValidationError,
131
+ 'Relative period is required in timetravel mode'
132
+ end
133
+ return if relative_period.nil? || %i[month quarter year].include?(relative_period)
134
+
135
+ raise ValidationError, "Invalid relative period #{relative_period}"
136
+ end
137
+ end
138
+
84
139
  module SinceValidator
85
- def self.validate!(since)
140
+ def self.validate!(since:, relative_period:, mode:)
86
141
  # since can be nil, a date string or a keyword (:month, :quarter, :year)
87
142
  return if since.nil?
88
143
 
89
- if since.is_a?(Symbol)
90
- raise Error, "Invalid since value #{since}" unless %i[month quarter year].include?(since)
91
- elsif since.is_a?(String)
92
- begin
93
- Date.strptime(since, '%Y-%m-%d')
94
- rescue StandardError
95
- raise Error, "Invalid date #{since}, please use correct format, YYYY-MM-DD"
96
- end
97
- else
98
- raise Error, "Invalid since value #{since}"
144
+ unless mode == :timetravel || since.nil? || relative_period.nil?
145
+ raise ValidationError,
146
+ '--since and relative period (--month, --quarter, --year) can only be used together in --timetravel mode'
147
+ end
148
+
149
+ raise ValidationError, "Invalid since value #{since}" unless since.is_a?(String)
150
+
151
+ begin
152
+ Date.strptime(since, '%Y-%m-%d')
153
+ rescue Date::Error
154
+ raise ValidationError, "Invalid date #{since}, please use correct format, YYYY-MM-DD"
99
155
  end
100
156
  end
101
157
  end
@@ -2,6 +2,13 @@
2
2
 
3
3
  module ChurnVsComplexity
4
4
  module GitDate
5
+ def self.select_dates_with_at_least_interval(dates, interval)
6
+ ds = dates.sort
7
+ ds.each_with_object([]) do |date, acc|
8
+ acc << date if acc.empty? || date - acc.last >= interval
9
+ end
10
+ end
11
+
5
12
  def self.git_period(cli_arg_since, latest_commit_date)
6
13
  latest_commit_date = latest_commit_date.to_date
7
14
  if cli_arg_since.nil?
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Serializer
5
+ module CSV
6
+ def self.serialize(result)
7
+ values_by_file = result[:values_by_file]
8
+ values_by_file.map do |file, values|
9
+ "#{file},#{values[0]},#{values[1]}\n"
10
+ end.join
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Serializer
5
+ class Graph
6
+ def initialize(template: Graph.load_template_file)
7
+ @template = template
8
+ end
9
+
10
+ def serialize(result)
11
+ data = result[:values_by_file].map do |file, values|
12
+ "{ file_path: '#{file}', churn: #{values[0]}, complexity: #{values[1]} }"
13
+ end.join(",\n") + "\n"
14
+ title = Serializer.title(result)
15
+ @template.gsub("// INSERT DATA\n", data).gsub('INSERT TITLE', title)
16
+ end
17
+
18
+ def self.load_template_file
19
+ file_path = File.expand_path('../../../tmp/template/graph.html', __dir__)
20
+ File.read(file_path)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Serializer
5
+ module PassThrough
6
+ class << self
7
+ def serialize(result)
8
+ values_by_file = result[:values_by_file]
9
+ end_date = result[:git_period].end_date
10
+ values = values_by_file.map do |_, values|
11
+ [values[0].to_f, values[1].to_f]
12
+ end
13
+ {
14
+ end_date:,
15
+ values:,
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Serializer
5
+ module Summary
6
+ def self.serialize(result)
7
+ values_by_file = result[:values_by_file]
8
+ summary = SummaryHash.serialize(result)
9
+
10
+ <<~SUMMARY
11
+ #{Serializer.title(result)}
12
+
13
+ Number of observations: #{values_by_file.size}
14
+
15
+ Churn:
16
+ Mean #{summary[:mean_churn]}, Median #{summary[:median_churn]}
17
+
18
+ Complexity:
19
+ Mean #{summary[:mean_complexity]}, Median #{summary[:median_complexity]}
20
+
21
+ Gamma score:
22
+ Mean #{summary[:mean_gamma_score]}, Median #{summary[:median_gamma_score]}
23
+ SUMMARY
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Serializer
5
+ module SummaryHash
6
+ class << self
7
+ def serialize(result)
8
+ values_by_file = result[:values_by_file]
9
+ churn_values = values_by_file.map { |_, values| values[0].to_f }
10
+ complexity_values = values_by_file.map { |_, values| values[1].to_f }
11
+
12
+ mean_churn = churn_values.sum / churn_values.size
13
+ median_churn = churn_values.sort[churn_values.size / 2]
14
+ mean_complexity = complexity_values.sum / complexity_values.size
15
+ median_complexity = complexity_values.sort[complexity_values.size / 2]
16
+
17
+ max_churn = churn_values.max
18
+ min_churn = churn_values.min
19
+ max_complexity = complexity_values.max
20
+ min_complexity = complexity_values.min
21
+
22
+ epsilon = 0.0001
23
+ gamma_score = values_by_file.map do |_, values|
24
+ # unnormalised harmonic mean of churn and complexity,
25
+ # since the summary needs to be comparable over time
26
+ churn = values[0].to_f + epsilon
27
+ complexity = values[1].to_f + epsilon
28
+
29
+ (2 * churn * complexity) / (churn + complexity)
30
+ end
31
+
32
+ mean_gamma_score = gamma_score.sum / gamma_score.size
33
+ median_gamma_score = gamma_score.sort[gamma_score.size / 2]
34
+
35
+ end_date = result[:git_period].end_date
36
+
37
+ {
38
+ mean_churn:,
39
+ median_churn:,
40
+ max_churn:,
41
+ min_churn:,
42
+ mean_complexity:,
43
+ median_complexity:,
44
+ max_complexity:,
45
+ min_complexity:,
46
+ mean_gamma_score:,
47
+ median_gamma_score:,
48
+ end_date:,
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Serializer
5
+ module Timetravel
6
+ EPSILON = 0.0001
7
+
8
+ class QualityCalculator
9
+ def initialize(min_churn:, max_churn:, min_complexity:, max_complexity:)
10
+ @min_churn = min_churn
11
+ @max_churn = max_churn
12
+ @min_complexity = min_complexity
13
+ @max_complexity = max_complexity
14
+ end
15
+
16
+ def alpha_score(raw_churn, raw_complexity)
17
+ # harmonic mean of normalised churn and complexity
18
+ churn = normalise(raw_churn, @min_churn, @max_churn, EPSILON)
19
+ complexity = normalise(raw_complexity, @min_complexity, @max_complexity, EPSILON)
20
+
21
+ (2 * churn * complexity) / (churn + complexity)
22
+ end
23
+
24
+ def beta_score(raw_churn, raw_complexity)
25
+ # geometric mean of normalised churn and complexity
26
+ churn = normalise(raw_churn, @min_churn, @max_churn, EPSILON)
27
+ complexity = normalise(raw_complexity, @min_complexity, @max_complexity, EPSILON)
28
+
29
+ Math.sqrt(churn * complexity)
30
+ end
31
+
32
+ private
33
+
34
+ def normalise(score, min, max, epsilon) = (score + epsilon - min) / (epsilon + max - min)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Serializer
5
+ module Timetravel
6
+ class StatsCalculator
7
+ # ['some_sha', { 'end_date' => '2024-01-01', 'values' => [[1, 2], [3, 4]] }]
8
+ def summaries(result)
9
+ observations = result.sort_by do |_sha, summary|
10
+ summary['end_date']
11
+ end.map { |entry| entry[1] }
12
+
13
+ quality_calculator = QualityCalculator.new(**extrema(observations))
14
+ observations.map do |o|
15
+ end_date = o['end_date']
16
+ scores = o['values'].map do |(churn, complexity)|
17
+ alpha = quality_calculator.alpha_score(churn, complexity)
18
+ beta = quality_calculator.beta_score(churn, complexity)
19
+ [churn, complexity, alpha, beta]
20
+ end
21
+ {
22
+ 'end_date' => end_date,
23
+ 'mean_churn' => mean(scores.map { |s| s[0] }),
24
+ 'median_churn' => median(scores.map { |s| s[0] }),
25
+ 'mean_complexity' => mean(scores.map { |s| s[1] }),
26
+ 'median_complexity' => median(scores.map { |s| s[1] }),
27
+ 'mean_alpha_score' => mean(scores.map { |s| s[2] }),
28
+ 'median_alpha_score' => median(scores.map { |s| s[2] }),
29
+ 'mean_beta_score' => mean(scores.map { |s| s[3] }),
30
+ 'median_beta_score' => median(scores.map { |s| s[3] }),
31
+ }
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def extrema(observations)
38
+ churn_series = observations.flat_map { |o| o['values'] }.map { |(churn, _)| churn }
39
+ max_churn = churn_series.max
40
+ min_churn = churn_series.min
41
+
42
+ complexity_series = observations.flat_map { |o| o['values'] }.map { |(_, complexity)| complexity }
43
+ max_complexity = complexity_series.max
44
+ min_complexity = complexity_series.min
45
+
46
+ { max_churn:, min_churn:, max_complexity:, min_complexity: }
47
+ end
48
+
49
+ def mean(series)
50
+ series.sum / series.size
51
+ end
52
+
53
+ def median(series)
54
+ sorted = series.sort
55
+ sorted[sorted.size / 2]
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'timetravel/quality_calculator'
4
+ require_relative 'timetravel/stats_calculator'
5
+
6
+ module ChurnVsComplexity
7
+ module Serializer
8
+ module Timetravel
9
+ def self.summaries(result)
10
+ StatsCalculator.new.summaries(result)
11
+ end
12
+
13
+ def self.resolve(serializer:, git_period:, relative_period:, jump_days:)
14
+ case serializer
15
+ when :csv
16
+ CSV
17
+ when :graph
18
+ Graph.new(git_period:, relative_period:, jump_days:)
19
+ end
20
+ end
21
+
22
+ module CSV
23
+ def self.serialize(result)
24
+ summaries = Timetravel.summaries(result)
25
+
26
+ # 2. Add title row to front of summaries
27
+ summaries.unshift(
28
+ {
29
+ 'end_date' => 'Date',
30
+ 'mean_churn' => 'Mean Churn',
31
+ 'median_churn' => 'Median Churn',
32
+ 'mean_complexity' => 'Mean Complexity',
33
+ 'median_complexity' => 'Median Complexity',
34
+ 'mean_alpha_score' => 'Mean Alpha Score',
35
+ 'median_alpha_score' => 'Median Alpha Score',
36
+ 'mean_beta_score' => 'Mean Beta Score',
37
+ 'median_beta_score' => 'Median Beta Score',
38
+ },
39
+ )
40
+
41
+ # 3. convert to csv
42
+ summaries.map do |summary|
43
+ "#{summary['end_date']},#{summary['mean_churn']},#{summary['median_churn']},#{summary['mean_complexity']},#{summary['median_complexity']},#{summary['mean_alpha_score']},#{summary['median_alpha_score']},#{summary['mean_beta_score']},#{summary['median_beta_score']}"
44
+ end.join("\n")
45
+ end
46
+ end
47
+
48
+ # TODO: unit test
49
+ class Graph
50
+ def initialize(git_period:, relative_period:, jump_days:, template: Graph.load_template_file)
51
+ @template = template
52
+ @git_period = git_period
53
+ @relative_period = relative_period
54
+ @jump_days = jump_days
55
+ end
56
+
57
+ def self.load_template_file
58
+ file_path = File.expand_path('../../../tmp/template/timetravel_graph.html', __dir__)
59
+ File.read(file_path)
60
+ end
61
+
62
+ def serialize(result)
63
+ summaries = Timetravel.summaries(result)
64
+
65
+ data = summaries.map do |summary|
66
+ JSON.dump(summary)
67
+ end.join(",\n") + "\n"
68
+
69
+ @template.gsub("// INSERT DATA\n", data)
70
+ .gsub('INSERT TITLE', title)
71
+ .gsub('INSERT CHURN MODIFIER', churn_modifier)
72
+ end
73
+
74
+ private
75
+
76
+ def title
77
+ "#{churn_modifier}churn and complexity since #{since} evaluated every #{@jump_days} days"
78
+ end
79
+
80
+ def since
81
+ if @git_period.requested_start_date.nil?
82
+ 'start of project'
83
+ else
84
+ @git_period.effective_start_date.strftime('%Y-%m-%d').to_s
85
+ end
86
+ end
87
+
88
+ def churn_modifier
89
+ case @relative_period
90
+ when :month
91
+ 'Monthly '
92
+ when :quarter
93
+ 'Quarterly '
94
+ when :year
95
+ 'Yearly '
96
+ else
97
+ ''
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'serializer/timetravel'
4
+ require_relative 'serializer/summary_hash'
5
+ require_relative 'serializer/summary'
6
+ require_relative 'serializer/csv'
7
+ require_relative 'serializer/graph'
8
+ require_relative 'serializer/pass_through'
9
+
3
10
  module ChurnVsComplexity
4
11
  module Serializer
5
12
  def self.title(result)
@@ -15,65 +22,5 @@ module ChurnVsComplexity
15
22
  module None
16
23
  def self.serialize(result) = result
17
24
  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
49
- end
50
-
51
- module CSV
52
- def self.serialize(result)
53
- values_by_file = result[:values_by_file]
54
- values_by_file.map do |file, values|
55
- "#{file},#{values[0]},#{values[1]}\n"
56
- end.join
57
- end
58
- end
59
-
60
- class Graph
61
- def initialize(template: Graph.load_template_file)
62
- @template = template
63
- end
64
-
65
- def serialize(result)
66
- data = result[:values_by_file].map do |file, values|
67
- "{ file_path: '#{file}', churn: #{values[0]}, complexity: #{values[1]} }"
68
- end.join(",\n") + "\n"
69
- title = Serializer.title(result)
70
- @template.gsub("// INSERT DATA\n", data).gsub('INSERT TITLE', title)
71
- end
72
-
73
- def self.load_template_file
74
- file_path = File.expand_path('../../tmp/template/graph.html', __dir__)
75
- File.read(file_path)
76
- end
77
- end
78
25
  end
79
26
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ # TODO: unit test and integration test
5
+ module Timetravel
6
+ class Traveller
7
+ def initialize(since:, relative_period:, engine:, serializer:, jump_days:, factory: Factory)
8
+ @relative_period = relative_period
9
+ @engine = engine
10
+ @jump_days = jump_days
11
+ @serializer = serializer
12
+ @git_period = GitDate.git_period(since, Time.now.to_date)
13
+ @factory = factory
14
+ end
15
+
16
+ def go(folder:)
17
+ git_strategy = @factory.git_strategy(folder:)
18
+ commits = git_strategy.resolve_commits_with_interval(git_period: @git_period, jump_days: @jump_days)
19
+
20
+ chunked = make_chunks(commits)
21
+ work_on(chunked:, folder:, git_strategy:)
22
+ combined = chunked.map { |c_and_p| read_result(c_and_p[:pipe]) }.reduce({}, :merge)
23
+
24
+ serializer.serialize(combined)
25
+ end
26
+
27
+ private
28
+
29
+ def work_on(chunked:, folder:, git_strategy:)
30
+ chunked.map.with_index do |c_and_p, i|
31
+ worktree = @factory.worktree(root_folder: folder, git_strategy:, number: i)
32
+ worktree.prepare
33
+ schedule_work(worktree:, **c_and_p)
34
+ end
35
+ end
36
+
37
+ def make_chunks(commits)
38
+ chunk_size = (commits.size / 3.0).ceil
39
+ commits.each_slice(chunk_size).map do |chunk|
40
+ { chunk:, pipe: @factory.pipe }
41
+ end.to_a
42
+ end
43
+
44
+ def read_result(pipe)
45
+ part = begin
46
+ JSON.parse(pipe[0].gets)
47
+ rescue StandardError => e
48
+ warn "Error parsing JSON: #{e}"
49
+ {}
50
+ end
51
+ pipe.each(&:close)
52
+ part
53
+ end
54
+
55
+ def schedule_work(chunk:, worktree:, pipe:)
56
+ @factory.worker(engine: @engine, worktree:)
57
+ .schedule(chunk:, pipe:)
58
+ end
59
+
60
+ def serializer
61
+ @factory.serializer(serializer: @serializer, git_period: @git_period,
62
+ relative_period: @relative_period, jump_days: @jump_days,)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'tmpdir'
5
+
6
+ module ChurnVsComplexity
7
+ module Timetravel
8
+ class Worktree
9
+ attr_reader :folder
10
+
11
+ def initialize(root_folder:, git_strategy:, number:)
12
+ @root_folder = root_folder
13
+ @git_strategy = git_strategy
14
+ @number = number
15
+ end
16
+
17
+ def prepare
18
+ @folder = prepare_worktree
19
+ end
20
+
21
+ def checkout(sha)
22
+ raise Error, 'Worktree not prepared' if @folder.nil?
23
+
24
+ @git_strategy.checkout_in_worktree(@folder, sha)
25
+ end
26
+
27
+ def remove
28
+ raise Error, 'Worktree not prepared' if @folder.nil?
29
+
30
+ @git_strategy.remove_worktree(@folder)
31
+ end
32
+
33
+ private
34
+
35
+ def tt_folder
36
+ folder_hash = Digest::SHA256.hexdigest(@root_folder)[0..7]
37
+ File.join(Dir.tmpdir, 'churn_vs_complexity', 'timetravel', folder_hash)
38
+ end
39
+
40
+ def prepare_worktree
41
+ worktree_folder = File.join(tt_folder, "worktree_#{@number}")
42
+
43
+ unless File.directory?(worktree_folder)
44
+ begin
45
+ FileUtils.mkdir_p(worktree_folder)
46
+ rescue StandardError
47
+ nil
48
+ end
49
+ @git_strategy.add_worktree(worktree_folder)
50
+ end
51
+
52
+ worktree_folder
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'timetravel/traveller'
4
+ require_relative 'timetravel/worktree'
5
+
6
+ module ChurnVsComplexity
7
+ module Timetravel
8
+ class Factory
9
+ def self.git_strategy(folder:) = GitStrategy.new(folder:)
10
+ def self.pipe = IO.pipe
11
+ def self.worker(engine:, worktree:) = Worker.new(engine:, worktree:)
12
+ def self.worktree(root_folder:, git_strategy:, number:) = Worktree.new(root_folder:, git_strategy:, number:)
13
+ def self.serializer(**args) = Serializer::Timetravel.resolve(**args)
14
+ end
15
+
16
+ class Worker
17
+ def initialize(engine:, worktree:)
18
+ @engine = engine
19
+ @worktree = worktree
20
+ end
21
+
22
+ def schedule(chunk:, pipe:)
23
+ fork do
24
+ results = chunk.to_h do |commit|
25
+ sha = commit.sha
26
+ @worktree.checkout(sha)
27
+ result = @engine.check(folder: @worktree.folder)
28
+ [sha, result]
29
+ end
30
+ @worktree.remove
31
+ pipe[1].puts(JSON.dump(results))
32
+ pipe[1].close
33
+ end
34
+ end
35
+ end
36
+
37
+ class GitStrategy
38
+ def initialize(folder:)
39
+ @repo = Git.open(folder)
40
+ @folder = folder
41
+ end
42
+
43
+ def checkout_in_worktree(worktree_folder, sha)
44
+ command = "(cd #{worktree_folder} && git checkout #{sha}) > /dev/null 2>&1"
45
+ `#{command}`
46
+ end
47
+
48
+ def resolve_commits_with_interval(git_period:, jump_days:)
49
+ candidates = @repo.log(1_000_000).since(git_period.effective_start_date).until(git_period.end_date).to_a
50
+
51
+ commits_by_date = candidates.filter { |c| c.date.to_date >= git_period.effective_start_date }
52
+ .group_by { |c| c.date.to_date }
53
+
54
+ found_dates = GitDate.select_dates_with_at_least_interval(commits_by_date.keys, jump_days)
55
+
56
+ found_dates.map { |date| commits_by_date[date].max_by(&:date) }
57
+ end
58
+
59
+ def add_worktree(wt_folder)
60
+ command = "(cd #{@folder} && git worktree add -f #{wt_folder}) > /dev/null 2>&1"
61
+ `#{command}`
62
+ end
63
+
64
+ def remove_worktree(worktree_folder)
65
+ command = "(cd #{worktree_folder} && git worktree remove -f #{worktree_folder}) > /dev/null 2>&1"
66
+ `#{command}`
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChurnVsComplexity
4
- VERSION = '1.3.0'
4
+ VERSION = '1.4.0'
5
5
  end
@@ -13,7 +13,9 @@ require_relative 'churn_vs_complexity/cli'
13
13
  require_relative 'churn_vs_complexity/config'
14
14
  require_relative 'churn_vs_complexity/serializer'
15
15
  require_relative 'churn_vs_complexity/git_date'
16
+ require_relative 'churn_vs_complexity/timetravel'
16
17
 
17
18
  module ChurnVsComplexity
18
19
  class Error < StandardError; end
20
+ class ValidationError < Error; end
19
21
  end
@@ -16,10 +16,7 @@
16
16
  ];
17
17
 
18
18
  // Extract data for Chart.js
19
- const labels = dataPoints.map(point => point.file_path);
20
- const churnData = dataPoints.map(point => point.churn);
21
- const complexityData = dataPoints.map(point => point.complexity);
22
-
19
+ const labels = dataPoints.map(point => point.file_path);
23
20
  // Prepare data in Chart.js format
24
21
  const data = {
25
22
  labels: labels,
@@ -0,0 +1,100 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>INSERT TITLE</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
9
+ <style>
10
+ body {
11
+ font-family: Arial, sans-serif;
12
+ }
13
+ h1 {
14
+ text-align: center;
15
+ font-size: 24px;
16
+ font-weight: bold;
17
+ margin-bottom: 20px;
18
+ color: #333;
19
+ }
20
+ canvas {
21
+ margin: 20px auto;
22
+ }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <h1>INSERT TITLE</h1>
27
+ <canvas id="complexityChart" width="800" height="400"></canvas>
28
+ <canvas id="churnChart" width="800" height="400"></canvas>
29
+ <canvas id="alphaScoreChart" width="800" height="400"></canvas>
30
+ <canvas id="betaScoreChart" width="800" height="400"></canvas>
31
+
32
+ <script>
33
+
34
+ const dataPoints = [
35
+ // INSERT DATA
36
+ ];
37
+
38
+ // Extract dates for x-axis
39
+ const labels = dataPoints.map(point => point.end_date);
40
+
41
+ // Function to create a dataset
42
+ function createDataset(label, data, color) {
43
+ return {
44
+ label: label,
45
+ data: data,
46
+ borderColor: color,
47
+ backgroundColor: color,
48
+ fill: false,
49
+ tension: 0.1
50
+ };
51
+ }
52
+
53
+ // Function to create a chart
54
+ function createChart(ctx, title, datasets) {
55
+ return new Chart(ctx, {
56
+ type: 'line',
57
+ data: { labels: labels, datasets: datasets },
58
+ options: {
59
+ responsive: true,
60
+ plugins: {
61
+ title: { display: true, text: title }
62
+ },
63
+ scales: {
64
+ x: { type: 'time', time: { parser: 'yyyy-MM-dd', tooltipFormat: 'll' } },
65
+ y: { beginAtZero: true }
66
+ }
67
+ }
68
+ });
69
+ }
70
+
71
+ // Create Complexity Chart
72
+ const complexityCtx = document.getElementById('complexityChart').getContext('2d');
73
+ createChart(complexityCtx, 'Complexity Over Time', [
74
+ createDataset('Mean Complexity', dataPoints.map(p => ({ x: p.end_date, y: p.mean_complexity })), 'rgb(75, 192, 192)'),
75
+ createDataset('Median Complexity', dataPoints.map(p => ({ x: p.end_date, y: p.median_complexity })), 'rgb(255, 99, 132)')
76
+ ]);
77
+
78
+ // Create Churn Chart
79
+ const churnCtx = document.getElementById('churnChart').getContext('2d');
80
+ createChart(churnCtx, 'INSERT CHURN MODIFIERChurn Over Time', [
81
+ createDataset('Mean Churn', dataPoints.map(p => ({ x: p.end_date, y: p.mean_churn })), 'rgb(54, 162, 235)'),
82
+ createDataset('Median Churn', dataPoints.map(p => ({ x: p.end_date, y: p.median_churn })), 'rgb(255, 206, 86)')
83
+ ]);
84
+
85
+ // Create Alpha Score Chart
86
+ const alphaScoreCtx = document.getElementById('alphaScoreChart').getContext('2d');
87
+ createChart(alphaScoreCtx, 'Alpha Score Over Time', [
88
+ createDataset('Mean Alpha Score', dataPoints.map(p => ({ x: p.end_date, y: p.mean_alpha_score })), 'rgb(153, 102, 255)'),
89
+ createDataset('Median Alpha Score', dataPoints.map(p => ({ x: p.end_date, y: p.median_alpha_score })), 'rgb(255, 159, 64)')
90
+ ]);
91
+
92
+ // Create Beta Score Chart
93
+ const betaScoreCtx = document.getElementById('betaScoreChart').getContext('2d');
94
+ createChart(betaScoreCtx, 'Beta Score Over Time', [
95
+ createDataset('Mean Beta Score', dataPoints.map(p => ({ x: p.end_date, y: p.mean_beta_score })), 'rgb(153, 102, 255)'),
96
+ createDataset('Median Beta Score', dataPoints.map(p => ({ x: p.end_date, y: p.median_beta_score })), 'rgb(255, 159, 64)')
97
+ ]);
98
+ </script>
99
+ </body>
100
+ </html>
File without changes
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.3.0
4
+ version: 1.4.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-09-25 00:00:00.000000000 Z
11
+ date: 2024-10-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: flog
@@ -57,6 +57,7 @@ files:
57
57
  - LICENSE.txt
58
58
  - README.md
59
59
  - Rakefile
60
+ - TODO
60
61
  - bin/churn_vs_complexity
61
62
  - lib/churn_vs_complexity.rb
62
63
  - lib/churn_vs_complexity/churn.rb
@@ -71,12 +72,24 @@ files:
71
72
  - lib/churn_vs_complexity/file_selector.rb
72
73
  - lib/churn_vs_complexity/git_date.rb
73
74
  - lib/churn_vs_complexity/serializer.rb
75
+ - lib/churn_vs_complexity/serializer/csv.rb
76
+ - lib/churn_vs_complexity/serializer/graph.rb
77
+ - lib/churn_vs_complexity/serializer/pass_through.rb
78
+ - lib/churn_vs_complexity/serializer/summary.rb
79
+ - lib/churn_vs_complexity/serializer/summary_hash.rb
80
+ - lib/churn_vs_complexity/serializer/timetravel.rb
81
+ - lib/churn_vs_complexity/serializer/timetravel/quality_calculator.rb
82
+ - lib/churn_vs_complexity/serializer/timetravel/stats_calculator.rb
83
+ - lib/churn_vs_complexity/timetravel.rb
84
+ - lib/churn_vs_complexity/timetravel/traveller.rb
85
+ - lib/churn_vs_complexity/timetravel/worktree.rb
74
86
  - lib/churn_vs_complexity/version.rb
75
87
  - package-lock.json
76
88
  - tmp/eslint-support/complexity-calculator.js
77
89
  - tmp/eslint-support/package.json
78
90
  - tmp/pmd-support/ruleset.xml
79
91
  - tmp/template/graph.html
92
+ - tmp/template/timetravel_graph.html
80
93
  - tmp/test-support/java/small-example/src/main/java/org/example/Main.java
81
94
  - tmp/test-support/java/small-example/src/main/java/org/example/spice/Checker.java
82
95
  - tmp/test-support/javascript/complex.js
@@ -93,6 +106,7 @@ files:
93
106
  - tmp/test-support/txt/st.txt
94
107
  - tmp/test-support/txt/uvx.txt
95
108
  - tmp/test-support/txt/yz.txt
109
+ - tmp/timetravel/.keep
96
110
  homepage: https://github.com/beatmadsen/churn_vs_complexity
97
111
  licenses:
98
112
  - MIT