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,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SolidScore
|
|
6
|
+
# Abstract base for per-principle cops.
|
|
7
|
+
# Subclasses must define `principle_key` (e.g. :srp) and `principle_name` (e.g. "SRP").
|
|
8
|
+
class PrincipleBase < Base
|
|
9
|
+
include Helpers
|
|
10
|
+
|
|
11
|
+
def on_class(node)
|
|
12
|
+
check_principle(node)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on_module(node)
|
|
16
|
+
check_principle(node)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def check_principle(node)
|
|
22
|
+
score_results_for.each do |result|
|
|
23
|
+
target_node = find_class_node_for(result) || node
|
|
24
|
+
next unless same_node?(target_node, node)
|
|
25
|
+
|
|
26
|
+
score = result.send(principle_key)
|
|
27
|
+
threshold = score_threshold
|
|
28
|
+
next unless score < threshold
|
|
29
|
+
|
|
30
|
+
add_offense(node, message: format(
|
|
31
|
+
self.class::MSG,
|
|
32
|
+
score, threshold
|
|
33
|
+
))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def principle_key
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def principle_name
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SolidScore
|
|
6
|
+
# Checks that each class/module meets a minimum SRP (Single Responsibility Principle) score.
|
|
7
|
+
#
|
|
8
|
+
# @example MinScore: 60 (default)
|
|
9
|
+
# SolidScore/SingleResponsibility:
|
|
10
|
+
# Enabled: true
|
|
11
|
+
# MinScore: 60
|
|
12
|
+
class SingleResponsibility < PrincipleBase
|
|
13
|
+
MSG = "SRP score %.1f is below minimum %g"
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def principle_key = :srp
|
|
18
|
+
def principle_name = "SRP"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SolidScore
|
|
6
|
+
# Checks that each class/module meets a minimum SOLID total score.
|
|
7
|
+
#
|
|
8
|
+
# @example EnforcedMinScore: 70 (default)
|
|
9
|
+
# # bad - total score below threshold
|
|
10
|
+
# class GodObject
|
|
11
|
+
# # ... many responsibilities, concrete deps, etc.
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# # good - total score above threshold
|
|
15
|
+
# class FocusedService
|
|
16
|
+
# # ... single responsibility, injected deps
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Configuration in .rubocop.yml
|
|
20
|
+
# SolidScore/TotalScore:
|
|
21
|
+
# Enabled: true
|
|
22
|
+
# MinScore: 70
|
|
23
|
+
class TotalScore < Base
|
|
24
|
+
include Helpers
|
|
25
|
+
|
|
26
|
+
MSG = "SOLID total score %.1f is below minimum %g (SRP=%.1f OCP=%.1f LSP=%.1f ISP=%.1f DIP=%.1f)"
|
|
27
|
+
|
|
28
|
+
def on_class(node)
|
|
29
|
+
check_scores(node)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def on_module(node)
|
|
33
|
+
check_scores(node)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def check_scores(node)
|
|
39
|
+
score_results_for.each do |result|
|
|
40
|
+
target_node = find_class_node_for(result) || node
|
|
41
|
+
next unless same_node?(target_node, node)
|
|
42
|
+
next unless result.total < score_threshold
|
|
43
|
+
|
|
44
|
+
add_offense(node, message: format(
|
|
45
|
+
MSG,
|
|
46
|
+
result.total, score_threshold,
|
|
47
|
+
result.srp, result.ocp, result.lsp, result.isp, result.dip
|
|
48
|
+
))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module SolidScore
|
|
5
|
+
# Injects default configuration from config/default.yml
|
|
6
|
+
module Inject
|
|
7
|
+
def self.defaults!
|
|
8
|
+
path = File.expand_path("../../../config/default.yml", __dir__)
|
|
9
|
+
hash = ::RuboCop::ConfigLoader.load_yaml_configuration(path)
|
|
10
|
+
config = ::RuboCop::Config.new(hash, path)
|
|
11
|
+
puts "configuration from #{path}" if ::RuboCop::ConfigLoader.debug?
|
|
12
|
+
config = ::RuboCop::ConfigLoader.merge_with_default(config, path)
|
|
13
|
+
::RuboCop::ConfigLoader.instance_variable_set(:@default_configuration, config)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
require "solid_score"
|
|
5
|
+
require_relative "rubocop/solid_score/inject"
|
|
6
|
+
|
|
7
|
+
require_relative "rubocop/cop/solid_score/helpers"
|
|
8
|
+
require_relative "rubocop/cop/solid_score/total_score"
|
|
9
|
+
require_relative "rubocop/cop/solid_score/principle_base"
|
|
10
|
+
require_relative "rubocop/cop/solid_score/single_responsibility"
|
|
11
|
+
require_relative "rubocop/cop/solid_score/open_closed"
|
|
12
|
+
require_relative "rubocop/cop/solid_score/liskov_substitution"
|
|
13
|
+
require_relative "rubocop/cop/solid_score/interface_segregation"
|
|
14
|
+
require_relative "rubocop/cop/solid_score/dependency_inversion"
|
|
15
|
+
|
|
16
|
+
RuboCop::SolidScore::Inject.defaults!
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Analyzers
|
|
5
|
+
class BaseAnalyzer
|
|
6
|
+
def analyze(class_info)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#analyze must be implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def clamp_score(score)
|
|
13
|
+
[[score, 0].max, 100].min.round(1)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Analyzes Dependency Inversion Principle compliance.
|
|
6
|
+
#
|
|
7
|
+
# Phase 1 改善: 標準ライブラリのホワイトリスト導入
|
|
8
|
+
# Phase 2b 改善: ファクトリメソッド検出、ユーザー定義ホワイトリスト
|
|
9
|
+
class DipAnalyzer < BaseAnalyzer
|
|
10
|
+
DI_BONUS = 15
|
|
11
|
+
|
|
12
|
+
# 具象依存1件あたりのペナルティと、その上限。
|
|
13
|
+
#
|
|
14
|
+
# 旧実装は concrete / (concrete + injected) の「比率」でスコアを決めて
|
|
15
|
+
# いたため、注入依存が0件のクラスは比率が必ず 1.0 になり、具象依存が
|
|
16
|
+
# 1件でも10件でも一律0点に潰れていた(著名OSSの13%が0点・中間スコア
|
|
17
|
+
# ほぼ消滅という二極化が実測された)。
|
|
18
|
+
#
|
|
19
|
+
# DIPで本来効くのは「いくつの具象クラスに直接結合しているか」という
|
|
20
|
+
# 絶対数なので、依存件数に比例した逓減ペナルティへ変更する。
|
|
21
|
+
# 1件=12pt … 5件以上で上限60ptに到達し、件数が増えるほど低スコアに
|
|
22
|
+
# なるが、1〜2件程度の素朴な `Foo.new` は中〜高スコアに留まる。
|
|
23
|
+
CONCRETE_DEP_PENALTY_PER = 12
|
|
24
|
+
MAX_CONCRETE_DEP_PENALTY = 60
|
|
25
|
+
|
|
26
|
+
# Issue #12: memoized factory bonus
|
|
27
|
+
MEMOIZED_FACTORY_BONUS_PER = 5
|
|
28
|
+
MEMOIZED_FACTORY_BONUS_MAX = 15
|
|
29
|
+
|
|
30
|
+
# Phase 2b: .new 以外のファクトリメソッドも具象依存として検出
|
|
31
|
+
FACTORY_METHODS = %i[new create build call open].freeze
|
|
32
|
+
|
|
33
|
+
# Ruby標準ライブラリおよびコアクラスのホワイトリスト
|
|
34
|
+
# これらのクラスの .new 呼び出しは具象依存としてカウントしない
|
|
35
|
+
STANDARD_LIBRARY_WHITELIST = %w[
|
|
36
|
+
Array Hash Set SortedSet
|
|
37
|
+
Thread Mutex Monitor ConditionVariable Queue SizedQueue
|
|
38
|
+
Time Date DateTime
|
|
39
|
+
BigDecimal Rational Complex
|
|
40
|
+
String StringIO Regexp
|
|
41
|
+
File Dir IO Tempfile
|
|
42
|
+
Struct OpenStruct
|
|
43
|
+
StandardError RuntimeError ArgumentError TypeError
|
|
44
|
+
Range Enumerator Proc Method
|
|
45
|
+
Logger
|
|
46
|
+
URI
|
|
47
|
+
JSON
|
|
48
|
+
CSV
|
|
49
|
+
Socket
|
|
50
|
+
Net::HTTP
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
def initialize(user_whitelist: [])
|
|
54
|
+
@user_whitelist = user_whitelist
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 具象依存が1件でも残るクラスは、DIボーナス等を加点しても満点には
|
|
58
|
+
# 到達させない上限。100点は「具象依存ゼロの真にクリーンなクラス」に
|
|
59
|
+
# 限定し、DI採用は加点しつつも「具象結合が残る=DIPは完璧ではない」
|
|
60
|
+
# を表現する(具象1件 + 注入1件のような構成が100点に潰れるのを防ぐ)。
|
|
61
|
+
MAX_SCORE_WITH_CONCRETE_DEP = 95
|
|
62
|
+
|
|
63
|
+
# Phase 2c: レイヤー別のDIPペナルティ重み
|
|
64
|
+
# Controller/Model ではDIP違反の影響度を軽減
|
|
65
|
+
# (Railsの標準パターンである Service.new.call, OtherModel.create を許容)
|
|
66
|
+
LAYER_DIP_WEIGHT = {
|
|
67
|
+
controller: 0.4, # Service.new.call は標準パターン
|
|
68
|
+
model: 0.5, # OtherModel.create は標準パターン
|
|
69
|
+
service: 1.0, # DIが推奨される層
|
|
70
|
+
job: 0.6, # Service呼び出しは許容
|
|
71
|
+
mailer: 0.5, # Model参照は許容
|
|
72
|
+
lib: 1.0, # DIが推奨される層
|
|
73
|
+
unknown: 1.0
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
def analyze(class_info)
|
|
77
|
+
concrete_deps = count_concrete_instantiations(class_info)
|
|
78
|
+
injected_deps = count_injected_dependencies(class_info)
|
|
79
|
+
total_deps = concrete_deps + injected_deps
|
|
80
|
+
bonus = memoized_factory_bonus(class_info)
|
|
81
|
+
|
|
82
|
+
if total_deps.zero?
|
|
83
|
+
return clamp_score(100 + bonus)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Phase 2c: レイヤー別にペナルティの重みを調整
|
|
87
|
+
weight = layer_weight(class_info)
|
|
88
|
+
|
|
89
|
+
# 具象依存の「絶対数」に応じた逓減ペナルティ(比率ベースを廃止)。
|
|
90
|
+
# これにより具象1件のクラスが0点に潰れず、多依存クラスとの差が出る。
|
|
91
|
+
score = 100 - (concrete_dep_penalty(concrete_deps) * weight)
|
|
92
|
+
|
|
93
|
+
score += DI_BONUS if injected_deps.positive?
|
|
94
|
+
score += bonus
|
|
95
|
+
|
|
96
|
+
# 具象依存が残る限り、加点しても満点には届かせない。
|
|
97
|
+
score = [score, MAX_SCORE_WITH_CONCRETE_DEP].min if concrete_deps.positive?
|
|
98
|
+
|
|
99
|
+
clamp_score(score)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Phase 1 改善: 標準ライブラリを除外した具象依存カウント
|
|
105
|
+
#
|
|
106
|
+
# method_calls (MethodCallInfo) を使用してレシーバ情報を取得し、
|
|
107
|
+
# 標準ライブラリのインスタンス化を除外します。
|
|
108
|
+
#
|
|
109
|
+
# @param class_info [ClassInfo] クラス情報
|
|
110
|
+
# @return [Integer] 具象依存の数
|
|
111
|
+
def count_concrete_instantiations(class_info)
|
|
112
|
+
class_info.methods.sum do |method|
|
|
113
|
+
count_concrete_deps_in_method(method)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Phase 2b: ファクトリメソッドも含めて具象依存をカウント
|
|
118
|
+
def count_concrete_deps_in_method(method)
|
|
119
|
+
method.method_calls.count do |call|
|
|
120
|
+
next false unless FACTORY_METHODS.include?(call.method_name)
|
|
121
|
+
next false unless call.receiver_type == :const
|
|
122
|
+
|
|
123
|
+
!whitelisted_class?(call.receiver)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# 標準ライブラリまたはユーザー定義ホワイトリストに含まれるかを判定
|
|
128
|
+
def whitelisted_class?(class_name)
|
|
129
|
+
return false if class_name.nil?
|
|
130
|
+
|
|
131
|
+
all_whitelist = STANDARD_LIBRARY_WHITELIST + @user_whitelist
|
|
132
|
+
all_whitelist.any? do |lib_class|
|
|
133
|
+
class_name == lib_class || class_name.end_with?("::#{lib_class}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def count_injected_dependencies(class_info)
|
|
138
|
+
init = class_info.methods.find { |m| m.name == :initialize }
|
|
139
|
+
return 0 unless init
|
|
140
|
+
|
|
141
|
+
# Parser gem returns:
|
|
142
|
+
# :kwarg = required keyword argument (def foo(bar:))
|
|
143
|
+
# :kwoptarg = optional keyword argument (def foo(bar: default))
|
|
144
|
+
# :kwrestarg = keyword rest argument (def foo(**kwargs))
|
|
145
|
+
# Also check :key and :keyreq for backward compatibility
|
|
146
|
+
init.parameters.count { |type, _| %i[key keyreq kwarg kwoptarg kwrestarg].include?(type) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Issue #12: Reward `@svc ||= ServiceClass.new(...)` style memoised
|
|
150
|
+
# factories, but skip standard-library / user-whitelisted classes so
|
|
151
|
+
# `@queue ||= Queue.new` doesn't earn a DI-style bonus.
|
|
152
|
+
def memoized_factory_bonus(class_info)
|
|
153
|
+
count = class_info.methods.count do |m|
|
|
154
|
+
next false unless m.memoized_factory? && m.cyclomatic_complexity == 1
|
|
155
|
+
next false if m.memoized_factory_receiver.nil?
|
|
156
|
+
|
|
157
|
+
!whitelisted_class?(m.memoized_factory_receiver)
|
|
158
|
+
end
|
|
159
|
+
[count * MEMOIZED_FACTORY_BONUS_PER, MEMOIZED_FACTORY_BONUS_MAX].min
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# 具象依存の件数に比例した逓減ペナルティ(上限あり)。
|
|
163
|
+
# 0件=0, 1件=12, 2件=24 ... 5件以上は60で頭打ち。
|
|
164
|
+
def concrete_dep_penalty(concrete_deps)
|
|
165
|
+
[concrete_deps * CONCRETE_DEP_PENALTY_PER, MAX_CONCRETE_DEP_PENALTY].min
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def layer_weight(class_info)
|
|
169
|
+
LAYER_DIP_WEIGHT.fetch(class_info.layer, 1.0)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Analyzers
|
|
5
|
+
class IspAnalyzer < BaseAnalyzer
|
|
6
|
+
include Mitigations
|
|
7
|
+
|
|
8
|
+
# Issue #7: Linear interpolation between 5 and 25 public methods.
|
|
9
|
+
# 5 methods → 100, 25 methods → 20, slope -4pt per method.
|
|
10
|
+
PUBLIC_METHOD_SCORE_CEILING = 100
|
|
11
|
+
PUBLIC_METHOD_SCORE_FLOOR = 20
|
|
12
|
+
PUBLIC_METHOD_FREE_COUNT = 5
|
|
13
|
+
PUBLIC_METHOD_FLOOR_COUNT = 25
|
|
14
|
+
PUBLIC_METHOD_SLOPE = 4
|
|
15
|
+
|
|
16
|
+
# Issue #8: Symmetric prefix pairs.
|
|
17
|
+
# Methods like `enable_X` / `disable_X` describe one capability and should
|
|
18
|
+
# not double-count toward the public surface. Each prefix pair contributes
|
|
19
|
+
# at most one discount per matching suffix.
|
|
20
|
+
SYMMETRIC_PREFIXES = [
|
|
21
|
+
%w[enable disable], %w[start stop], %w[open close],
|
|
22
|
+
%w[add remove], %w[create delete], %w[show hide],
|
|
23
|
+
%w[lock unlock], %w[mount unmount], %w[register unregister],
|
|
24
|
+
%w[encrypt decrypt], %w[compress decompress],
|
|
25
|
+
%w[serialize deserialize], %w[connect disconnect],
|
|
26
|
+
%w[attach detach], %w[subscribe unsubscribe]
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Phase 2a: フレームワークConcern/標準ライブラリモジュール
|
|
30
|
+
# これらのincludeはペナルティを緩和する
|
|
31
|
+
FRAMEWORK_MODULES = %w[
|
|
32
|
+
ActiveModel::Validations ActiveModel::Callbacks
|
|
33
|
+
ActiveModel::Dirty ActiveModel::Serialization
|
|
34
|
+
ActiveModel::Model ActiveModel::Attributes
|
|
35
|
+
ActiveSupport::Concern ActiveSupport::Callbacks
|
|
36
|
+
Comparable Enumerable Singleton
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def analyze(class_info)
|
|
40
|
+
public_methods = class_info.public_methods_list
|
|
41
|
+
return 100 if public_methods.empty?
|
|
42
|
+
|
|
43
|
+
score = public_method_score(effective_public_method_count(class_info))
|
|
44
|
+
score -= include_penalty(class_info)
|
|
45
|
+
score -= cohesion_penalty(class_info)
|
|
46
|
+
score = mitigate_inspection(score, class_info)
|
|
47
|
+
|
|
48
|
+
clamp_score(score)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def effective_public_method_count(class_info)
|
|
54
|
+
names = class_info.public_methods_list.map { |m| m.name.to_s }
|
|
55
|
+
names.size - symmetric_pair_count(names)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def symmetric_pair_count(names)
|
|
59
|
+
SYMMETRIC_PREFIXES.sum do |a, b|
|
|
60
|
+
# Regexp.escape so that future user-supplied prefixes (via
|
|
61
|
+
# .solid-score.yml) cannot inject unintended regex syntax.
|
|
62
|
+
a_re = /\A#{Regexp.escape(a)}_/
|
|
63
|
+
b_re = /\A#{Regexp.escape(b)}_/
|
|
64
|
+
a_suffixes = names.grep(a_re).map { |n| n.sub(a_re, "") }
|
|
65
|
+
b_suffixes = names.grep(b_re).map { |n| n.sub(b_re, "") }
|
|
66
|
+
(a_suffixes & b_suffixes).size
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def public_method_score(count)
|
|
71
|
+
return PUBLIC_METHOD_SCORE_CEILING if count <= PUBLIC_METHOD_FREE_COUNT
|
|
72
|
+
return PUBLIC_METHOD_SCORE_FLOOR if count >= PUBLIC_METHOD_FLOOR_COUNT
|
|
73
|
+
|
|
74
|
+
PUBLIC_METHOD_SCORE_CEILING - ((count - PUBLIC_METHOD_FREE_COUNT) * PUBLIC_METHOD_SLOPE)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Phase 2a: フレームワークConcernを区別してペナルティを計算
|
|
78
|
+
def include_penalty(class_info)
|
|
79
|
+
all_includes = class_info.includes + class_info.extends
|
|
80
|
+
custom_count = all_includes.count { |mod| !framework_module?(mod) }
|
|
81
|
+
framework_count = all_includes.size - custom_count
|
|
82
|
+
|
|
83
|
+
# カスタムモジュールはフルペナルティ、フレームワークモジュールは半減
|
|
84
|
+
penalty = custom_penalty(custom_count) + framework_penalty(framework_count)
|
|
85
|
+
[penalty, 20].min
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def custom_penalty(count)
|
|
89
|
+
if count >= 7
|
|
90
|
+
20
|
|
91
|
+
elsif count >= 4
|
|
92
|
+
10
|
|
93
|
+
else
|
|
94
|
+
0
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def framework_penalty(count)
|
|
99
|
+
if count >= 7
|
|
100
|
+
10
|
|
101
|
+
elsif count >= 4
|
|
102
|
+
5
|
|
103
|
+
else
|
|
104
|
+
0
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def framework_module?(module_name)
|
|
109
|
+
return false if module_name.nil?
|
|
110
|
+
|
|
111
|
+
FRAMEWORK_MODULES.any? do |fm|
|
|
112
|
+
module_name == fm || module_name.end_with?("::#{fm}")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def cohesion_penalty(class_info)
|
|
117
|
+
public_methods = class_info.public_methods_list
|
|
118
|
+
return 0 if public_methods.size <= 2
|
|
119
|
+
|
|
120
|
+
srp = SrpAnalyzer.new
|
|
121
|
+
public_only_class = Models::ClassInfo.new(
|
|
122
|
+
name: class_info.name,
|
|
123
|
+
methods: public_methods
|
|
124
|
+
)
|
|
125
|
+
lcom4 = srp.calculate_lcom4(public_only_class)
|
|
126
|
+
|
|
127
|
+
lcom4 > 2 ? 15 : 0
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Analyzes Liskov Substitution Principle compliance.
|
|
6
|
+
#
|
|
7
|
+
# LSP違反の静的に検出可能な真のシグナルは「サブクラスが親の契約を破る
|
|
8
|
+
# 例外を投げる(NotImplementedError 以外の raise)」こと。
|
|
9
|
+
#
|
|
10
|
+
# 旧実装は「super を呼ばないメソッドごとに減点(no_super_penalty)」も
|
|
11
|
+
# していたが、静的解析では「親をオーバーライドしたメソッド」と「子で
|
|
12
|
+
# 新規追加しただけのメソッド」を区別できないため、Visitor/Adapter
|
|
13
|
+
# パターンの完全オーバーライドや、単に独自メソッドを多く持つだけの
|
|
14
|
+
# 継承クラスまで一律に減点していた(実測: LSP<100 の継承クラスの84%が
|
|
15
|
+
# この誤検出由来)。super を呼ばないこと自体は LSP 違反ではないため、
|
|
16
|
+
# このペナルティは廃止した。
|
|
17
|
+
#
|
|
18
|
+
# 既知の限界:
|
|
19
|
+
# 現在の判定軸は extra_raise(契約破りの例外)1軸のみで、契約破りの
|
|
20
|
+
# raise を含まない継承クラスは一律 100 点になる。これは「誤検出ゼロを
|
|
21
|
+
# 優先した意図的な縮退」であり、LSP の重み(0.10)が低いことと併せて
|
|
22
|
+
# 許容している。AST だけでは「親メソッドの存在」「事前/事後条件」
|
|
23
|
+
# 「戻り値の共変/反変」を確認できないため、これらの検出軸を追加するには
|
|
24
|
+
# クロスファイル/実行時の型情報が必要になる。将来の拡張候補(共変戻り値・
|
|
25
|
+
# 事前条件強化・シグネチャ変更の検出など)は Issue #24 を参照。
|
|
26
|
+
class LspAnalyzer < BaseAnalyzer
|
|
27
|
+
SIGNATURE_CHANGE_PENALTY = 20
|
|
28
|
+
EXTRA_RAISE_PENALTY = 15
|
|
29
|
+
|
|
30
|
+
def analyze(class_info)
|
|
31
|
+
return 100 unless class_info.has_superclass?
|
|
32
|
+
|
|
33
|
+
score = 100.0
|
|
34
|
+
|
|
35
|
+
class_info.methods.each do |method|
|
|
36
|
+
next if method.name == :initialize
|
|
37
|
+
|
|
38
|
+
score -= extra_raise_penalty(method)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
clamp_score(score)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def extra_raise_penalty(method)
|
|
47
|
+
standard_raises = ["NotImplementedError"]
|
|
48
|
+
extra_raises = method.raises.reject { |r| standard_raises.include?(r) }
|
|
49
|
+
|
|
50
|
+
extra_raises.any? ? EXTRA_RAISE_PENALTY : 0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Shared score-mitigation helpers used by multiple analyzers.
|
|
6
|
+
module Mitigations
|
|
7
|
+
INSPECTION_MIN_SCORE = 80
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Issue #9: floor inspection / diagnostic helpers at INSPECTION_MIN_SCORE.
|
|
12
|
+
def mitigate_inspection(score, class_info)
|
|
13
|
+
return score unless class_info.inspection_class?
|
|
14
|
+
|
|
15
|
+
[score, INSPECTION_MIN_SCORE].max
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Analyzes Open/Closed Principle compliance.
|
|
6
|
+
#
|
|
7
|
+
# Phase 1 改善: case/when 分岐へのペナルティ追加
|
|
8
|
+
# Phase 2b 改善: respond_to? を弱い型チェックとして追加
|
|
9
|
+
class OcpAnalyzer < BaseAnalyzer
|
|
10
|
+
# 強い型チェック: 直接的な型判定 → 10点/回
|
|
11
|
+
STRONG_TYPE_CHECK_METHODS = %i[is_a? kind_of? instance_of?].freeze
|
|
12
|
+
# 弱い型チェック: ダックタイピング分岐 → 5点/回
|
|
13
|
+
WEAK_TYPE_CHECK_METHODS = %i[respond_to?].freeze
|
|
14
|
+
|
|
15
|
+
MAX_TYPE_CHECK_PENALTY = 40
|
|
16
|
+
MAX_EXTENSION_BONUS = 20
|
|
17
|
+
|
|
18
|
+
CASE_WHEN_PENALTY_PER_BRANCH = 5
|
|
19
|
+
MAX_CASE_WHEN_PENALTY = 30
|
|
20
|
+
|
|
21
|
+
def analyze(class_info)
|
|
22
|
+
return 100 if class_info.methods.empty?
|
|
23
|
+
|
|
24
|
+
score = 100.0
|
|
25
|
+
|
|
26
|
+
score -= conditional_density_penalty(class_info)
|
|
27
|
+
score -= type_check_penalty(class_info)
|
|
28
|
+
score -= case_when_penalty(class_info) # Phase 1 改善
|
|
29
|
+
score += extension_point_bonus(class_info)
|
|
30
|
+
|
|
31
|
+
clamp_score(score)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def conditional_density_penalty(class_info)
|
|
37
|
+
method_count = class_info.methods.size.to_f
|
|
38
|
+
return 0 if method_count.zero?
|
|
39
|
+
|
|
40
|
+
branch_count = class_info.methods.sum { |m| m.cyclomatic_complexity - 1 }
|
|
41
|
+
density = branch_count / method_count
|
|
42
|
+
|
|
43
|
+
if density > 1.0
|
|
44
|
+
40
|
|
45
|
+
elsif density > 0.5
|
|
46
|
+
20
|
|
47
|
+
else
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Phase 2b: 強い型チェックと弱い型チェックで差別化
|
|
53
|
+
def type_check_penalty(class_info)
|
|
54
|
+
strong_checks = count_type_checks(class_info, STRONG_TYPE_CHECK_METHODS)
|
|
55
|
+
weak_checks = count_type_checks(class_info, WEAK_TYPE_CHECK_METHODS)
|
|
56
|
+
|
|
57
|
+
[strong_checks * 10 + weak_checks * 5, MAX_TYPE_CHECK_PENALTY].min
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def count_type_checks(class_info, check_methods)
|
|
61
|
+
class_info.methods.sum do |method|
|
|
62
|
+
method.called_methods.count { |m| check_methods.include?(m) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Issue #11: 2-way and 3-way case/when leniency.
|
|
67
|
+
# 1 when-clause → 2pt (2-way branch, often a minor domain conditional)
|
|
68
|
+
# 2 when-clauses → 5pt (3-way branch, still acceptable)
|
|
69
|
+
# 3+ when-clauses → n * 5pt (linear, real OCP smell)
|
|
70
|
+
#
|
|
71
|
+
# Note on the per-method shape: in earlier versions the score was driven
|
|
72
|
+
# by a class-wide pool of `when` clauses. With the per-method curve, a
|
|
73
|
+
# class containing many small 2-way conditionals will now score better
|
|
74
|
+
# than before, but the class-level cap (MAX_CASE_WHEN_PENALTY) still
|
|
75
|
+
# caps the contribution. This trade-off is intentional — wide n-way
|
|
76
|
+
# switches are still penalised heavily.
|
|
77
|
+
def case_when_penalty(class_info)
|
|
78
|
+
raw = class_info.methods.sum { |m| case_when_penalty_for_count(m.case_when_count) }
|
|
79
|
+
[raw, MAX_CASE_WHEN_PENALTY].min
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def case_when_penalty_for_count(when_count)
|
|
83
|
+
case when_count
|
|
84
|
+
when 0 then 0
|
|
85
|
+
when 1 then 2
|
|
86
|
+
when 2 then 5
|
|
87
|
+
else when_count * CASE_WHEN_PENALTY_PER_BRANCH
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def extension_point_bonus(class_info)
|
|
92
|
+
extension_points = class_info.methods.count do |method|
|
|
93
|
+
method.raises.include?("NotImplementedError")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
has_block_params = class_info.methods.count do |method|
|
|
97
|
+
method.parameters.any? { |type, _| type == :block }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
[(extension_points + has_block_params) * 10, MAX_EXTENSION_BONUS].min
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|