churn_vs_complexity 1.8.0 → 1.9.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +1 -1
  4. data/bin/churn_vs_complexity +6 -1
  5. data/lib/churn_vs_complexity/churn.rb +17 -2
  6. data/lib/churn_vs_complexity/complexity/kotlin_calculator.rb +1 -1
  7. data/lib/churn_vs_complexity/delta/serializer.rb +1 -0
  8. data/lib/churn_vs_complexity/gate/config.rb +7 -0
  9. data/lib/churn_vs_complexity/gate/serializer.rb +9 -2
  10. data/lib/churn_vs_complexity/git_strategy.rb +16 -2
  11. data/lib/churn_vs_complexity/normal/serializer/summary_hash.rb +21 -8
  12. data/lib/churn_vs_complexity/timetravel/traveller.rb +2 -0
  13. data/lib/churn_vs_complexity/version.rb +1 -1
  14. metadata +6 -43
  15. data/.devcontainer/devcontainer.json +0 -26
  16. data/.rubocop.yml +0 -25
  17. data/CLAUDE.md +0 -60
  18. data/Dockerfile +0 -34
  19. data/Dockerfile.dev +0 -27
  20. data/Rakefile +0 -6
  21. data/TODO +0 -11
  22. data/package-lock.json +0 -6
  23. data/tmp/test-support/delta/ruby-summary.txt +0 -55
  24. data/tmp/test-support/delta/ruby.csv +0 -12
  25. data/tmp/test-support/go/main.go +0 -11
  26. data/tmp/test-support/go/utils.go +0 -13
  27. data/tmp/test-support/java/small-example/src/main/java/org/example/Main.java +0 -10
  28. data/tmp/test-support/java/small-example/src/main/java/org/example/spice/Checker.java +0 -28
  29. data/tmp/test-support/javascript/complex.js +0 -43
  30. data/tmp/test-support/javascript/moderate.js +0 -12
  31. data/tmp/test-support/javascript/simple.js +0 -5
  32. data/tmp/test-support/javascript/typescript-example.ts +0 -26
  33. data/tmp/test-support/kotlin/Main.kt +0 -7
  34. data/tmp/test-support/kotlin/Utils.kt +0 -11
  35. data/tmp/test-support/python/example.py +0 -6
  36. data/tmp/test-support/python/utils.py +0 -9
  37. data/tmp/test-support/rust/main.rs +0 -7
  38. data/tmp/test-support/rust/utils.rs +0 -20
  39. data/tmp/test-support/swift/main.swift +0 -7
  40. data/tmp/test-support/swift/utils.swift +0 -24
  41. data/tmp/test-support/txt/abc.txt +0 -0
  42. data/tmp/test-support/txt/d.txt +0 -0
  43. data/tmp/test-support/txt/ef.txt +0 -0
  44. data/tmp/test-support/txt/ghij.txt +0 -0
  45. data/tmp/test-support/txt/klm.txt +0 -0
  46. data/tmp/test-support/txt/nopq.txt +0 -0
  47. data/tmp/test-support/txt/r.txt +0 -0
  48. data/tmp/test-support/txt/st.txt +0 -0
  49. data/tmp/test-support/txt/uvx.txt +0 -0
  50. data/tmp/test-support/txt/yz.txt +0 -0
  51. data/tmp/timetravel/.keep +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5a149d026d33ac7319cc78d8c57af7e03113edf42d69f35612bbd72be708c77
4
- data.tar.gz: c87fb3252fc699c681fd8da590fe02fe2d06e47b85fd5b7667fe314bd8ce3393
3
+ metadata.gz: 612c6e1945c04f5be2c72cdbdfbd4bc91157da01c6d4ab66f5a502a7e109c805
4
+ data.tar.gz: c84417b6b7eeeeb4fac050e32c89f037b010977c342e8db14762d28b2f63d557
5
5
  SHA512:
