solid_score 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +427 -0
- data/config/default.yml +36 -0
- data/exe/solid-score +6 -0
- data/lib/rubocop/cop/solid_score/dependency_inversion.rb +22 -0
- data/lib/rubocop/cop/solid_score/helpers.rb +74 -0
- data/lib/rubocop/cop/solid_score/interface_segregation.rb +22 -0
- data/lib/rubocop/cop/solid_score/liskov_substitution.rb +22 -0
- data/lib/rubocop/cop/solid_score/open_closed.rb +22 -0
- data/lib/rubocop/cop/solid_score/principle_base.rb +47 -0
- data/lib/rubocop/cop/solid_score/single_responsibility.rb +22 -0
- data/lib/rubocop/cop/solid_score/total_score.rb +54 -0
- data/lib/rubocop/solid_score/inject.rb +17 -0
- data/lib/rubocop-solid_score.rb +16 -0
- data/lib/solid_score/analyzers/base_analyzer.rb +17 -0
- data/lib/solid_score/analyzers/dip_analyzer.rb +173 -0
- data/lib/solid_score/analyzers/isp_analyzer.rb +131 -0
- data/lib/solid_score/analyzers/lsp_analyzer.rb +54 -0
- data/lib/solid_score/analyzers/mitigations.rb +19 -0
- data/lib/solid_score/analyzers/ocp_analyzer.rb +104 -0
- data/lib/solid_score/analyzers/srp_analyzer.rb +177 -0
- data/lib/solid_score/cli.rb +83 -0
- data/lib/solid_score/configuration.rb +159 -0
- data/lib/solid_score/diff_analyzer.rb +58 -0
- data/lib/solid_score/diff_classifier.rb +49 -0
- data/lib/solid_score/formatters/base_formatter.rb +11 -0
- data/lib/solid_score/formatters/html_formatter.rb +223 -0
- data/lib/solid_score/formatters/json_formatter.rb +45 -0
- data/lib/solid_score/formatters/text_formatter.rb +54 -0
- data/lib/solid_score/models/class_info.rb +150 -0
- data/lib/solid_score/models/method_info.rb +82 -0
- data/lib/solid_score/models/score_result.rb +95 -0
- data/lib/solid_score/parser/ruby_parser.rb +451 -0
- data/lib/solid_score/presets.rb +64 -0
- data/lib/solid_score/runner.rb +82 -0
- data/lib/solid_score/scorer.rb +35 -0
- data/lib/solid_score/version.rb +5 -0
- data/lib/solid_score.rb +28 -0
- metadata +114 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Analyzers
|
|
5
|
+
class SrpAnalyzer < BaseAnalyzer
|
|
6
|
+
include Mitigations
|
|
7
|
+
|
|
8
|
+
LCOM4_SCORES = {
|
|
9
|
+
1 => 100,
|
|
10
|
+
2 => 60,
|
|
11
|
+
3 => 30
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def analyze(class_info)
|
|
15
|
+
analyze_with_breakdown(class_info)[:score]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Issue #13: Returns the score together with a per-step breakdown so
|
|
19
|
+
# diff-mode output can split structural changes from mechanical ones.
|
|
20
|
+
def analyze_with_breakdown(class_info)
|
|
21
|
+
methods = analyzable_methods(class_info)
|
|
22
|
+
return { score: 100, breakdown: { base: 100 } } if methods.empty?
|
|
23
|
+
|
|
24
|
+
lcom4 = calculate_lcom4(class_info)
|
|
25
|
+
base_score = lcom4_to_score(lcom4)
|
|
26
|
+
base_score = mitigate_data_class(base_score, class_info) if class_info.data_class?
|
|
27
|
+
|
|
28
|
+
wmc = wmc_penalty(class_info)
|
|
29
|
+
statement_penalty = effective_statement_penalty(class_info)
|
|
30
|
+
pre_mitigation = base_score - wmc - statement_penalty
|
|
31
|
+
|
|
32
|
+
after_framework = mitigate_framework_base(pre_mitigation, class_info)
|
|
33
|
+
after_api = mitigate_api_client(after_framework, class_info)
|
|
34
|
+
after_inspection = mitigate_inspection(after_api, class_info)
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
score: clamp_score(after_inspection),
|
|
38
|
+
breakdown: {
|
|
39
|
+
base: base_score,
|
|
40
|
+
wmc_penalty: wmc,
|
|
41
|
+
effective_statement_penalty: statement_penalty,
|
|
42
|
+
mitigation_framework_base: after_framework - pre_mitigation,
|
|
43
|
+
mitigation_api_client: after_api - after_framework,
|
|
44
|
+
mitigation_inspection: after_inspection - after_api
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# LCOM4(Lack of Cohesion of Methods)を計算する。
|
|
50
|
+
# インスタンスメソッドのみを対象とする。クラスメソッドは
|
|
51
|
+
# インスタンス変数を共有しないため凝集度の概念が異なり、
|
|
52
|
+
# 含めるとスコアが不当に低下する。
|
|
53
|
+
# NOTE: ISP Analyzerからも呼ばれるためpublic
|
|
54
|
+
def calculate_lcom4(class_info)
|
|
55
|
+
methods = analyzable_methods(class_info).select(&:instance_method?)
|
|
56
|
+
return 1 if methods.size <= 1
|
|
57
|
+
|
|
58
|
+
# Phase 2c: 小規模クラス(メソッド数≤3)はLCOM4を1に固定
|
|
59
|
+
# 少ないメソッドで凝集度を測定しても統計的に意味がない
|
|
60
|
+
return 1 if methods.size <= 3
|
|
61
|
+
|
|
62
|
+
graph = build_method_graph(methods)
|
|
63
|
+
count_connected_components(graph, methods)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def analyzable_methods(class_info)
|
|
69
|
+
class_info.methods.reject { |m| m.name == :initialize || m.empty? }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_method_graph(methods)
|
|
73
|
+
adjacency = Hash.new { |h, k| h[k] = Set.new }
|
|
74
|
+
|
|
75
|
+
methods.each_with_index do |m1, i|
|
|
76
|
+
methods[(i + 1)..].each do |m2|
|
|
77
|
+
if share_instance_variables?(m1, m2) || call_each_other?(m1, m2)
|
|
78
|
+
adjacency[m1.name] << m2.name
|
|
79
|
+
adjacency[m2.name] << m1.name
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
adjacency
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def share_instance_variables?(m1, m2)
|
|
88
|
+
m1.instance_variables.intersect?(m2.instance_variables)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def call_each_other?(m1, m2)
|
|
92
|
+
m1.called_methods.include?(m2.name) || m2.called_methods.include?(m1.name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def count_connected_components(graph, methods)
|
|
96
|
+
visited = Set.new
|
|
97
|
+
components = 0
|
|
98
|
+
|
|
99
|
+
methods.each do |method|
|
|
100
|
+
next if visited.include?(method.name)
|
|
101
|
+
|
|
102
|
+
bfs(method.name, graph, visited)
|
|
103
|
+
components += 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
components
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bfs(start, graph, visited)
|
|
110
|
+
queue = [start]
|
|
111
|
+
|
|
112
|
+
while (current = queue.shift)
|
|
113
|
+
next if visited.include?(current)
|
|
114
|
+
|
|
115
|
+
visited << current
|
|
116
|
+
graph[current].each { |neighbor| queue << neighbor unless visited.include?(neighbor) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def lcom4_to_score(lcom4)
|
|
121
|
+
LCOM4_SCORES.fetch(lcom4, 0)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def mitigate_data_class(score, _class_info)
|
|
125
|
+
[score, 90].max
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Phase 2c: フレームワーク基盤クラス (ApplicationController等) の最低スコア保証
|
|
129
|
+
# Railsの規約として共通ロジック集約は許容される
|
|
130
|
+
def mitigate_framework_base(score, class_info)
|
|
131
|
+
return score unless class_info.framework_base_class?
|
|
132
|
+
|
|
133
|
+
[score, 70].max
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Phase 2c: APIクライアントパターンの最低スコア保証
|
|
137
|
+
# 全publicメソッドが共通のHTTPクライアント変数を参照する構造は
|
|
138
|
+
# 「1つの外部サービスとの通信」という単一責務
|
|
139
|
+
def mitigate_api_client(score, class_info)
|
|
140
|
+
return score unless class_info.http_client_pattern?
|
|
141
|
+
|
|
142
|
+
[score, 80].max
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def wmc_penalty(class_info)
|
|
146
|
+
wmc = class_info.methods.sum(&:cyclomatic_complexity)
|
|
147
|
+
|
|
148
|
+
if wmc > 40
|
|
149
|
+
20
|
|
150
|
+
elsif wmc > 20
|
|
151
|
+
10
|
|
152
|
+
else
|
|
153
|
+
0
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Issue #10: Penalise based on effective statement count from the AST.
|
|
158
|
+
# Replaces the old line-count heuristic so that stylistic refactors
|
|
159
|
+
# (e.g. expanding `raise X, msg` into `if cond; raise X.new(msg); end`)
|
|
160
|
+
# do not change the SRP score when the semantics are unchanged.
|
|
161
|
+
EFFECTIVE_STATEMENT_HIGH_THRESHOLD = 200
|
|
162
|
+
EFFECTIVE_STATEMENT_LOW_THRESHOLD = 100
|
|
163
|
+
|
|
164
|
+
def effective_statement_penalty(class_info)
|
|
165
|
+
total = class_info.methods.sum(&:effective_statement_count)
|
|
166
|
+
|
|
167
|
+
if total > EFFECTIVE_STATEMENT_HIGH_THRESHOLD
|
|
168
|
+
20
|
|
169
|
+
elsif total > EFFECTIVE_STATEMENT_LOW_THRESHOLD
|
|
170
|
+
10
|
|
171
|
+
else
|
|
172
|
+
0
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SolidScore
|
|
6
|
+
class CLI
|
|
7
|
+
def run(args)
|
|
8
|
+
options = parse_options(args)
|
|
9
|
+
return 0 if options[:exit]
|
|
10
|
+
|
|
11
|
+
config = load_config(options)
|
|
12
|
+
config.merge_cli_options(options)
|
|
13
|
+
|
|
14
|
+
paths = args.reject { |a| a.start_with?("-") }
|
|
15
|
+
config.paths = paths unless paths.empty?
|
|
16
|
+
|
|
17
|
+
runner = Runner.new(config)
|
|
18
|
+
runner.run
|
|
19
|
+
|
|
20
|
+
puts runner.formatted_output
|
|
21
|
+
|
|
22
|
+
runner.passing? ? 0 : 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_options(args)
|
|
28
|
+
options = {}
|
|
29
|
+
|
|
30
|
+
parser = OptionParser.new do |opts|
|
|
31
|
+
opts.banner = "Usage: solid-score [path] [options]"
|
|
32
|
+
|
|
33
|
+
opts.on("--format FORMAT", %w[text json html], "Output format (text|json|html)") do |f|
|
|
34
|
+
options[:format] = f.to_sym
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
opts.on("--config FILE", "Config file path") do |f|
|
|
38
|
+
options[:config] = f
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
opts.on("--min-score SCORE", Integer, "Minimum total score (CI)") do |s|
|
|
42
|
+
options[:min_score] = s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
%w[srp ocp lsp isp dip].each do |principle|
|
|
46
|
+
opts.on("--min-#{principle} SCORE", Integer, "Minimum #{principle.upcase} score") do |s|
|
|
47
|
+
options[:"min_#{principle}"] = s
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
opts.on("--diff REF", "Diff base reference") do |r|
|
|
52
|
+
options[:diff_ref] = r
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
opts.on("--max-decrease SCORE", Integer, "Max score decrease per class") do |s|
|
|
56
|
+
options[:max_decrease] = s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on("--exclude PATTERN", "Exclude patterns (comma-separated)") do |p|
|
|
60
|
+
options[:exclude] = p
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on("--version", "Show version") do
|
|
64
|
+
puts "solid-score v#{VERSION}"
|
|
65
|
+
options[:exit] = true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
opts.on("-h", "--help", "Show help") do
|
|
69
|
+
puts opts
|
|
70
|
+
options[:exit] = true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
parser.parse!(args)
|
|
75
|
+
options
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load_config(options)
|
|
79
|
+
config_path = options[:config] || ".solid-score.yml"
|
|
80
|
+
Configuration.from_file(config_path)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module SolidScore
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_WEIGHTS = {
|
|
8
|
+
srp: 0.30,
|
|
9
|
+
ocp: 0.15,
|
|
10
|
+
lsp: 0.10,
|
|
11
|
+
isp: 0.20,
|
|
12
|
+
dip: 0.25
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
DEFAULT_THRESHOLDS = {
|
|
16
|
+
total: 0,
|
|
17
|
+
srp: 0,
|
|
18
|
+
ocp: 0,
|
|
19
|
+
lsp: 0,
|
|
20
|
+
isp: 0,
|
|
21
|
+
dip: 0
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Issue #14: extended tuning knobs
|
|
25
|
+
DEFAULT_DIFF_THRESHOLDS = {
|
|
26
|
+
ignore_below_delta: 0.0,
|
|
27
|
+
flag_only_structural_below: 0.0
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
DEFAULT_SCORING_OPTIONS = {
|
|
31
|
+
isp_public_method_curve: :linear,
|
|
32
|
+
case_when_2way_lenient: true
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
attr_accessor :paths, :exclude, :format, :thresholds, :weights,
|
|
36
|
+
:diff_ref, :max_decrease, :new_class_min,
|
|
37
|
+
:dip_whitelist,
|
|
38
|
+
:inspection_class_patterns, :symmetric_method_pairs,
|
|
39
|
+
:diff_thresholds, :scoring_options
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@paths = ["."]
|
|
43
|
+
@exclude = []
|
|
44
|
+
@format = :text
|
|
45
|
+
@thresholds = DEFAULT_THRESHOLDS.dup
|
|
46
|
+
@weights = DEFAULT_WEIGHTS.dup
|
|
47
|
+
@diff_ref = nil
|
|
48
|
+
@max_decrease = nil
|
|
49
|
+
@new_class_min = nil
|
|
50
|
+
@dip_whitelist = []
|
|
51
|
+
@inspection_class_patterns = []
|
|
52
|
+
@symmetric_method_pairs = []
|
|
53
|
+
@diff_thresholds = DEFAULT_DIFF_THRESHOLDS.dup
|
|
54
|
+
@scoring_options = DEFAULT_SCORING_OPTIONS.dup
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.default
|
|
58
|
+
new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.from_file(path)
|
|
62
|
+
config = new
|
|
63
|
+
return config unless File.exist?(path)
|
|
64
|
+
|
|
65
|
+
yaml = YAML.safe_load_file(path, symbolize_names: false) || {}
|
|
66
|
+
config.apply_yaml(yaml)
|
|
67
|
+
config
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def apply_yaml(yaml)
|
|
71
|
+
apply_preset(yaml["preset"]) if yaml["preset"]
|
|
72
|
+
|
|
73
|
+
@paths = yaml["paths"] if yaml["paths"]
|
|
74
|
+
@exclude = yaml["exclude"] if yaml["exclude"]
|
|
75
|
+
@format = yaml["format"]&.to_sym if yaml["format"]
|
|
76
|
+
|
|
77
|
+
yaml["thresholds"]&.each { |k, v| @thresholds[k.to_sym] = v }
|
|
78
|
+
|
|
79
|
+
yaml["weights"]&.each { |k, v| @weights[k.to_sym] = v }
|
|
80
|
+
|
|
81
|
+
if yaml["dip"]
|
|
82
|
+
@dip_whitelist = yaml["dip"]["whitelist"] || []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
apply_extended_keys(yaml)
|
|
86
|
+
|
|
87
|
+
return unless yaml["diff"]
|
|
88
|
+
|
|
89
|
+
@max_decrease = yaml["diff"]["max_decrease"]
|
|
90
|
+
@new_class_min = yaml["diff"]["new_class_min"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def merge_cli_options(options)
|
|
94
|
+
@format = options[:format] if options[:format]
|
|
95
|
+
@diff_ref = options[:diff_ref] if options[:diff_ref]
|
|
96
|
+
@thresholds[:total] = options[:min_score] if options[:min_score]
|
|
97
|
+
|
|
98
|
+
%i[srp ocp lsp isp dip].each do |principle|
|
|
99
|
+
key = :"min_#{principle}"
|
|
100
|
+
@thresholds[principle] = options[key] if options[key]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@max_decrease = options[:max_decrease] if options[:max_decrease]
|
|
104
|
+
@exclude = options[:exclude].split(",") if options[:exclude]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def apply_preset(name)
|
|
110
|
+
preset = Presets.fetch(name)
|
|
111
|
+
@paths = preset[:paths] if preset[:paths]
|
|
112
|
+
@exclude = preset[:exclude] if preset[:exclude]
|
|
113
|
+
preset[:weights]&.each { |k, v| @weights[k] = v }
|
|
114
|
+
@dip_whitelist = preset[:dip_whitelist] if preset[:dip_whitelist]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Issue #14: keys parsed by apply_extended_keys but not yet wired into
|
|
118
|
+
# the analyzers. Setting any of them is currently a no-op; the runtime
|
|
119
|
+
# emits a one-time warning so users know their config has no effect yet.
|
|
120
|
+
UNWIRED_EXTENDED_KEYS = %w[
|
|
121
|
+
inspection_classes
|
|
122
|
+
symmetric_method_pairs
|
|
123
|
+
diff_thresholds
|
|
124
|
+
scoring
|
|
125
|
+
].freeze
|
|
126
|
+
|
|
127
|
+
def apply_extended_keys(yaml)
|
|
128
|
+
@inspection_class_patterns = Array(yaml["inspection_classes"]) if yaml["inspection_classes"]
|
|
129
|
+
|
|
130
|
+
custom_pairs = yaml["symmetric_method_pairs"].is_a?(Hash) &&
|
|
131
|
+
yaml["symmetric_method_pairs"]["custom"]
|
|
132
|
+
@symmetric_method_pairs = custom_pairs if custom_pairs
|
|
133
|
+
|
|
134
|
+
diff_thresholds = yaml["diff_thresholds"]
|
|
135
|
+
diff_thresholds.each { |k, v| @diff_thresholds[k.to_sym] = v } if diff_thresholds.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
scoring = yaml["scoring"]
|
|
138
|
+
scoring.each { |k, v| @scoring_options[k.to_sym] = normalize_scoring_value(v) } if scoring.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
warn_unwired_keys(yaml)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_scoring_value(value)
|
|
144
|
+
return value.downcase.to_sym if value.is_a?(String)
|
|
145
|
+
|
|
146
|
+
value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def warn_unwired_keys(yaml)
|
|
150
|
+
present = UNWIRED_EXTENDED_KEYS.select { |k| yaml.key?(k) }
|
|
151
|
+
return if present.empty?
|
|
152
|
+
|
|
153
|
+
Kernel.warn(
|
|
154
|
+
"[solid_score] .solid-score.yml keys parsed but not yet wired " \
|
|
155
|
+
"into the analyzers: #{present.join(", ")} (tracked as a follow-up)."
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
class DiffAnalyzer
|
|
5
|
+
attr_reader :base_ref
|
|
6
|
+
|
|
7
|
+
def initialize(base_ref)
|
|
8
|
+
@base_ref = base_ref
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def changed_files
|
|
12
|
+
git_diff_names.split("\n").select { |f| f.end_with?(".rb") }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def changed_line_ranges
|
|
16
|
+
ranges = Hash.new { |h, k| h[k] = [] }
|
|
17
|
+
current_file = nil
|
|
18
|
+
|
|
19
|
+
git_diff_output.each_line do |line|
|
|
20
|
+
if line.start_with?("+++ b/")
|
|
21
|
+
current_file = line.sub("+++ b/", "").strip
|
|
22
|
+
elsif line.match?(/^@@ .+ @@/) || line.match?(/^\s+@@ .+ @@/)
|
|
23
|
+
match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
|
24
|
+
if match && current_file
|
|
25
|
+
start_line = match[1].to_i
|
|
26
|
+
count = (match[2] || "1").to_i
|
|
27
|
+
ranges[current_file] << (start_line..(start_line + count - 1))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ranges
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def filter_classes(classes, file_ranges)
|
|
36
|
+
classes.select do |class_info|
|
|
37
|
+
file_ranges.key?(class_info.file_path) &&
|
|
38
|
+
file_ranges[class_info.file_path].any? do |range|
|
|
39
|
+
ranges_overlap?(range, class_info.line_start..class_info.line_end)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def ranges_overlap?(a, b)
|
|
47
|
+
a.begin <= b.end && b.begin <= a.end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def git_diff_names
|
|
51
|
+
`git diff --name-only #{base_ref}`.strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def git_diff_output
|
|
55
|
+
`git diff #{base_ref}`
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
# Splits a between-revision delta into structural and mechanical
|
|
5
|
+
# components so reviewers can tell genuine OCP/DIP/LSP regressions
|
|
6
|
+
# apart from mass-only SRP/ISP movements.
|
|
7
|
+
#
|
|
8
|
+
# - +delta_structural+ = OCP + LSP + DIP (architectural decay signals)
|
|
9
|
+
# - +delta_mechanical+ = SRP + ISP (mass-driven movement)
|
|
10
|
+
# - +delta_total+ = +ScoreResult#total+ delta (weighted)
|
|
11
|
+
#
|
|
12
|
+
# The classification is performed at the principle-score level rather
|
|
13
|
+
# than the subscore level for now. The original IMPROVEMENT_PLAN.md
|
|
14
|
+
# discusses subscore-level classification (e.g. attributing the SRP
|
|
15
|
+
# +wmc_penalty+ to mechanical and the LCOM4 movement to structural);
|
|
16
|
+
# extending this requires breakdowns from every analyzer and is
|
|
17
|
+
# tracked as a follow-up.
|
|
18
|
+
#
|
|
19
|
+
# IMPORTANT: +delta_structural+ and +delta_mechanical+ are unweighted
|
|
20
|
+
# sums while +delta_total+ applies +ScoreResult+ weights. They are NOT
|
|
21
|
+
# required to add up to +delta_total+. Consumers that need the weighted
|
|
22
|
+
# split should multiply the unweighted contributions by the relevant
|
|
23
|
+
# weights themselves.
|
|
24
|
+
#
|
|
25
|
+
# Compare with +DiffAnalyzer+, which detects which classes/files
|
|
26
|
+
# changed between two revisions but does not interpret score deltas.
|
|
27
|
+
class DiffClassifier
|
|
28
|
+
STRUCTURAL_PRINCIPLES = %i[ocp lsp dip].freeze
|
|
29
|
+
MECHANICAL_PRINCIPLES = %i[srp isp].freeze
|
|
30
|
+
|
|
31
|
+
def classify(before_result, after_result)
|
|
32
|
+
{
|
|
33
|
+
delta_total: round1(after_result.total - before_result.total),
|
|
34
|
+
delta_structural: round1(principle_delta(before_result, after_result, STRUCTURAL_PRINCIPLES)),
|
|
35
|
+
delta_mechanical: round1(principle_delta(before_result, after_result, MECHANICAL_PRINCIPLES))
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def principle_delta(before_result, after_result, principles)
|
|
42
|
+
principles.sum { |p| after_result.public_send(p) - before_result.public_send(p) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def round1(value)
|
|
46
|
+
value.round(1)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|