churn_vs_complexity 1.5.2 → 1.6.1
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 +25 -0
- data/CLAUDE.md +60 -0
- data/README.md +60 -14
- 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 +41 -10
|
@@ -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
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ChurnVsComplexity
|
|
6
|
+
module Focus
|
|
7
|
+
class Checker
|
|
8
|
+
def initialize(engine:, subcommand:, serializer:, baseline_path:)
|
|
9
|
+
@engine = engine
|
|
10
|
+
@subcommand = subcommand
|
|
11
|
+
@serializer = serializer
|
|
12
|
+
@baseline_path = baseline_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check(folder:)
|
|
16
|
+
resolved_path = resolve_baseline_path(folder)
|
|
17
|
+
case @subcommand
|
|
18
|
+
when :start then run_start(folder, resolved_path)
|
|
19
|
+
when :end then run_end(folder, resolved_path)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def run_start(folder, baseline_path)
|
|
26
|
+
raw_result = @engine.check(folder:)
|
|
27
|
+
entries = RiskAnnotator.annotate(raw_result[:values_by_file])
|
|
28
|
+
|
|
29
|
+
baseline = {
|
|
30
|
+
timestamp: Time.now.utc.iso8601,
|
|
31
|
+
files: entries.map { |e| { file: e[:file], gamma_score: e[:gamma_score] } },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
File.write(baseline_path, JSON.generate(baseline))
|
|
35
|
+
"Baseline saved to #{baseline_path} (#{entries.size} files)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_end(folder, baseline_path)
|
|
39
|
+
raw_result = @engine.check(folder:)
|
|
40
|
+
current_entries = RiskAnnotator.annotate(raw_result[:values_by_file])
|
|
41
|
+
|
|
42
|
+
baseline = load_baseline(baseline_path)
|
|
43
|
+
@serializer.serialize(baseline:, current: current_entries)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def load_baseline(baseline_path)
|
|
47
|
+
return unless File.exist?(baseline_path)
|
|
48
|
+
|
|
49
|
+
JSON.parse(File.read(baseline_path))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_baseline_path(folder)
|
|
53
|
+
return @baseline_path if File.absolute_path?(@baseline_path)
|
|
54
|
+
|
|
55
|
+
File.join(folder, @baseline_path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChurnVsComplexity
|
|
4
|
+
module Focus
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_BASELINE_PATH = '.focus-baseline.json'
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
language:,
|
|
10
|
+
subcommand:,
|
|
11
|
+
serializer: :json,
|
|
12
|
+
since: nil,
|
|
13
|
+
excluded: [],
|
|
14
|
+
baseline_path: DEFAULT_BASELINE_PATH,
|
|
15
|
+
complexity_validator: ComplexityValidator,
|
|
16
|
+
since_validator: Normal::SinceValidator,
|
|
17
|
+
**options
|
|
18
|
+
)
|
|
19
|
+
@language = language
|
|
20
|
+
@subcommand = subcommand.to_sym
|
|
21
|
+
@serializer = serializer
|
|
22
|
+
@since = since
|
|
23
|
+
@excluded = excluded
|
|
24
|
+
@baseline_path = baseline_path
|
|
25
|
+
@complexity_validator = complexity_validator
|
|
26
|
+
@since_validator = since_validator
|
|
27
|
+
@options = options
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate!
|
|
31
|
+
LanguageValidator.validate!(@language)
|
|
32
|
+
@since_validator.validate!(since: @since, relative_period: nil)
|
|
33
|
+
@complexity_validator.validate!(@language)
|
|
34
|
+
validate_subcommand!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def checker
|
|
38
|
+
Checker.new(
|
|
39
|
+
engine: normal_engine,
|
|
40
|
+
subcommand: @subcommand,
|
|
41
|
+
serializer: focus_serializer,
|
|
42
|
+
baseline_path: @baseline_path,
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normal_engine
|
|
49
|
+
Normal::Config.new(language: @language, serializer: :none, since: @since, excluded: @excluded).checker
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_subcommand!
|
|
53
|
+
return if %i[start end].include?(@subcommand)
|
|
54
|
+
|
|
55
|
+
raise ValidationError, "Invalid focus subcommand: #{@subcommand}. Use 'start' or 'end'."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def focus_serializer
|
|
59
|
+
case @subcommand
|
|
60
|
+
when :start then nil # start doesn't need a serializer
|
|
61
|
+
when :end then Serializer::Json
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ChurnVsComplexity
|
|
6
|
+
module Focus
|
|
7
|
+
module Serializer
|
|
8
|
+
module Json
|
|
9
|
+
def self.serialize(baseline:, current:)
|
|
10
|
+
baseline ? build_report(baseline, current) : build_fallback_report(current)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.build_report(baseline, current)
|
|
14
|
+
before_gammas = baseline['files'].to_h { |f| [f['file'], f['gamma_score']] }
|
|
15
|
+
after_gammas = current.to_h { |e| [e[:file], e[:gamma_score]] }
|
|
16
|
+
touched = find_touched_files(before_gammas, after_gammas)
|
|
17
|
+
|
|
18
|
+
JSON.generate(
|
|
19
|
+
session: session_data(started: baseline['timestamp'], files_modified: touched.size),
|
|
20
|
+
impact: impact_data(before: mean(before_gammas.values), after: mean(after_gammas.values)),
|
|
21
|
+
files_touched: touched,
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.build_fallback_report(current)
|
|
26
|
+
after_scores = current.map { |e| e[:gamma_score] }
|
|
27
|
+
|
|
28
|
+
report = {
|
|
29
|
+
warning: 'No baseline found. Comparing against current state only.',
|
|
30
|
+
session: session_data(started: nil, files_modified: 0),
|
|
31
|
+
impact: { mean_gamma_before: nil, mean_gamma_after: mean(after_scores), direction: 'unknown' },
|
|
32
|
+
files_touched: [],
|
|
33
|
+
}
|
|
34
|
+
JSON.generate(report)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.session_data(started:, files_modified:)
|
|
38
|
+
{ started:, ended: Time.now.utc.iso8601, files_modified: }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.impact_data(before:, after:)
|
|
42
|
+
{ mean_gamma_before: before, mean_gamma_after: after, direction: direction(before, after) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.mean(scores)
|
|
46
|
+
return 0.0 if scores.empty?
|
|
47
|
+
|
|
48
|
+
(scores.sum.to_f / scores.size).round(2)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.direction(before, after)
|
|
52
|
+
diff = after - before
|
|
53
|
+
return 'unchanged' if diff.abs < 0.5
|
|
54
|
+
|
|
55
|
+
diff.positive? ? 'degraded' : 'improved'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.find_touched_files(before_files, after_files)
|
|
59
|
+
all_files = (before_files.keys + after_files.keys).uniq
|
|
60
|
+
all_files.filter_map { |file| build_touched_entry(file, before_files[file], after_files[file]) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.build_touched_entry(file, old_gamma, new_gamma)
|
|
64
|
+
return if old_gamma == new_gamma
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
file:,
|
|
68
|
+
gamma_before: old_gamma, gamma_after: new_gamma,
|
|
69
|
+
complexity_added: new_gamma ? (new_gamma - (old_gamma || 0)).round(2) : 0,
|
|
70
|
+
has_tests: test_file_exists?(file),
|
|
71
|
+
recommendation: recommend(old_gamma, new_gamma),
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.test_file_exists?(file)
|
|
76
|
+
return false unless file
|
|
77
|
+
|
|
78
|
+
base = File.basename(file, File.extname(file))
|
|
79
|
+
ext = File.extname(file)
|
|
80
|
+
%w[test spec].any? do |dir|
|
|
81
|
+
File.exist?(File.join(dir, File.dirname(file), "#{base}_#{dir == 'test' ? 'test' : 'spec'}#{ext}"))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.recommend(old_gamma, new_gamma)
|
|
86
|
+
return 'File removed.' if new_gamma.nil?
|
|
87
|
+
return 'New file. Add test coverage.' if old_gamma.nil?
|
|
88
|
+
|
|
89
|
+
new_gamma > old_gamma ? 'Complexity increased. Consider adding tests.' : 'Complexity decreased. Good.'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private_class_method :build_report, :build_fallback_report, :session_data, :impact_data,
|
|
93
|
+
:mean, :direction, :find_touched_files, :build_touched_entry,
|
|
94
|
+
:test_file_exists?, :recommend
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ChurnVsComplexity
|
|
6
|
+
module Gate
|
|
7
|
+
class Checker
|
|
8
|
+
class Result
|
|
9
|
+
def initialize(json, passed:)
|
|
10
|
+
@json = json
|
|
11
|
+
@passed = passed
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def passed?
|
|
15
|
+
@passed
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
@json
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(engine:, serializer:, max_gamma:)
|
|
24
|
+
@engine = engine
|
|
25
|
+
@serializer = serializer
|
|
26
|
+
@max_gamma = max_gamma
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def check(folder:)
|
|
30
|
+
raw_result = @engine.check(folder:)
|
|
31
|
+
json = @serializer.serialize(raw_result, max_gamma: @max_gamma)
|
|
32
|
+
Result.new(json, passed: JSON.parse(json)['passed'])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChurnVsComplexity
|
|
4
|
+
module Gate
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_MAX_GAMMA = 25
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
language:,
|
|
10
|
+
serializer: :json,
|
|
11
|
+
max_gamma: DEFAULT_MAX_GAMMA,
|
|
12
|
+
since: nil,
|
|
13
|
+
excluded: [],
|
|
14
|
+
complexity_validator: ComplexityValidator,
|
|
15
|
+
since_validator: Normal::SinceValidator,
|
|
16
|
+
**options
|
|
17
|
+
)
|
|
18
|
+
@language = language
|
|
19
|
+
@serializer = serializer
|
|
20
|
+
@max_gamma = max_gamma
|
|
21
|
+
@since = since
|
|
22
|
+
@excluded = excluded
|
|
23
|
+
@complexity_validator = complexity_validator
|
|
24
|
+
@since_validator = since_validator
|
|
25
|
+
@options = options
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate!
|
|
29
|
+
LanguageValidator.validate!(@language)
|
|
30
|
+
@since_validator.validate!(since: @since, relative_period: nil)
|
|
31
|
+
@complexity_validator.validate!(@language)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def checker
|
|
35
|
+
normal_config = Normal::Config.new(
|
|
36
|
+
language: @language,
|
|
37
|
+
serializer: :none,
|
|
38
|
+
since: @since,
|
|
39
|
+
excluded: @excluded,
|
|
40
|
+
)
|
|
41
|
+
Checker.new(engine: normal_config.checker, serializer: gate_serializer, max_gamma: @max_gamma)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def gate_serializer
|
|
47
|
+
case @serializer
|
|
48
|
+
when :json then Serializer::Json
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ChurnVsComplexity
|
|
6
|
+
module Gate
|
|
7
|
+
module Serializer
|
|
8
|
+
module Json
|
|
9
|
+
class << self
|
|
10
|
+
def serialize(result, max_gamma:)
|
|
11
|
+
violations = find_violations(result[:values_by_file], max_gamma)
|
|
12
|
+
|
|
13
|
+
JSON.generate(
|
|
14
|
+
passed: violations.empty?,
|
|
15
|
+
threshold: { max_gamma: },
|
|
16
|
+
violations:,
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def find_violations(values_by_file, max_gamma)
|
|
23
|
+
values_by_file.filter_map do |file, values|
|
|
24
|
+
gamma = GammaScore.calculate(values[0], values[1]).round(2)
|
|
25
|
+
next unless gamma > max_gamma
|
|
26
|
+
|
|
27
|
+
exceeds_pct = (((gamma - max_gamma) / max_gamma) * 100).round(0)
|
|
28
|
+
{ file:, gamma_score: gamma, exceeds_by: "#{exceeds_pct}%" }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module ChurnVsComplexity
|
|
4
4
|
class GitStrategy
|
|
5
|
+
EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
|
6
|
+
|
|
5
7
|
def initialize(folder:)
|
|
6
8
|
@repo = Git.open(folder)
|
|
7
9
|
@folder = folder
|
|
@@ -20,15 +22,14 @@ module ChurnVsComplexity
|
|
|
20
22
|
|
|
21
23
|
def surrounding(commit:)
|
|
22
24
|
current = object(commit)
|
|
23
|
-
|
|
24
|
-
next_commit =
|
|
25
|
-
[current.parent&.sha, next_commit
|
|
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
|
|
27
|
+
[current.parent&.sha, next_commit]
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def changes(commit:)
|
|
29
31
|
commit_object = @repo.object(commit)
|
|
30
|
-
|
|
31
|
-
commit_object.diff(base).map do |change|
|
|
32
|
+
diff_from_parent(commit_object).map do |change|
|
|
32
33
|
{ path: change.path, type: change.type.to_sym }
|
|
33
34
|
end
|
|
34
35
|
end
|
|
@@ -58,5 +59,12 @@ module ChurnVsComplexity
|
|
|
58
59
|
command = "(cd #{worktree_folder} && git worktree remove -f #{worktree_folder}) > /dev/null 2>&1"
|
|
59
60
|
`#{command}`
|
|
60
61
|
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def diff_from_parent(commit_object)
|
|
66
|
+
base = commit_object.parent
|
|
67
|
+
base ? base.diff(commit_object) : @repo.diff(EMPTY_TREE_SHA, commit_object.sha)
|
|
68
|
+
end
|
|
61
69
|
end
|
|
62
70
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChurnVsComplexity
|
|
4
|
+
module Hotspots
|
|
5
|
+
class Checker
|
|
6
|
+
def initialize(engine:, serializer:)
|
|
7
|
+
@engine = engine
|
|
8
|
+
@serializer = serializer
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def check(folder:)
|
|
12
|
+
raw_result = @engine.check(folder:)
|
|
13
|
+
@serializer.serialize(raw_result)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChurnVsComplexity
|
|
4
|
+
module Hotspots
|
|
5
|
+
class Config
|
|
6
|
+
def initialize(
|
|
7
|
+
language:,
|
|
8
|
+
serializer: :json,
|
|
9
|
+
since: nil,
|
|
10
|
+
excluded: [],
|
|
11
|
+
complexity_validator: ComplexityValidator,
|
|
12
|
+
since_validator: Normal::SinceValidator,
|
|
13
|
+
**options
|
|
14
|
+
)
|
|
15
|
+
@language = language
|
|
16
|
+
@serializer = serializer
|
|
17
|
+
@since = since
|
|
18
|
+
@excluded = excluded
|
|
19
|
+
@complexity_validator = complexity_validator
|
|
20
|
+
@since_validator = since_validator
|
|
21
|
+
@options = options
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate!
|
|
25
|
+
LanguageValidator.validate!(@language)
|
|
26
|
+
@since_validator.validate!(since: @since, relative_period: nil)
|
|
27
|
+
@complexity_validator.validate!(@language)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def checker
|
|
31
|
+
normal_config = Normal::Config.new(
|
|
32
|
+
language: @language,
|
|
33
|
+
serializer: :none,
|
|
34
|
+
since: @since,
|
|
35
|
+
excluded: @excluded,
|
|
36
|
+
)
|
|
37
|
+
Checker.new(engine: normal_config.checker, serializer: hotspots_serializer)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def hotspots_serializer
|
|
43
|
+
case @serializer
|
|
44
|
+
when :json then Serializer::Json
|
|
45
|
+
when :markdown then Serializer::Markdown
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ChurnVsComplexity
|
|
6
|
+
module Hotspots
|
|
7
|
+
module Serializer
|
|
8
|
+
module Json
|
|
9
|
+
def self.serialize(result)
|
|
10
|
+
entries = RiskAnnotator.annotate(result[:values_by_file])
|
|
11
|
+
entries.sort_by! { |e| -e[:gamma_score] }
|
|
12
|
+
|
|
13
|
+
JSON.generate({ generated: Time.now.utc.iso8601, files: entries,
|
|
14
|
+
summary: RiskAnnotator.risk_summary(entries), })
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module Markdown
|
|
19
|
+
RISK_HEADINGS = {
|
|
20
|
+
'high' => 'High Risk -- require tests and careful review',
|
|
21
|
+
'medium' => 'Medium Risk -- exercise judgement',
|
|
22
|
+
'low' => 'Low Risk -- safe for quick changes',
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def self.serialize(result)
|
|
26
|
+
entries = RiskAnnotator.annotate(result[:values_by_file])
|
|
27
|
+
entries.sort_by! { |e| -e[:gamma_score] }
|
|
28
|
+
grouped = entries.group_by { |e| e[:risk] }
|
|
29
|
+
|
|
30
|
+
lines = ["## Hotspots (generated #{Date.today})", '']
|
|
31
|
+
RISK_HEADINGS.each { |level, heading| append_section(lines, grouped[level], heading) }
|
|
32
|
+
lines.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.append_section(lines, entries, heading)
|
|
36
|
+
return unless entries&.any?
|
|
37
|
+
|
|
38
|
+
lines << "### #{heading}"
|
|
39
|
+
entries.each do |e|
|
|
40
|
+
lines << "- `#{e[:file]}` (gamma: #{e[:gamma_score]}, churn: #{e[:churn]}, complexity: #{e[:complexity]})"
|
|
41
|
+
end
|
|
42
|
+
lines << ''
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :append_section
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|