6
- metadata.gz: '09d8eddfd30cd8c56abbd200d5f4e31529ac5fc1171837e3420ccfe2536bf203e3d003a8fbebe7c4c6b0fb54be9a84d71071ca447b9526538edd814953c8b436'
7
- data.tar.gz: ad11518d39553ff5b03fb6c8ea0bc44989ba47d034cbd3b82582279055cad86806612cf0f71c1888b6ecf2aa312a20afce3ad80c8d36b5ab85480152e213e570
6
+ metadata.gz: a8d9617c65744a1b04910c0e9206d788cddcc02133820a05d9bad5bdc160d9c11e05552080bee72d2a1a9b53c765e884cd249095e54a866e38cdb6f3b803880e
7
+ data.tar.gz: 7174c9a6d71d2db643b83b72c49973aef249b09aafacc41112e25407b58a3a46675af29f968c5f84226b8e53fb114c3546664f4f9c2b35940277d68d8f9e5fda
data/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## [1.9.0] - 2026-09-20
2
+
3
+ ### Fixed
4
+ - Churn counted only the commits made since the 30th most recent commit. The
5
+ underlying `git` gem caps a log at 30 entries by default, and the clamp that
6
+ is meant to stop `--since` reaching past the first commit was reading that cap
7
+ as the first commit. Any repository with more than 30 commits was undercounted.
8
+ - Timetravel built its commit window from bare dates, which git treats as
9
+ approximate and resolves against the clock and the timezone. Commits on the
10
+ first and last day of the period were included or dropped depending on when
11
+ and where the command ran. Both ends are now given an explicit time.
12
+ - Running any mode against a repository with no commits raised
13
+ `NoMethodError: undefined method 'date' for nil`. It now says which folder has
14
+ no commits.
15
+ - Errors the tool raises on purpose, such as telling you which analyser to
16
+ install, were printed underneath a backtrace. The backtrace is now kept for
17
+ genuinely unexpected failures. These still exit 2, so a gate failure (exit 1)
18
+ stays distinguishable from a tool that could not run.
19
+ - `--gate --max-gamma 0` crashed with `FloatDomainError: Infinity`, because the
20
+ percentage by which a score exceeds the threshold divided by it. Violations are
21
+ now reported against a zero threshold with `exceeds_by` set to null.
22
+ - A negative `--max-gamma` is now rejected instead of reporting every file as a
23
+ violation with a negative excess.
24
+ - Timetravel raised `ArgumentError: invalid slice size` when no commits fell in
25
+ the requested period. An empty period now produces an empty result.
26
+ - The `churn_vs_complexity` executable failed with a `LoadError` when run from
27
+ inside a project that uses Bundler, which is most of the repositories the tool
28
+ exists to analyse. It required `bundler/setup`, so it resolved against the
29
+ analysed project's Gemfile instead of its own installed gems.
30
+
31
+ ### Changed
32
+ - The `git` dependency moves from 2.x to 5.x and `flog` from 4.8 to 4.9.
33
+ - The published gem no longer carries the test fixtures, the Dockerfiles or the
34
+ other development files it had been shipping.
35
+
36
+ ### Documentation
37
+ - The README told Go users to install gocyclo. The Go calculator runs gocognit,
38
+ and has since Go support was added.
39
+
1
40
  ## [1.8.0] - 2026-04-05
2
41
 
3
42
  ### Added
