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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +427 -0
  4. data/config/default.yml +36 -0
  5. data/exe/solid-score +6 -0
  6. data/lib/rubocop/cop/solid_score/dependency_inversion.rb +22 -0
  7. data/lib/rubocop/cop/solid_score/helpers.rb +74 -0
  8. data/lib/rubocop/cop/solid_score/interface_segregation.rb +22 -0
  9. data/lib/rubocop/cop/solid_score/liskov_substitution.rb +22 -0
  10. data/lib/rubocop/cop/solid_score/open_closed.rb +22 -0
  11. data/lib/rubocop/cop/solid_score/principle_base.rb +47 -0
  12. data/lib/rubocop/cop/solid_score/single_responsibility.rb +22 -0
  13. data/lib/rubocop/cop/solid_score/total_score.rb +54 -0
  14. data/lib/rubocop/solid_score/inject.rb +17 -0
  15. data/lib/rubocop-solid_score.rb +16 -0
  16. data/lib/solid_score/analyzers/base_analyzer.rb +17 -0
  17. data/lib/solid_score/analyzers/dip_analyzer.rb +173 -0
  18. data/lib/solid_score/analyzers/isp_analyzer.rb +131 -0
  19. data/lib/solid_score/analyzers/lsp_analyzer.rb +54 -0
  20. data/lib/solid_score/analyzers/mitigations.rb +19 -0
  21. data/lib/solid_score/analyzers/ocp_analyzer.rb +104 -0
  22. data/lib/solid_score/analyzers/srp_analyzer.rb +177 -0
  23. data/lib/solid_score/cli.rb +83 -0
  24. data/lib/solid_score/configuration.rb +159 -0
  25. data/lib/solid_score/diff_analyzer.rb +58 -0
  26. data/lib/solid_score/diff_classifier.rb +49 -0
  27. data/lib/solid_score/formatters/base_formatter.rb +11 -0
  28. data/lib/solid_score/formatters/html_formatter.rb +223 -0
  29. data/lib/solid_score/formatters/json_formatter.rb +45 -0
  30. data/lib/solid_score/formatters/text_formatter.rb +54 -0
  31. data/lib/solid_score/models/class_info.rb +150 -0
  32. data/lib/solid_score/models/method_info.rb +82 -0
  33. data/lib/solid_score/models/score_result.rb +95 -0
  34. data/lib/solid_score/parser/ruby_parser.rb +451 -0
  35. data/lib/solid_score/presets.rb +64 -0
  36. data/lib/solid_score/runner.rb +82 -0
  37. data/lib/solid_score/scorer.rb +35 -0
  38. data/lib/solid_score/version.rb +5 -0
  39. data/lib/solid_score.rb +28 -0
  40. 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidScore
4
+ module Formatters
5
+ class BaseFormatter
6
+ def format(results)
7
+ raise NotImplementedError
8
+ end
9
+ end
10
+ end
11
+ end