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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/CLAUDE.md +60 -0
  4. data/README.md +60 -14
  5. data/lib/churn_vs_complexity/churn.rb +1 -1
  6. data/lib/churn_vs_complexity/cli/main.rb +8 -0
  7. data/lib/churn_vs_complexity/cli/parser.rb +68 -11
  8. data/lib/churn_vs_complexity/cli.rb +3 -1
  9. data/lib/churn_vs_complexity/complexity/go_calculator.rb +48 -0
  10. data/lib/churn_vs_complexity/complexity/python_calculator.rb +50 -0
  11. data/lib/churn_vs_complexity/complexity.rb +2 -0
  12. data/lib/churn_vs_complexity/complexity_validator.rb +4 -0
  13. data/lib/churn_vs_complexity/delta/config.rb +2 -2
  14. data/lib/churn_vs_complexity/delta.rb +8 -0
  15. data/lib/churn_vs_complexity/diff/checker.rb +46 -0
  16. data/lib/churn_vs_complexity/diff/config.rb +53 -0
  17. data/lib/churn_vs_complexity/diff/serializer.rb +78 -0
  18. data/lib/churn_vs_complexity/diff.rb +10 -0
  19. data/lib/churn_vs_complexity/file_selector.rb +26 -0
  20. data/lib/churn_vs_complexity/focus/checker.rb +59 -0
  21. data/lib/churn_vs_complexity/focus/config.rb +66 -0
  22. data/lib/churn_vs_complexity/focus/serializer.rb +98 -0
  23. data/lib/churn_vs_complexity/focus.rb +10 -0
  24. data/lib/churn_vs_complexity/gamma_score.rb +13 -0
  25. data/lib/churn_vs_complexity/gate/checker.rb +36 -0
  26. data/lib/churn_vs_complexity/gate/config.rb +53 -0
  27. data/lib/churn_vs_complexity/gate/serializer.rb +35 -0
  28. data/lib/churn_vs_complexity/gate.rb +10 -0
  29. data/lib/churn_vs_complexity/git_strategy.rb +13 -5
  30. data/lib/churn_vs_complexity/hotspots/checker.rb +17 -0
  31. data/lib/churn_vs_complexity/hotspots/config.rb +50 -0
  32. data/lib/churn_vs_complexity/hotspots/serializer.rb +49 -0
  33. data/lib/churn_vs_complexity/hotspots.rb +10 -0
  34. data/lib/churn_vs_complexity/language_validator.rb +3 -1
  35. data/lib/churn_vs_complexity/normal/config.rb +18 -0
  36. data/lib/churn_vs_complexity/normal/serializer/json.rb +36 -0
  37. data/lib/churn_vs_complexity/normal/serializer/summary_hash.rb +25 -32
  38. data/lib/churn_vs_complexity/normal/serializer.rb +1 -0
  39. data/lib/churn_vs_complexity/normal.rb +1 -1
  40. data/lib/churn_vs_complexity/risk_annotator.rb +26 -0
  41. data/lib/churn_vs_complexity/risk_classifier.rb +35 -0
  42. data/lib/churn_vs_complexity/triage/checker.rb +64 -0
  43. data/lib/churn_vs_complexity/triage/config.rb +52 -0
  44. data/lib/churn_vs_complexity/triage/serializer.rb +16 -0
  45. data/lib/churn_vs_complexity/triage.rb +10 -0
  46. data/lib/churn_vs_complexity/version.rb +1 -1
  47. data/lib/churn_vs_complexity.rb +8 -0
  48. data/tmp/test-support/delta/ruby-summary.txt +10 -5
  49. data/tmp/test-support/delta/ruby.csv +5 -5
  50. data/tmp/test-support/go/main.go +11 -0
  51. data/tmp/test-support/go/utils.go +13 -0
  52. data/tmp/test-support/python/example.py +6 -0
  53. data/tmp/test-support/python/utils.py +9 -0
  54. 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
@@ -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
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'focus/config'
4
+ require_relative 'focus/checker'
5
+ require_relative 'focus/serializer'
6
+
7
+ module ChurnVsComplexity
8
+ module Focus
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChurnVsComplexity
4
+ module GammaScore
5
+ EPSILON = 0.0001
6
+
7
+ def self.calculate(churn, complexity)
8
+ c = churn.to_f + EPSILON
9
+ x = complexity.to_f + EPSILON
10
+ (2 * c * x) / (c + x)
11
+ end
12
+ end
13
+ 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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'gate/serializer'
4
+ require_relative 'gate/config'
5
+ require_relative 'gate/checker'
6
+
7
+ module ChurnVsComplexity
8
+ module Gate
9
+ end
10
+ 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
- is_head = current.sha == @repo.object('HEAD').sha
24
- next_commit = is_head ? nil : @repo.log(100_000).find { |c| c.parents.map(&:sha).include?(current.sha) }
25
- [current.parent&.sha, next_commit&.sha]
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
- base = commit_object.parent
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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hotspots/serializer'
4
+ require_relative 'hotspots/config'
5
+ require_relative 'hotspots/checker'
6
+
7
+ module ChurnVsComplexity
8
+ module Hotspots
9
+ end
10
+ end