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
|
@@ -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
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module ChurnVsComplexity
|
|
4
4
|
module LanguageValidator
|
|
5
|
+
SUPPORTED = %i[java ruby javascript python go].freeze
|
|
6
|
+
|
|
5
7
|
def self.validate!(language)
|
|
6
|
-
raise ValidationError, "Unsupported language: #{language}" unless
|
|
8
|
+
raise ValidationError, "Unsupported language: #{language}" unless SUPPORTED.include?(language)
|
|
7
9
|
end
|
|
8
10
|
end
|
|
9
11
|
end
|
|
@@ -59,6 +59,22 @@ module ChurnVsComplexity
|
|
|
59
59
|
serializer:,
|
|
60
60
|
since: @since || @relative_period,
|
|
61
61
|
)
|
|
62
|
+
when :python
|
|
63
|
+
Engine.concurrent(
|
|
64
|
+
complexity: Complexity::PythonCalculator,
|
|
65
|
+
churn:,
|
|
66
|
+
file_selector: FileSelector::Python.excluding(@excluded),
|
|
67
|
+
serializer:,
|
|
68
|
+
since: @since || @relative_period,
|
|
69
|
+
)
|
|
70
|
+
when :go
|
|
71
|
+
Engine.concurrent(
|
|
72
|
+
complexity: Complexity::GoCalculator,
|
|
73
|
+
churn:,
|
|
74
|
+
file_selector: FileSelector::Go.excluding(@excluded),
|
|
75
|
+
serializer:,
|
|
76
|
+
since: @since || @relative_period,
|
|
77
|
+
)
|
|
62
78
|
end
|
|
63
79
|
end
|
|
64
80
|
|
|
@@ -78,6 +94,8 @@ module ChurnVsComplexity
|
|
|
78
94
|
Serializer::Summary
|
|
79
95
|
when :pass_through
|
|
80
96
|
Serializer::PassThrough
|
|
97
|
+
when :json
|
|
98
|
+
Serializer::Json
|
|
81
99
|
end
|
|
82
100
|
end
|
|
83
101
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ChurnVsComplexity
|
|
6
|
+
module Normal
|
|
7
|
+
module Serializer
|
|
8
|
+
module Json
|
|
9
|
+
class << self
|
|
10
|
+
def serialize(result)
|
|
11
|
+
values_by_file = result[:values_by_file]
|
|
12
|
+
summary = SummaryHash.serialize(result)
|
|
13
|
+
|
|
14
|
+
files = values_by_file.map { |file, values| build_file_entry(file, values) }
|
|
15
|
+
|
|
16
|
+
JSON.generate({
|
|
17
|
+
files:,
|
|
18
|
+
summary: summary.merge(end_date: summary[:end_date].to_s),
|
|
19
|
+
})
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_file_entry(file, values)
|
|
25
|
+
{
|
|
26
|
+
file:,
|
|
27
|
+
churn: values[0],
|
|
28
|
+
complexity: values[1].to_f,
|
|
29
|
+
gamma_score: GammaScore.calculate(values[0], values[1]).round(2),
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|