data/README.md CHANGED
@@ -32,7 +32,7 @@ External tool dependencies per language:
32
32
  - **Java**: Requires [PMD](https://pmd.github.io) on the search path as `pmd`. On macOS: `brew install pmd`.
33
33
  - **JavaScript/TypeScript**: Requires [Node.js](https://nodejs.org) (uses ESLint internally).
34
34
  - **Python**: Requires [Radon](https://radon.readthedocs.io) on the search path as `radon`. Install with `pip install radon`.
35
- - **Go**: Requires [gocyclo](https://github.com/fzipp/gocyclo) on the search path. Install with `go install github.com/fzipp/gocyclo/cmd/gocyclo@latest`.
35
+ - **Go**: Requires [gocognit](https://github.com/uudashr/gocognit) on the search path. Install with `go install github.com/uudashr/gocognit/cmd/gocognit@latest`.
36
36
  - **Kotlin**: Requires [lizard](https://github.com/terryyin/lizard) on the search path. Install with `pip install lizard`.
37
37
  - **Rust**: Requires [lizard](https://github.com/terryyin/lizard) on the search path. Install with `pip install lizard`.
38
38
  - **Swift**: Requires [lizard](https://github.com/terryyin/lizard) on the search path. Install with `pip install lizard`.
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'bundler/setup'
5
4
  require 'churn_vs_complexity'
6
5
 
7
6
  begin
@@ -9,6 +8,12 @@ begin
9
8
  rescue ChurnVsComplexity::ValidationError => e
10
9
  warn e.message
11
10
  exit 1
11
+ rescue ChurnVsComplexity::Error => e
12
+ # Raised deliberately, so the message is the point and a backtrace only
13
+ # buries it. Exit 2, because exit 1 is how the gate reports a codebase that
14
+ # failed its threshold.
15
+ warn e.message
16
+ exit 2
12
17
  rescue StandardError => e
13
18
  warn e.backtrace
14
19
  warn e.message
@@ -23,13 +23,28 @@ module ChurnVsComplexity
23
23
  end
24
24
 
25
25
  def date_of_latest_commit(folder:)
26
- repo(folder).log.first.date
26
+ latest = repo(folder).log(1).execute.first
27
+ raise Error, "#{folder} has no commits to measure churn against" if latest.nil?
28
+
29
+ latest.date
27
30
  end
28
31
 
29
32
  private
30
33
 
34
+ # Asks git for the root commits directly. Reading it off `repo(folder).log`
35
+ # instead would silently answer with the 30th most recent commit, because
36
+ # that is where the git gem caps a log by default.
31
37
  def date_of_first_commit(folder:)
32
- repo(folder).log.last&.date&.to_date || Time.at(0).to_date
38
+ git_dir = File.join(folder, '.git')
39
+ cmd = %(git --git-dir #{git_dir} --work-tree #{folder} log --max-parents=0 --format=%ad --date=short)
40
+ dates = `(#{cmd}) 2>/dev/null`.split("\n").filter_map { |line| parse_date(line) }
41
+ dates.min || Time.at(0).to_date
42
+ end
43
+
44
+ def parse_date(line)
45
+ Date.parse(line)
46
+ rescue Date::Error
47
+ nil
33
48
  end
34
49
 
35
50
  def repo(folder)
@@ -16,7 +16,7 @@ module ChurnVsComplexity
16
16
  end
17
17
 
18
18
  # Lizard CSV format: NLOC,CCN,tokens,params,length,"loc","file","func","long_func",start,end
19
- LIZARD_LINE_PATTERN = /^\d+,(\d+),\d+,\d+,\d+,"[^"]*","([^"]*)"/.freeze
19
+ LIZARD_LINE_PATTERN = /^\d+,(\d+),\d+,\d+,\d+,"[^"]*","([^"]*)"/
20
20
 
21
21
  def parse_lizard_output(csv_output, files:)
22
22
  scores = Hash.new(0)
@@ -62,6 +62,7 @@ module ChurnVsComplexity
62
62
 
63
63
  module PassThrough
64
64
  extend Normal::Serializer::None
65
+
65
66
  def self.has_commit_summary? = true
66
67
  end
67
68
  end
@@ -29,6 +29,7 @@ module ChurnVsComplexity
29
29
  LanguageValidator.validate!(@language)
30
30
  @since_validator.validate!(since: @since, relative_period: nil)
31
31
  @complexity_validator.validate!(@language)
32
+ validate_max_gamma!
32
33
  end
33
34
 
34
35
  def checker
@@ -43,6 +44,12 @@ module ChurnVsComplexity
43
44
 
44
45
  private
45
46
 
47
+ def validate_max_gamma!
48
+ return unless @max_gamma.negative?
49
+
50
+ raise ValidationError, "Max gamma must not be negative, got #{@max_gamma}"
51
+ end
52
+
46
53
  def gate_serializer
47
54
  case @serializer
48
55
  when :json then Serializer::Json
@@ -24,10 +24,17 @@ module ChurnVsComplexity
24
24
  gamma = GammaScore.calculate(values[0], values[1]).round(2)
25
25
  next unless gamma > max_gamma
26
26
 
27
- exceeds_pct = (((gamma - max_gamma) / max_gamma) * 100).round(0)
28
- { file:, gamma_score: gamma, exceeds_by: "#{exceeds_pct}%" }
27
+ { file:, gamma_score: gamma, exceeds_by: excess(gamma, max_gamma) }
29
28
  end
30
29
  end
30
+
31
+ # There is no percentage by which a score exceeds zero, so a zero
32
+ # threshold reports the violation without one.
33
+ def excess(gamma, max_gamma)
34
+ return nil if max_gamma.zero?
35
+
36
+ "#{(((gamma - max_gamma) / max_gamma) * 100).round(0)}%"
37
+ end
31
38
  end
32
39
  end
33
40
  end
@@ -23,7 +23,7 @@ module ChurnVsComplexity
23
23
  def surrounding(commit:)
24
24
  current = object(commit)
25
25
  child_sha = `git -C #{@folder} log --reverse --ancestry-path #{current.sha}..HEAD --format=%H`.lines.first&.chomp
26
- next_commit = child_sha&.empty? ? nil : child_sha
26
+ next_commit = child_sha.nil? || child_sha.empty? ? nil : child_sha
27
27
  [current.parent&.sha, next_commit]
28
28
  end
29
29
 
@@ -39,8 +39,22 @@ module ChurnVsComplexity
39
39
  `#{command}`
40
40
  end
41
41
 
42
+ # Both ends are given an explicit time. A bare date is an approximate date
43
+ # to git, resolved against the clock and the timezone, so the window it
44
+ # produces is not the window that was asked for.
45
+ def commits_in(git_period)
46
+ @repo.log(1_000_000)
47
+ .since(start_of_day(git_period.effective_start_date))
48
+ .until(end_of_day(git_period.end_date))
49
+ .execute.to_a
50
+ end
51
+
52
+ def start_of_day(date) = date.strftime('%Y-%m-%dT00:00:00')
53
+
54
+ def end_of_day(date) = date.strftime('%Y-%m-%dT23:59:59')
55
+
42
56
  def resolve_commits_with_interval(git_period:, jump_days:)
43
- candidates = @repo.log(1_000_000).since(git_period.effective_start_date).until(git_period.end_date).to_a
57
+ candidates = commits_in(git_period)
44
58
 
45
59
  commits_by_date = candidates.filter { |c| c.date.to_date >= git_period.effective_start_date }
46
60
  .group_by { |c| c.date.to_date }
@@ -11,18 +11,31 @@ module ChurnVsComplexity
11
11
 
12
12
  return empty_summary(end_date) if values_by_file.empty?
13
13
 
14
- churn_values = values_by_file.map { |_, values| values[0].to_f }
15
- complexity_values = values_by_file.map { |_, values| values[1].to_f }
16
- gamma_scores = values_by_file.map { |_, values| GammaScore.calculate(values[0], values[1]) }
17
-
18
- stats(churn_values).transform_keys { |k| :"#{k}_churn" }
19
- .merge(stats(complexity_values).transform_keys { |k| :"#{k}_complexity" })
20
- .merge(stats(gamma_scores).slice(:mean, :median).transform_keys { |k| :"#{k}_gamma_score" })
21
- .merge(end_date:)
14
+ summarise(values_by_file).merge(end_date:)
22
15
  end
23
16
 
24
17
  private
25
18
 
19
+ def summarise(values_by_file)
20
+ churn_stats(values_by_file)
21
+ .merge(complexity_stats(values_by_file), gamma_stats(values_by_file))
22
+ end
23
+
24
+ def churn_stats(values_by_file)
25
+ values = values_by_file.map { |_, pair| pair[0].to_f }
26
+ stats(values).transform_keys { |key| :"#{key}_churn" }
27
+ end
28
+
29
+ def complexity_stats(values_by_file)
30
+ values = values_by_file.map { |_, pair| pair[1].to_f }
31
+ stats(values).transform_keys { |key| :"#{key}_complexity" }
32
+ end
33
+
34
+ def gamma_stats(values_by_file)
35
+ values = values_by_file.map { |_, pair| GammaScore.calculate(pair[0], pair[1]) }
36
+ stats(values).slice(:mean, :median).transform_keys { |key| :"#{key}_gamma_score" }
37
+ end
38
+
26
39
  def stats(values)
27
40
  { mean: values.sum / values.size, median: values.sort[values.size / 2], max: values.max, min: values.min }
28
41
  end
@@ -35,6 +35,8 @@ module ChurnVsComplexity
35
35
  end
36
36
 
37
37
  def make_chunks(commits)
38
+ return [] if commits.empty?
39
+
38
40
  chunk_size = (commits.size / 3.0).ceil
39
41
  commits.each_slice(chunk_size).map do |chunk|
40
42
  { chunk:, pipe: @factory.pipe }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChurnVsComplexity
4
- VERSION = '1.8.0'
4
+ VERSION = '1.9.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: churn_vs_complexity
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik T. Madsen
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '4.8'
18
+ version: '4.9'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '4.8'
25
+ version: '4.9'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: git
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '2.1'
32
+ version: '5.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '2.1'
39
+ version: '5.0'
40
40
  description: Correlates file churn (how often files change) with complexity scores
41
41
  to identify refactoring hotspots. Supports Ruby, JavaScript/TypeScript, Java, Python,
42
42
  Go, Kotlin, Rust, and Swift. Modes include hotspots ranking, triage assessment,
@@ -49,16 +49,9 @@ executables:
49
49
  extensions: []
50
50
  extra_rdoc_files: []
51
51
  files:
52
- - ".devcontainer/devcontainer.json"
53
- - ".rubocop.yml"
54
52
  - CHANGELOG.md
55
- - CLAUDE.md
56
- - Dockerfile
57
- - Dockerfile.dev
58
53
  - LICENSE.txt
59
54
  - README.md
60
- - Rakefile
61
- - TODO
62
55
  - bin/churn_vs_complexity
63
56
  - lib/churn_vs_complexity.rb
64
57
  - lib/churn_vs_complexity/churn.rb
@@ -132,41 +125,11 @@ files:
132
125
  - lib/churn_vs_complexity/triage/config.rb
133
126
  - lib/churn_vs_complexity/triage/serializer.rb
134
127
  - lib/churn_vs_complexity/version.rb
135
- - package-lock.json
136
128
  - tmp/eslint-support/complexity-calculator.js
137
129
  - tmp/eslint-support/package.json
138
130
  - tmp/pmd-support/ruleset.xml
139
131
  - tmp/template/graph.html
140
132
  - tmp/template/timetravel_graph.html
141
- - tmp/test-support/delta/ruby-summary.txt
142
- - tmp/test-support/delta/ruby.csv
143
- - tmp/test-support/go/main.go
144
- - tmp/test-support/go/utils.go
145
- - tmp/test-support/java/small-example/src/main/java/org/example/Main.java
146
- - tmp/test-support/java/small-example/src/main/java/org/example/spice/Checker.java
147
- - tmp/test-support/javascript/complex.js
148
- - tmp/test-support/javascript/moderate.js
149
- - tmp/test-support/javascript/simple.js
150
- - tmp/test-support/javascript/typescript-example.ts
151
- - tmp/test-support/kotlin/Main.kt
152
- - tmp/test-support/kotlin/Utils.kt
153
- - tmp/test-support/python/example.py
154
- - tmp/test-support/python/utils.py
155
- - tmp/test-support/rust/main.rs
156
- - tmp/test-support/rust/utils.rs
157
- - tmp/test-support/swift/main.swift
158
- - tmp/test-support/swift/utils.swift
159
- - tmp/test-support/txt/abc.txt
160
- - tmp/test-support/txt/d.txt
161
- - tmp/test-support/txt/ef.txt
162
- - tmp/test-support/txt/ghij.txt
163
- - tmp/test-support/txt/klm.txt
164
- - tmp/test-support/txt/nopq.txt
165
- - tmp/test-support/txt/r.txt
166
- - tmp/test-support/txt/st.txt
167
- - tmp/test-support/txt/uvx.txt
168
- - tmp/test-support/txt/yz.txt
169
- - tmp/timetravel/.keep
170
133
  homepage: https://github.com/beatmadsen/churn_vs_complexity
171
134
  licenses:
172
135
  - MIT
@@ -188,7 +151,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
151
  - !ruby/object:Gem::Version
189
152
  version: '0'
190
153
  requirements: []
191
- rubygems_version: 4.0.6
154
+ rubygems_version: 4.0.9
192
155
  specification_version: 4
193
156
  summary: Analyse churn vs complexity to find refactoring hotspots, gate CI quality,
194
157
  triage risky files, and track codebase health over time.
@@ -1,26 +0,0 @@
1
- // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2
- // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile
3
- {
4
- "name": "Existing Dockerfile",
5
- "build": {
6
- // Sets the run context to one level up instead of the .devcontainer folder.
7
- "context": "..",
8
- // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
9
- "dockerfile": "../Dockerfile.dev"
10
- }
11
-
12
- // Features to add to the dev container. More info: https://containers.dev/features.
13
- // "features": {},
14
-
15
- // Use 'forwardPorts' to make a list of ports inside the container available locally.
16
- // "forwardPorts": [],
17
-
18
- // Uncomment the next line to run commands after the container is created.
19
- // "postCreateCommand": "cat /etc/os-release",
20
-
21
- // Configure tool-specific properties.
22
- // "customizations": {},
23
-
24
- // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
25
- // "remoteUser": "devcontainer"
26
- }
data/.rubocop.yml DELETED
@@ -1,25 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.3
3
- NewCops: enable
4
-
5
- Layout/LineLength:
6
- Max: 120
7
-
8
-
9
- Style/Documentation:
10
- Enabled: false
11
-
12
- Style/ModuleFunction:
13
- Enabled: false
14
-
15
- Style/TrailingCommaInArrayLiteral:
16
- Enabled: true
17
- EnforcedStyleForMultiline: consistent_comma
18
-
19
- Style/TrailingCommaInHashLiteral:
20
- Enabled: true
21
- EnforcedStyleForMultiline: consistent_comma
22
-
23
- Style/TrailingCommaInArguments:
24
- Enabled: true
25
- EnforcedStyleForMultiline: consistent_comma
data/CLAUDE.md DELETED
@@ -1,60 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- `churn_vs_complexity` is a Ruby gem that analyzes code quality by correlating file churn (how often files change) with complexity scores. It supports Ruby (via Flog), JavaScript/TypeScript (via ESLint), Java (via PMD), Python (via Radon), Go (via gocognit), Kotlin (via lizard), Rust (via lizard), and Swift (via lizard). Requires Ruby >= 3.3.
8
-
9
- ## Commands
10
-
11
- ```bash
12
- # Run tests (TLDR framework)
13
- bundle exec rake # default task runs tldr
14
- bundle exec tldr # direct
15
-
16
- # Run a single test file
17
- bundle exec tldr test/churn_vs_complexity/engine_test.rb
18
-
19
- # Run a single test by name
20
- bundle exec tldr --name test_something test/path/to_test.rb
21
-
22
- # Lint
23
- bundle exec rubocop
24
- bundle exec rubocop -a # auto-fix safe offenses
25
-
26
- # Build/install gem
27
- bundle exec rake build
28
- bundle exec rake install
29
- ```
30
-
31
- ## Architecture
32
-
33
- Three operating modes, each with its own Config/Checker/Serializer pipeline:
34
-
35
- - **Normal** (`Normal::Config` → `Engine` → `ConcurrentCalculator`): Analyzes a folder, computing churn + complexity per file. Outputs CSV, HTML graph, or text summary.
36
- - **Timetravel** (`Timetravel::Config` → `Traveller`): Samples quality scores across historical commits at N-day intervals. Uses forked processes with git worktrees for isolation.
37
- - **Delta** (`Delta::Config` → `MultiChecker` → `Checker`): Analyzes complexity of files changed in specific commits. Uses threads with git worktrees.
38
-
39
- Entry point: `CLI::run!` → `CLI::Parser` → `CLI::Main.run!` → mode-specific Config → checker.
40
-
41
- ### Key interfaces
42
-
43
- - **Complexity calculators**: `folder_based?`, `calculate(folder:)` or `calculate(files:)`
44
- - **Churn calculators**: `calculate(folder:, file:, since:)`, `date_of_latest_commit(folder:)`
45
- - **Serializers**: `serialize(result)`
46
- - **File selectors**: `select_files(folder)` → `{ included: [...], explicitly_excluded: [...] }`
47
-
48
- ### Concurrency model
49
-
50
- - Normal: threads (nprocessors) for per-file churn calculation
51
- - Timetravel: forked child processes with IO.pipe for IPC
52
- - Delta: threads (2x nprocessors) for concurrent commit analysis
53
-
54
- ## Code Style
55
-
56
- RuboCop config (`.rubocop.yml`):
57
- - Max line length: 120
58
- - Trailing commas enforced on multiline (arrays, hashes, arguments)
59
- - `NewCops: enable`
60
- - No class/module documentation required (`Style::Documentation` disabled)
data/Dockerfile DELETED
@@ -1,34 +0,0 @@
1
- # Create a build image with wget and unzip
2
- FROM ubuntu:latest as pmd_builder
3
- # Install wget and unzip
4
- RUN apt-get update && \
5
- apt-get install -y --no-install-recommends \
6
- wget unzip
7
-
8
- RUN wget https://github.com/pmd/pmd/releases/download/pmd_releases%2F7.0.0/pmd-dist-7.0.0-bin.zip --no-check-certificate
9
- RUN unzip pmd-dist-7.0.0-bin.zip
10
-
11
- FROM ruby:3.3-bullseye
12
- # Install git and java JDK
13
-
14
- RUN apt-get update; \
15
- apt-get install -y --no-install-recommends \
16
- git openjdk-17-jdk
17
-
18
- # copy PMD from build image
19
- COPY --from=pmd_builder /pmd-bin-7.0.0 /usr/local/bin/pmd-bin-7.0.0
20
- # make pmd available in the PATH
21
- RUN ln -s /usr/local/bin/pmd-bin-7.0.0/bin/pmd /usr/local/bin/pmd
22
-
23
- COPY *.gemspec Gemfile* /app/
24
- COPY lib /app/lib
25
-
26
- WORKDIR /app
27
-
28
- RUN bundle install
29
-
30
- COPY bin /app/bin
31
- COPY tmp/pmd-support /app/tmp/pmd-support
32
- COPY tmp/template /app/tmp/template
33
-
34
- CMD ["/bin/sh", "-c", "echo 'It works!'"]
data/Dockerfile.dev DELETED
@@ -1,27 +0,0 @@
1
- # Create a build image with wget and unzip
2
- FROM ubuntu:latest as pmd_builder
3
- # Install wget and unzip
4
- RUN apt-get update && \
5
- apt-get install -y --no-install-recommends \
6
- wget unzip
7
-
8
- RUN wget https://github.com/pmd/pmd/releases/download/pmd_releases%2F7.0.0/pmd-dist-7.0.0-bin.zip --no-check-certificate
9
- RUN unzip pmd-dist-7.0.0-bin.zip
10
-
11
- FROM ruby:3.3-bullseye
12
- # Install git and java JDK
13
-
14
- RUN apt-get update; \
15
- apt-get install -y --no-install-recommends \
16
- git openjdk-17-jdk
17
-
18
- # copy PMD from build image
19
- COPY --from=pmd_builder /pmd-bin-7.0.0 /usr/local/bin/pmd-bin-7.0.0
20
- # make pmd available in the PATH
21
- RUN ln -s /usr/local/bin/pmd-bin-7.0.0/bin/pmd /usr/local/bin/pmd
22
-
23
- COPY *.gemspec Gemfile* ./
24
- COPY lib/churn_vs_complexity/version.rb lib/churn_vs_complexity/version.rb
25
- RUN bundle install
26
-
27
- CMD ["/bin/sh", "-c", "echo 'It works!'"]
data/Rakefile DELETED
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'bundler/gem_tasks'
4
- require 'tldr/rake'
5
-
6
- task default: :tldr
data/TODO DELETED
@@ -1,11 +0,0 @@
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
data/package-lock.json DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "name": "churn_vs_complexity",
3
- "lockfileVersion": 3,
4
- "requires": true,
5
- "packages": {}
6
- }
@@ -1,55 +0,0 @@
1
- Commit: b20a9bddd04afb9c7a736cf4530fd3188b5f785e
2
- Parent: 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0
3
- Next: 8cdaac516365bd7007b9f755bbe6f6e86d8e13dd
4
-
5
-
6
- File, relative path: lib/churn_vs_complexity/serializer/timetravel.rb
7
- Type of change: modified
8
- Complexity: 58.34326171010786
9
-
10
- File, relative path: lib/churn_vs_complexity/serializer/timetravel/stats_calculator.rb
11
- Type of change: new
12
- Complexity: 80.35549194682164
13
-
14
- File, relative path: test/churn_vs_complexity/serializer/timetravel/quality_calculator_test.rb
15
- Type of change: modified
16
- Complexity: 13.054183368177016
17
-
18
- File, relative path: test/churn_vs_complexity/serializer/timetravel/stats_calculator_test.rb
19
- Type of change: new
20
- Complexity: 12.2198021565134
21
-
22
-
23
-
24
- Commit: 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0
25
- Parent: 83deeccf8aa40e46cd0ca7e2c603c9726633256e
26
- Next: b20a9bddd04afb9c7a736cf4530fd3188b5f785e
27
-
28
-
29
- File, relative path: lib/churn_vs_complexity/config.rb
30
- Type of change: modified
31
- Complexity: 70.26655854867414
32
-
33
- File, relative path: lib/churn_vs_complexity/serializer/pass_through.rb
34
- Type of change: new
35
- Complexity: 19.309088021254183
36
-
37
- File, relative path: lib/churn_vs_complexity/serializer/summary_hash.rb
38
- Type of change: modified
39
- Complexity: 89.25682464940157
40
-
41
- File, relative path: lib/churn_vs_complexity/serializer/timetravel.rb
42
- Type of change: modified
43
- Complexity: 131.0989019732436
44
-
45
- File, relative path: lib/churn_vs_complexity/serializer/timetravel/quality_calculator.rb
46
- Type of change: new
47
- Complexity: 23.324498016752965
48
-
49
- File, relative path: test/churn_vs_complexity/serializer/summary_hash_test.rb
50
- Type of change: modified
51
- Complexity: 14.396336540125482
52
-
53
- File, relative path: test/churn_vs_complexity/serializer/timetravel/quality_calculator_test.rb
54
- Type of change: new
55
- Complexity: 13.054183368177016
@@ -1,12 +0,0 @@
1
- Commit, Relative Path, Type of Change, Complexity
2
- b20a9bddd04afb9c7a736cf4530fd3188b5f785e, lib/churn_vs_complexity/serializer/timetravel.rb, modified, 58.34326171010786
3
- b20a9bddd04afb9c7a736cf4530fd3188b5f785e, lib/churn_vs_complexity/serializer/timetravel/stats_calculator.rb, new, 80.35549194682164
4
- b20a9bddd04afb9c7a736cf4530fd3188b5f785e, test/churn_vs_complexity/serializer/timetravel/quality_calculator_test.rb, modified, 13.054183368177016
5
- b20a9bddd04afb9c7a736cf4530fd3188b5f785e, test/churn_vs_complexity/serializer/timetravel/stats_calculator_test.rb, new, 12.2198021565134
6
- 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0, lib/churn_vs_complexity/config.rb, modified, 70.26655854867414
7
- 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0, lib/churn_vs_complexity/serializer/pass_through.rb, new, 19.309088021254183
8
- 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0, lib/churn_vs_complexity/serializer/summary_hash.rb, modified, 89.25682464940157
9
- 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0, lib/churn_vs_complexity/serializer/timetravel.rb, modified, 131.0989019732436
10
- 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0, lib/churn_vs_complexity/serializer/timetravel/quality_calculator.rb, new, 23.324498016752965
11
- 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0, test/churn_vs_complexity/serializer/summary_hash_test.rb, modified, 14.396336540125482
12
- 4f3151f83e982eb8f2b8a4e7a7572a0af156c3c0, test/churn_vs_complexity/serializer/timetravel/quality_calculator_test.rb, new, 13.054183368177016
@@ -1,11 +0,0 @@
1
- package main
2
-
3
- import "fmt"
4
-
5
- func main() {
6
- fmt.Println("Hello, world!")
7
- }
8
-
9
- func add(a, b int) int {
10
- return a + b
11
- }
@@ -1,13 +0,0 @@
1
- package main
2
-
3
- func calculateSum(numbers []int) int {
4
- total := 0
5
- for _, n := range numbers {
6
- total += n
7
- }
8
- return total
9
- }
10
-
11
- func isEven(n int) bool {
12
- return n%2 == 0
13
- }
@@ -1,10 +0,0 @@
1
- package org.example;
2
-
3
- import org.example.spice.Checker;
4
-
5
- public class Main {
6
- public static void main(String[] args) {
7
- new Checker().check();
8
- }
9
-
10
- }
@@ -1,28 +0,0 @@
1
- package org.example.spice;
2
-
3
- import java.util.Iterator;
4
- import java.util.PrimitiveIterator;
5
- import java.util.Random;
6
-
7
- public class Checker {
8
- public void check() {
9
- for (int j = 0; j < 3; j++) {
10
- var iter = tenRandomInts();
11
- while (iter.hasNext()) {
12
- var i = iter.nextInt();
13
- if (i < 100) {
14
- System.out.println("Bongo");
15
- } else if (i > 200) {
16
- System.out.println("Dingo");
17
- } else {
18
- System.out.println("Zapp");
19
- }
20
- }
21
- }
22
- }
23
-
24
- private static PrimitiveIterator.OfInt tenRandomInts() {
25
- var randomInts = new Random().ints();
26
- return randomInts.limit(10).iterator();
27
- }
28
- }
@@ -1,43 +0,0 @@
1
- function analyzeNumber(num) {
2
- let result = '';
3
-
4
- if (num < 0) {
5
- result += 'negative ';
6
- } else if (num > 0) {
7
- result += 'positive ';
8
- } else {
9
- return 'zero';
10
- }
11
-
12
- if (num % 2 === 0) {
13
- result += 'even ';
14
- } else {
15
- result += 'odd ';
16
- }
17
-
18
- if (num % 3 === 0) {
19
- result += 'divisible by 3 ';
20
- }
21
-
22
- if (num % 5 === 0) {
23
- result += 'divisible by 5 ';
24
- }
25
-
26
- if (isPrime(num)) {
27
- result += 'prime ';
28
- }
29
-
30
- return result.trim();
31
- }
32
-
33
- function isPrime(num) {
34
- if (num <= 1) return false;
35
- for (let i = 2; i <= Math.sqrt(num); i++) {
36
- if (num % i === 0) return false;
37
- }
38
- return true;
39
- }
40
-
41
- console.log(analyzeNumber(17));
42
- console.log(analyzeNumber(30));
43
- console.log(analyzeNumber(-7));
@@ -1,12 +0,0 @@
1
- function fibonacci(n) {
2
- if (n <= 1) return n;
3
- return fibonacci(n - 1) + fibonacci(n - 2);
4
- }
5
-
6
- function printFibonacciSequence(length) {
7
- for (let i = 0; i < length; i++) {
8
- console.log(fibonacci(i));
9
- }
10
- }
11
-
12
- printFibonacciSequence(10);
@@ -1,5 +0,0 @@
1
- function greet(name) {
2
- return `Hello, ${name}!`;
3
- }
4
-
5
- console.log(greet('World'));
@@ -1,26 +0,0 @@
1
- interface Person {
2
- name: string;
3
- age: number;
4
- }
5
-
6
- function createGreeting(person: Person): string {
7
- let greeting = `Hello, ${person.name}!`;
8
-
9
- if (person.age < 18) {
10
- greeting += " You're still a minor.";
11
- } else if (person.age >= 18 && person.age < 65) {
12
- greeting += " You're an adult.";
13
- } else {
14
- greeting += " You're a senior citizen.";
15
- }
16
-
17
- return greeting;
18
- }
19
-
20
- const alice: Person = { name: 'Alice', age: 30 };
21
- const bob: Person = { name: 'Bob', age: 17 };
22
- const charlie: Person = { name: 'Charlie', age: 70 };
23
-
24
- console.log(createGreeting(alice));
25
- console.log(createGreeting(bob));
26
- console.log(createGreeting(charlie));
@@ -1,7 +0,0 @@
1
- fun main() {
2
- println("Hello, world!")
3
- }
4
-
5
- fun add(a: Int, b: Int): Int {
6
- return a + b
7
- }
@@ -1,11 +0,0 @@
1
- fun calculateSum(numbers: List<Int>): Int {
2
- var total = 0
3
- for (n in numbers) {
4
- total += n
5
- }
6
- return total
7
- }
8
-
9
- fun isEven(n: Int): Boolean {
10
- return n % 2 == 0
11
- }
@@ -1,6 +0,0 @@
1
- def hello():
2
- print("Hello, world!")
3
-
4
-
5
- def add(a, b):
6
- return a + b
@@ -1,9 +0,0 @@
1
- def calculate_sum(numbers):
2
- total = 0
3
- for n in numbers:
4
- total += n
5
- return total
6
-
7
-
8
- def is_even(n):
9
- return n % 2 == 0
@@ -1,7 +0,0 @@
1
- fn main() {
2
- println!("Hello, world!");
3
- }
4
-
5
- fn add(a: i32, b: i32) -> i32 {
6
- a + b
7
- }
@@ -1,20 +0,0 @@
1
- fn calculate_sum(numbers: &[i32]) -> i32 {
2
- let mut total = 0;
3
- for n in numbers {
4
- total += n;
5
- }
6
- total
7
- }
8
-
9
- fn classify(value: i32) -> &'static str {
10
- match value {
11
- 0 => "zero",
12
- 1..=10 => "low",
13
- 11..=100 => "medium",
14
- _ => "high",
15
- }
16
- }
17
-
18
- fn is_even(n: i32) -> bool {
19
- n % 2 == 0
20
- }
@@ -1,7 +0,0 @@
1
- func main() {
2
- print("Hello, world!")
3
- }
4
-
5
- func add(_ a: Int, _ b: Int) -> Int {
6
- return a + b
7
- }
@@ -1,24 +0,0 @@
1
- func calculateSum(_ numbers: [Int]) -> Int {
2
- var total = 0
3
- for n in numbers {
4
- total += n
5
- }
6
- return total
7
- }
8
-
9
- func classify(_ value: Int) -> String {
10
- switch value {
11
- case 0:
12
- return "zero"
13
- case 1...10:
14
- return "low"
15
- case 11...100:
16
- return "medium"
17
- default:
18
- return "high"
19
- }
20
- }
21
-
22
- func isEven(_ n: Int) -> Bool {
23
- return n % 2 == 0
24
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
data/tmp/timetravel/.keep DELETED
File without changes