churn_vs_complexity 1.5.2 → 1.6.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/CLAUDE.md +60 -0
  4. data/lib/churn_vs_complexity/churn.rb +1 -1
  5. data/lib/churn_vs_complexity/cli/main.rb +8 -0
  6. data/lib/churn_vs_complexity/cli/parser.rb +68 -11
  7. data/lib/churn_vs_complexity/cli.rb +3 -1
  8. data/lib/churn_vs_complexity/complexity/go_calculator.rb +48 -0
  9. data/lib/churn_vs_complexity/complexity/python_calculator.rb +50 -0
  10. data/lib/churn_vs_complexity/complexity.rb +2 -0
  11. data/lib/churn_vs_complexity/complexity_validator.rb +4 -0
  12. data/lib/churn_vs_complexity/delta/config.rb +2 -2
  13. data/lib/churn_vs_complexity/delta.rb +8 -0
  14. data/lib/churn_vs_complexity/diff/checker.rb +46 -0
  15. data/lib/churn_vs_complexity/diff/config.rb +53 -0
  16. data/lib/churn_vs_complexity/diff/serializer.rb +78 -0
  17. data/lib/churn_vs_complexity/diff.rb +10 -0
  18. data/lib/churn_vs_complexity/file_selector.rb +26 -0
  19. data/lib/churn_vs_complexity/focus/checker.rb +59 -0
  20. data/lib/churn_vs_complexity/focus/config.rb +66 -0
  21. data/lib/churn_vs_complexity/focus/serializer.rb +98 -0
  22. data/lib/churn_vs_complexity/focus.rb +10 -0
  23. data/lib/churn_vs_complexity/gamma_score.rb +13 -0
  24. data/lib/churn_vs_complexity/gate/checker.rb +36 -0
  25. data/lib/churn_vs_complexity/gate/config.rb +53 -0
  26. data/lib/churn_vs_complexity/gate/serializer.rb +35 -0
  27. data/lib/churn_vs_complexity/gate.rb +10 -0
  28. data/lib/churn_vs_complexity/git_strategy.rb +13 -5
  29. data/lib/churn_vs_complexity/hotspots/checker.rb +17 -0
  30. data/lib/churn_vs_complexity/hotspots/config.rb +50 -0
  31. data/lib/churn_vs_complexity/hotspots/serializer.rb +49 -0
  32. data/lib/churn_vs_complexity/hotspots.rb +10 -0
  33. data/lib/churn_vs_complexity/language_validator.rb +3 -1
  34. data/lib/churn_vs_complexity/normal/config.rb +18 -0
  35. data/lib/churn_vs_complexity/normal/serializer/json.rb +36 -0
  36. data/lib/churn_vs_complexity/normal/serializer/summary_hash.rb +25 -32
  37. data/lib/churn_vs_complexity/normal/serializer.rb +1 -0
  38. data/lib/churn_vs_complexity/normal.rb +1 -1
  39. data/lib/churn_vs_complexity/risk_annotator.rb +26 -0
  40. data/lib/churn_vs_complexity/risk_classifier.rb +35 -0
  41. data/lib/churn_vs_complexity/triage/checker.rb +64 -0
  42. data/lib/churn_vs_complexity/triage/config.rb +52 -0
  43. data/lib/churn_vs_complexity/triage/serializer.rb +16 -0
  44. data/lib/churn_vs_complexity/triage.rb +10 -0
  45. data/lib/churn_vs_complexity/version.rb +1 -1
  46. data/lib/churn_vs_complexity.rb +8 -0
  47. data/tmp/test-support/delta/ruby-summary.txt +10 -5
  48. data/tmp/test-support/delta/ruby.csv +5 -5
  49. data/tmp/test-support/go/main.go +11 -0
  50. data/tmp/test-support/go/utils.go +13 -0
  51. data/tmp/test-support/python/example.py +6 -0
  52. data/tmp/test-support/python/utils.py +9 -0
  53. metadata +34 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 843cae8dad7f09344cbea477eb73ae3d9d57945df8a5955d93d6dfc820b5a8e1
4
- data.tar.gz: 5066b98af73acde0f18b233fd12efe6858bb9977c4a9d553970c6ad859db98a9
3
+ metadata.gz: 7e7255f75c51831cad9a608e92dad4757febaa1572e88de868b41160bcd772c5
4
+ data.tar.gz: a22de6d66cf13646d53151b24c437b4fe3eff1bd4b1ec806ea5288bf7dc47cde
5
5
  SHA512:
6
- metadata.gz: 7af6d2c460fd9bb2cb78751d8a7f5cdd3ada48e9026e61ccbd3ef74a22c738bcfea4185984ffea2476a5d4b726f902c881edabf2baefa211884bfb129b59f80c
7
- data.tar.gz: c3cf5e4dfd7bea34298fac9d7e2db1b7ffe58ad16acab25e9217dae0f84b43e2da7d89f147027f1ebb3fd52115538aba81a1f05bca5a0f7e13f0ad5d3a7b9aef
6
+ metadata.gz: f405fc54741656b79ab93f5d6dd42864cdf0087df8ec3bb70d0f9409d88ef4eca3667953ac6e86fa6119ddec3f8191553fdb334d49472d149d986cf7265d70f2
7
+ data.tar.gz: f39face62b2f4a97235b265b167ffead22c682f4963b535365023e26bb6ae4448fa9ee425c0cfc02b5e76195ae902a7e315b9f57629ecdb33f4a7c0488a95376
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [1.6.0] - 2026-02-19
2
+
3
+ ### Added
4
+ - Python support for complexity calculation
5
+ - Go support for complexity calculation (via gocognit)
6
+ - New `--triage` mode: per-file risk assessment based on churn and complexity
7
+ - New `--hotspots` mode: ranked list of files by risk score
8
+ - New `--gate` mode: pass/fail quality gate with `--max-gamma` threshold (exit 0/1)
9
+ - New `--focus start|end` mode: capture complexity snapshots before and after coding sessions
10
+ - New `--diff REF` mode: compare codebase health between a reference commit and HEAD
11
+ - New `--json` output format for all modes
12
+ - New `--markdown` output format
13
+ - Risk classifier and risk annotator modules
14
+ - Gamma score module
15
+
16
+ ### Fixed
17
+ - Improved CLI help text with grouped sections (Languages, Modes, Output formats, Modifiers)
18
+
1
19
  ## [1.5.2] - 2024-10-21
2
20
 
3
21
  - Fixed bug where delta mode validations would fail when the commit was a non-sha value.
data/CLAUDE.md ADDED
@@ -0,0 +1,60 @@
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), and Java (via PMD). 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)
@@ -17,7 +17,7 @@ module ChurnVsComplexity
17
17
  def calculate(folder:, file:, since:)
18
18
  git_dir = File.join(folder, '.git')
19
19
  earliest_date = [date_of_first_commit(folder:), since].max
