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,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