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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +60 -0
- data/lib/churn_vs_complexity/churn.rb +1 -1
- data/lib/churn_vs_complexity/cli/main.rb +8 -0
- data/lib/churn_vs_complexity/cli/parser.rb +68 -11
- data/lib/churn_vs_complexity/cli.rb +3 -1
- data/lib/churn_vs_complexity/complexity/go_calculator.rb +48 -0
- data/lib/churn_vs_complexity/complexity/python_calculator.rb +50 -0
- data/lib/churn_vs_complexity/complexity.rb +2 -0
- data/lib/churn_vs_complexity/complexity_validator.rb +4 -0
- data/lib/churn_vs_complexity/delta/config.rb +2 -2
- data/lib/churn_vs_complexity/delta.rb +8 -0
- data/lib/churn_vs_complexity/diff/checker.rb +46 -0
- data/lib/churn_vs_complexity/diff/config.rb +53 -0
- data/lib/churn_vs_complexity/diff/serializer.rb +78 -0
- data/lib/churn_vs_complexity/diff.rb +10 -0
- data/lib/churn_vs_complexity/file_selector.rb +26 -0
- data/lib/churn_vs_complexity/focus/checker.rb +59 -0
- data/lib/churn_vs_complexity/focus/config.rb +66 -0
- data/lib/churn_vs_complexity/focus/serializer.rb +98 -0
- data/lib/churn_vs_complexity/focus.rb +10 -0
- data/lib/churn_vs_complexity/gamma_score.rb +13 -0
- data/lib/churn_vs_complexity/gate/checker.rb +36 -0
- data/lib/churn_vs_complexity/gate/config.rb +53 -0
- data/lib/churn_vs_complexity/gate/serializer.rb +35 -0
- data/lib/churn_vs_complexity/gate.rb +10 -0
- data/lib/churn_vs_complexity/git_strategy.rb +13 -5
- data/lib/churn_vs_complexity/hotspots/checker.rb +17 -0
- data/lib/churn_vs_complexity/hotspots/config.rb +50 -0
- data/lib/churn_vs_complexity/hotspots/serializer.rb +49 -0
- data/lib/churn_vs_complexity/hotspots.rb +10 -0
- data/lib/churn_vs_complexity/language_validator.rb +3 -1
- data/lib/churn_vs_complexity/normal/config.rb +18 -0
- data/lib/churn_vs_complexity/normal/serializer/json.rb +36 -0
- data/lib/churn_vs_complexity/normal/serializer/summary_hash.rb +25 -32
- data/lib/churn_vs_complexity/normal/serializer.rb +1 -0
- data/lib/churn_vs_complexity/normal.rb +1 -1
- data/lib/churn_vs_complexity/risk_annotator.rb +26 -0
- data/lib/churn_vs_complexity/risk_classifier.rb +35 -0
- data/lib/churn_vs_complexity/triage/checker.rb +64 -0
- data/lib/churn_vs_complexity/triage/config.rb +52 -0
- data/lib/churn_vs_complexity/triage/serializer.rb +16 -0
- data/lib/churn_vs_complexity/triage.rb +10 -0
- data/lib/churn_vs_complexity/version.rb +1 -1
- data/lib/churn_vs_complexity.rb +8 -0
- data/tmp/test-support/delta/ruby-summary.txt +10 -5
- data/tmp/test-support/delta/ruby.csv +5 -5
- data/tmp/test-support/go/main.go +11 -0
- data/tmp/test-support/go/utils.go +13 -0
- data/tmp/test-support/python/example.py +6 -0
- data/tmp/test-support/python/utils.py +9 -0
- metadata +34 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e7255f75c51831cad9a608e92dad4757febaa1572e88de868b41160bcd772c5
|
|
4
|
+
data.tar.gz: a22de6d66cf13646d53151b24c437b4fe3eff1bd4b1ec806ea5288bf7dc47cde
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-%
|
|
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('--
|
|
61
|
-
|
|
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
|
-
|
|
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]{
|
|
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
|
|
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
|
|
@@ -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
|