20
- formatted_date = earliest_date.strftime('%Y-%m-%d')
20
+ formatted_date = earliest_date.strftime('%Y-%m-%dT00:00:00')
21
21
  cmd = %(git --git-dir #{git_dir} --work-tree #{folder} log --format="%H" --follow --since="#{formatted_date}" -- #{file} | wc -l)
22
22
  `(#{cmd}) 2>/dev/null`.to_i
23
23
  end
@@ -26,16 +26,24 @@ module ChurnVsComplexity
26
26
  raise ValidationError, 'No options selected. Use --help for usage information.' if options.empty?
27
27
  raise ValidationError, 'No language selected. Use --help for usage information.' if options[:language].nil?
28
28
 
29
+ return if MODES_WITHOUT_SERIALIZER.include?(options[:mode])
29
30
  return unless options[:serializer].nil?
30
31
 
31
32
  raise ValidationError, 'No serializer selected. Use --help for usage information.'
32
33
  end
33
34
 
35
+ MODES_WITHOUT_SERIALIZER = %i[triage hotspots gate focus diff].freeze
36
+
34
37
  def config(options)
35
38
  config_class =
36
39
  case options[:mode]
37
40
  when :timetravel then Timetravel::Config
38
41
  when :delta then Delta::Config
42
+ when :triage then Triage::Config
43
+ when :hotspots then Hotspots::Config
44
+ when :gate then Gate::Config
45
+ when :focus then Focus::Config
46
+ when :diff then Diff::Config
39
47
  else Normal::Config
40
48
  end
41
49
  config_class.new(**options)
@@ -8,7 +8,10 @@ module ChurnVsComplexity
8
8
  def self.create
9
9
  options = { excluded: [] }
10
10
  parser = OptionParser.new do |opts|
11
- opts.banner = 'Usage: churn_vs_complexity [options] folder'
11
+ opts.banner = 'Usage: churn_vs_complexity [options] folder|file...'
12
+
13
+ opts.separator ''
14
+ opts.separator 'Languages:'
12
15
 
13
16
  opts.on('--java', 'Check complexity of java classes') do
14
17
  options[:language] = :java
@@ -23,6 +26,57 @@ module ChurnVsComplexity
23
26
  options[:language] = :javascript
24
27
  end
25
28
 
29
+ opts.on('--python', 'Check complexity of python files') do
30
+ options[:language] = :python
31
+ end
32
+
33
+ opts.on('--go', 'Check complexity of go files') do
34
+ options[:language] = :go
35
+ end
36
+
37
+ opts.separator ''
38
+ opts.separator 'Modes (mutually exclusive):'
39
+
40
+ opts.on('--timetravel N',
41
+ 'Calculate summary for all commits at intervals of N days throughout project history or from the date specified with --since',) do |value|
42
+ options[:mode] = :timetravel
43
+ options[:jump_days] = value.to_i
44
+ end
45
+
46
+ opts.on('--triage',
47
+ 'Assess risk of files based on churn and complexity. Accepts file paths or a folder as arguments.',) do
48
+ options[:mode] = :triage
49
+ end
50
+
51
+ opts.on('--hotspots', 'Generate ranked list of files by risk') do
52
+ options[:mode] = :hotspots
53
+ end
54
+
55
+ opts.on('--gate',
56
+ 'Pass/fail quality check against gamma threshold (exits 0 on pass, 1 on fail)',) do
57
+ options[:mode] = :gate
58
+ end
59
+
60
+ opts.on('--focus start|end',
61
+ 'Capture complexity snapshot before (start) and after (end) a coding session',) do |value|
62
+ options[:mode] = :focus
63
+ options[:subcommand] = value.to_sym
64
+ end
65
+
66
+ opts.on('--diff REF', 'Compare codebase health between REF and HEAD') do |value|
67
+ options[:mode] = :diff
68
+ options[:reference] = value
69
+ end
70
+
71
+ opts.on('--delta SHA',
72
+ 'Identify changes between the specified commit (SHA) and the previous commit and annotate changed files with complexity score. SHA can be a full or short commit hash, or the value HEAD. Can be used multiple times to specify multiple commits.',) do |value|
73
+ options[:mode] = :delta
74
+ (options[:commits] ||= []) << value
75
+ end
76
+
77
+ opts.separator ''
78
+ opts.separator 'Output formats:'
79
+
26
80
  opts.on('--csv', 'Format output as CSV') do
27
81
  options[:serializer] = :csv
28
82
  end
@@ -35,6 +89,17 @@ module ChurnVsComplexity
35
89
  options[:serializer] = :summary
36
90
  end
37
91
 
92
+ opts.on('--json', 'Format output as JSON') do
93
+ options[:serializer] = :json
94
+ end
95
+
96
+ opts.on('--markdown', 'Format output as Markdown') do
97
+ options[:serializer] = :markdown
98
+ end
99
+
100
+ opts.separator ''
101
+ opts.separator 'Modifiers:'
102
+
38
103
  opts.on('--excluded PATTERN',
39
104
  'Exclude file paths including this string. Can be used multiple times.',) do |value|
40
105
  options[:excluded] << value
@@ -57,16 +122,8 @@ module ChurnVsComplexity
57
122
  options[:relative_period] = :year
58
123
  end
59
124
 
60
- opts.on('--timetravel N',
61
- 'Calculate summary for all commits at intervals of N days throughout project history or from the date specified with --since',) do |value|
62
- options[:mode] = :timetravel
63
- options[:jump_days] = value.to_i
64
- end
65
-
66
- opts.on('--delta SHA',
67
- 'Identify changes between the specified commit (SHA) and the previous commit and annotate changed files with complexity score. SHA can be a full or short commit hash, or the value HEAD. Can be used multiple times to specify multiple commits.',) do |value|
68
- options[:mode] = :delta
69
- (options[:commits] ||= []) << value
125
+ opts.on('--max-gamma N', Float, 'Maximum gamma score threshold for gate mode (default: 25)') do |value|
126
+ options[:max_gamma] = value
70
127
  end
71
128
 
72
129
  opts.on('--dry-run', 'Echo the chosen options from the CLI') do
@@ -12,7 +12,9 @@ module ChurnVsComplexity
12
12
  # First argument that is not an option is the folder
13
13
  folder = ARGV.first
14
14
 
15
- puts Main.run!(options, folder)
15
+ result = Main.run!(options, folder)
16
+ puts result
17
+ exit 1 if result.respond_to?(:passed?) && !result.passed?
16
18
  end
17
19
  end
18
20
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module ChurnVsComplexity
7
+ module Complexity
8
+ module GoCalculator
9
+ class << self
10
+ attr_writer :command_runner
11
+
12
+ def folder_based? = false
13
+
14
+ def calculate(files:)
15
+ json_output = run_gocognit(files)
16
+ parse_gocognit_output(json_output, files:)
17
+ end
18
+
19
+ def parse_gocognit_output(json_output, files:)
20
+ stats = JSON.parse(json_output) || []
21
+ scores = stats.group_by { |s| s.dig('Pos', 'Filename') }
22
+ .transform_values { |funcs| funcs.sum { |f| f['Complexity'] } }
23
+ files.to_h { |file| [file, scores[file] || 0] }
24
+ end
25
+
26
+ def check_dependencies!
27
+ command_runner.call('gocognit --help 2>&1')
28
+ rescue Errno::ENOENT
29
+ raise Error, 'Needs gocognit installed (go install github.com/uudashr/gocognit/cmd/gocognit@latest)'
30
+ end
31
+
32
+ private
33
+
34
+ def command_runner
35
+ @command_runner || Open3.method(:capture2)
36
+ end
37
+
38
+ def run_gocognit(files)
39
+ files_arg = files.map { |f| "'#{f}'" }.join(' ')
40
+ stdout, status = command_runner.call("gocognit -json #{files_arg}")
41
+ raise Error, "gocognit failed (exit #{status.exitstatus}). Is it installed?" unless status.success?
42
+
43
+ stdout
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module ChurnVsComplexity
7
+ module Complexity
8
+ module PythonCalculator
9
+ class << self
10
+ attr_writer :command_runner
11
+
12
+ def folder_based? = false
13
+
14
+ def calculate(files:)
15
+ json_output = run_radon(files)
16
+ parse_radon_output(json_output, files:)
17
+ end
18
+
19
+ def parse_radon_output(json_output, files:)
20
+ data = JSON.parse(json_output)
21
+ files.to_h do |file|
22
+ blocks = data[file] || []
23
+ total = blocks.sum { |b| b['complexity'] }
24
+ [file, total]
25
+ end
26
+ end
27
+
28
+ def check_dependencies!
29
+ command_runner.call('radon --version 2>&1')
30
+ rescue Errno::ENOENT
31
+ raise Error, 'Needs radon installed (pip install radon)'
32
+ end
33
+
34
+ private
35
+
36
+ def command_runner
37
+ @command_runner || Open3.method(:capture2)
38
+ end
39
+
40
+ def run_radon(files)
41
+ files_arg = files.map { |f| "'#{f}'" }.join(' ')
42
+ stdout, status = command_runner.call("radon cc #{files_arg} -j")
43
+ raise Error, "radon failed (exit #{status.exitstatus}). Is it installed?" unless status.success?
44
+
45
+ stdout
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative 'complexity/pmd'
4
4
  require_relative 'complexity/flog_calculator'
5
5
  require_relative 'complexity/eslint_calculator'
6
+ require_relative 'complexity/python_calculator'
7
+ require_relative 'complexity/go_calculator'
6
8
 
7
9
  module ChurnVsComplexity
8
10
  module Complexity
@@ -8,6 +8,10 @@ module ChurnVsComplexity
8
8
  Complexity::PMD.check_dependencies!
9
9
  when :javascript
10
10
  Complexity::ESLintCalculator.check_dependencies!
11
+ when :python
12
+ Complexity::PythonCalculator.check_dependencies!
13
+ when :go
14
+ Complexity::GoCalculator.check_dependencies!
11
15
  end
12
16
  end
13
17
  end
@@ -38,10 +38,10 @@ module ChurnVsComplexity
38
38
  end
39
39
 
40
40
  def validate_commit!(commit)
41
- return if commit == 'HEAD' || commit.match?(/\A[0-9a-f]{40}\z/i) || commit.match?(/\A[0-9a-f]{8}\z/i)
41
+ return if commit == 'HEAD' || commit.match?(/\A[0-9a-f]{40}\z/i) || commit.match?(/\A[0-9a-f]{7,12}\z/i)
42
42
 
43
43
  raise ValidationError,
44
- "Invalid commit: #{commit}. It must be a valid 40-character SHA-1 hash or an 8-character shortened form."
44
+ "Invalid commit: #{commit}. It must be a valid 40-character SHA-1 hash or a 7-12 character shortened form."
45
45
  end
46
46
 
47
47
  def serializer = Serializer.resolve(@serializer)
@@ -34,6 +34,10 @@ module ChurnVsComplexity
34
34
  FileSelector::Ruby.predefined(included:, excluded:)
35
35
  when :javascript
36
36
  FileSelector::JavaScript.predefined(included:, excluded:)
37
+ when :python
38
+ FileSelector::Python.predefined(included:, excluded:)
39
+ when :go
40
+ FileSelector::Go.predefined(included:, excluded:)
37
41
  end
38
42
  end
39
43
 
@@ -45,6 +49,10 @@ module ChurnVsComplexity
45
49
  Complexity::FlogCalculator
46
50
  when :javascript
47
51
  Complexity::ESLintCalculator
52
+ when :python
53
+ Complexity::PythonCalculator
54
+ when :go
55
+ Complexity::GoCalculator
48
56
  end
49
57
  end
50
58
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Diff
5
+ class Checker
6
+ def initialize(engine_builder:, serializer:, reference:)
7
+ @engine_builder = engine_builder
8
+ @serializer = serializer
9
+ @reference = reference
10
+ end
11
+
12
+ def check(folder:)
13
+ worktree = setup_worktree(folder:)
14
+
15
+ begin
16
+ before_result = @engine_builder.call.check(folder: worktree.folder)
17
+ after_result = @engine_builder.call.check(folder:)
18
+ serialize_results(before_result, after_result)
19
+ ensure
20
+ cleanup_worktree(worktree)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def setup_worktree(folder:)
27
+ git_strategy = GitStrategy.new(folder:)
28
+ worktree_number = "diff_#{Thread.current.object_id}"
29
+ worktree = Timetravel::Worktree.new(root_folder: folder, git_strategy:, number: worktree_number)
30
+ worktree.prepare
31
+ worktree.checkout(@reference)
32
+ worktree
33
+ end
34
+
35
+ def cleanup_worktree(worktree)
36
+ worktree.remove
37
+ rescue StandardError
38
+ FileUtils.rm_rf(worktree.folder) if worktree&.folder
39
+ end
40
+
41
+ def serialize_results(before_result, after_result)
42
+ @serializer.serialize(reference: @reference, before: before_result, after: after_result)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module Diff
5
+ class Config
6
+ def initialize(
7
+ language:,
8
+ reference:,
9
+ serializer: :json,
10
+ since: nil,
11
+ excluded: [],
12
+ complexity_validator: ComplexityValidator,
13
+ since_validator: Normal::SinceValidator,
14
+ **options
15
+ )
16
+ @language = language
17
+ @reference = reference
18
+ @serializer = serializer
19
+ @since = since
20
+ @excluded = excluded
21
+ @complexity_validator = complexity_validator
22
+ @since_validator = since_validator
23
+ @options = options
24
+ end
25
+
26
+ def validate!
27
+ LanguageValidator.validate!(@language)
28
+ @since_validator.validate!(since: @since, relative_period: nil)
29
+ @complexity_validator.validate!(@language)
30
+ end
31
+
32
+ def checker
33
+ Checker.new(
34
+ engine_builder: method(:build_engine),
35
+ serializer: diff_serializer,
36
+ reference: @reference,
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def build_engine
43
+ Normal::Config.new(language: @language, serializer: :none, since: @since, excluded: @excluded).checker
44
+ end
45
+
46
+ def diff_serializer
47
+ case @serializer
48
+ when :json then Serializer::Json
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ChurnVsComplexity
6
+ module Diff
7
+ module Serializer
8
+ module Json
9
+ def self.serialize(reference:, before:, after:)
10
+ before_gammas = gammas_from(before)
11
+ after_gammas = gammas_from(after)
12
+ degraded, improved, unchanged_count = classify_changes(before_gammas, after_gammas)
13
+
14
+ JSON.generate(
15
+ reference:, current: 'HEAD',
16
+ overall: overall_summary(before_gammas.values, after_gammas.values),
17
+ degraded:, improved:, unchanged: unchanged_count,
18
+ )
19
+ end
20
+
21
+ def self.gammas_from(result)
22
+ result[:values_by_file].transform_values { |v| GammaScore.calculate(v[0], v[1]).round(2) }
23
+ end
24
+
25
+ def self.overall_summary(before_scores, after_scores)
26
+ before_mean = mean(before_scores)
27
+ after_mean = mean(after_scores)
28
+ { mean_gamma_before: before_mean, mean_gamma_after: after_mean,
29
+ direction: direction(before_mean, after_mean), }
30
+ end
31
+
32
+ def self.classify_changes(before_gammas, after_gammas)
33
+ all_files = (before_gammas.keys + after_gammas.keys).uniq
34
+ grouped = all_files.group_by { |f| classify_file(before_gammas[f], after_gammas[f]) }
35
+
36
+ [
37
+ entries_for(grouped[:degraded], before_gammas, after_gammas),
38
+ entries_for(grouped[:improved], before_gammas, after_gammas),
39
+ (grouped[:unchanged] || []).size,
40
+ ]
41
+ end
42
+
43
+ def self.entries_for(files, before_gammas, after_gammas)
44
+ (files || []).map { |f| file_entry(f, before_gammas[f], after_gammas[f]) }
45
+ end
46
+
47
+ def self.classify_file(old_g, new_g)
48
+ return :unchanged if old_g.nil? || new_g.nil? || (old_g - new_g).abs < 0.01
49
+ return :degraded if new_g > old_g
50
+
51
+ :improved
52
+ end
53
+
54
+ def self.file_entry(file, old_g, new_g)
55
+ pct = old_g.positive? ? (((new_g - old_g) / old_g) * 100).round(0) : 0
56
+ sign = pct.positive? ? '+' : ''
57
+ { file:, gamma_before: old_g, gamma_after: new_g, change: "#{sign}#{pct}%" }
58
+ end
59
+
60
+ def self.mean(scores)
61
+ return 0.0 if scores.empty?
62
+
63
+ (scores.sum.to_f / scores.size).round(2)
64
+ end
65
+
66
+ def self.direction(before, after)
67
+ diff = after - before
68
+ return 'unchanged' if diff.abs < 0.5
69
+
70
+ diff.positive? ? 'degraded' : 'improved'
71
+ end
72
+
73
+ private_class_method :gammas_from, :overall_summary, :classify_changes,
74
+ :classify_file, :entries_for, :file_entry, :mean, :direction
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'diff/config'
4
+ require_relative 'diff/checker'
5
+ require_relative 'diff/serializer'
6
+
7
+ module ChurnVsComplexity
8
+ module Diff
9
+ end
10
+ end
@@ -10,6 +10,10 @@ module ChurnVsComplexity
10
10
  ['.rb']
11
11
  when :javascript
12
12
  ['.js', '.jsx', '.ts', '.tsx']
13
+ when :python
14
+ ['.py']
15
+ when :go
16
+ ['.go']
13
17
  else
14
18
  raise Error, "Unsupported language: #{language}"
15
19
  end
@@ -104,5 +108,27 @@ module ChurnVsComplexity
104
108
  convert_to_absolute_path: true,)
105
109
  end
106
110
  end
111
+
112
+ module Python
113
+ DEFAULT_EXCLUDES = %w[venv .venv env .env __pycache__ .tox site-packages].freeze
114
+
115
+ def self.excluding(excluded)
116
+ Excluding.new(FileSelector.extensions(:python), DEFAULT_EXCLUDES + excluded)
117
+ end
118
+
119
+ def self.predefined(included:, excluded:)
120
+ Predefined.new(included:, extensions: FileSelector.extensions(:python), excluded: DEFAULT_EXCLUDES + excluded)
121
+ end
122
+ end
123
+
124
+ module Go
125
+ def self.excluding(excluded)
126
+ Excluding.new(FileSelector.extensions(:go), excluded)
127
+ end
128
+
129
+ def self.predefined(included:, excluded:)
130
+ Predefined.new(included:, extensions: FileSelector.extensions(:go), excluded:)
131
+ end
132
+ end
107
133
  end
108
134
  end