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,451 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/current"
|
|
4
|
+
|
|
5
|
+
module SolidScore
|
|
6
|
+
module Parser
|
|
7
|
+
# Parses Ruby source files and extracts class/method information.
|
|
8
|
+
#
|
|
9
|
+
# Phase 1 改善:
|
|
10
|
+
# - MethodCallInfo によるレシーバ情報の収集
|
|
11
|
+
# - case/when 分岐数のカウント
|
|
12
|
+
#
|
|
13
|
+
# Phase 2a 改善:
|
|
14
|
+
# - クラスメソッド (def self.xxx) の解析対応
|
|
15
|
+
# - モジュール (module) の解析対応
|
|
16
|
+
# - ネストしたクラス/モジュール対応
|
|
17
|
+
# - Rails DSL認識
|
|
18
|
+
class RubyParser
|
|
19
|
+
# Rails/ActiveSupport DSLメソッド
|
|
20
|
+
# これらはメソッド定義ではなくDSL宣言として扱う
|
|
21
|
+
RAILS_DSL_METHODS = %i[
|
|
22
|
+
has_many has_one belongs_to has_and_belongs_to_many
|
|
23
|
+
validates validate validates_presence_of validates_uniqueness_of
|
|
24
|
+
validates_format_of validates_length_of validates_numericality_of
|
|
25
|
+
validates_inclusion_of validates_exclusion_of validates_associated
|
|
26
|
+
before_action after_action around_action skip_before_action
|
|
27
|
+
before_filter after_filter around_filter
|
|
28
|
+
before_validation after_validation
|
|
29
|
+
before_create after_create before_update after_update
|
|
30
|
+
before_save after_save before_destroy after_destroy
|
|
31
|
+
after_commit after_rollback
|
|
32
|
+
scope enum delegate
|
|
33
|
+
serialize store
|
|
34
|
+
accepts_nested_attributes_for
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
def parse_file(file_path)
|
|
38
|
+
source = File.read(file_path)
|
|
39
|
+
ast = ::Parser::CurrentRuby.parse(source)
|
|
40
|
+
return [] unless ast
|
|
41
|
+
|
|
42
|
+
extract_definitions(ast, file_path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Phase 2a: クラスとモジュールの両方を抽出し、ネストにも対応
|
|
48
|
+
def extract_definitions(node, file_path, classes = [], namespace = nil)
|
|
49
|
+
return classes unless node.is_a?(::AST::Node)
|
|
50
|
+
|
|
51
|
+
case node.type
|
|
52
|
+
when :class
|
|
53
|
+
class_info = build_class_info(node, file_path, namespace)
|
|
54
|
+
classes << class_info
|
|
55
|
+
# ネストしたクラス/モジュールを探索
|
|
56
|
+
extract_nested_definitions(node.children[2], file_path, classes, class_info.name)
|
|
57
|
+
when :module
|
|
58
|
+
module_info = build_module_info(node, file_path, namespace)
|
|
59
|
+
classes << module_info
|
|
60
|
+
# モジュール内のネストしたクラス/モジュールを探索
|
|
61
|
+
extract_nested_definitions(node.children[1], file_path, classes, module_info.name)
|
|
62
|
+
else
|
|
63
|
+
node.children.each { |child| extract_definitions(child, file_path, classes, namespace) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
classes
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ネストしたクラス/モジュールを探索(親のbodyから)
|
|
70
|
+
def extract_nested_definitions(body, file_path, classes, parent_name)
|
|
71
|
+
return unless body.is_a?(::AST::Node)
|
|
72
|
+
|
|
73
|
+
nodes = body.type == :begin ? body.children : [body]
|
|
74
|
+
nodes.each do |child|
|
|
75
|
+
next unless child.is_a?(::AST::Node)
|
|
76
|
+
|
|
77
|
+
case child.type
|
|
78
|
+
when :class, :module
|
|
79
|
+
extract_definitions(child, file_path, classes, parent_name)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_class_info(node, file_path, namespace = nil)
|
|
85
|
+
raw_name = extract_class_name(node.children[0])
|
|
86
|
+
name = namespace ? "#{namespace}::#{raw_name}" : raw_name
|
|
87
|
+
superclass = node.children[1] ? extract_class_name(node.children[1]) : nil
|
|
88
|
+
body = node.children[2]
|
|
89
|
+
|
|
90
|
+
build_definition_info(name, file_path, node, body, superclass: superclass, kind: :class)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Phase 2a: モジュール情報を構築
|
|
94
|
+
def build_module_info(node, file_path, namespace = nil)
|
|
95
|
+
raw_name = extract_class_name(node.children[0])
|
|
96
|
+
name = namespace ? "#{namespace}::#{raw_name}" : raw_name
|
|
97
|
+
body = node.children[1]
|
|
98
|
+
|
|
99
|
+
build_definition_info(name, file_path, node, body, kind: :module)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# クラス/モジュール共通の情報構築
|
|
103
|
+
def build_definition_info(name, file_path, node, body, superclass: nil, kind: :class)
|
|
104
|
+
methods = []
|
|
105
|
+
includes = []
|
|
106
|
+
extends = []
|
|
107
|
+
attr_readers = []
|
|
108
|
+
attr_writers = []
|
|
109
|
+
dsl_calls = []
|
|
110
|
+
current_visibility = :public
|
|
111
|
+
|
|
112
|
+
if body
|
|
113
|
+
traverse_body(body, methods, includes, extends, attr_readers, attr_writers,
|
|
114
|
+
dsl_calls, current_visibility)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
instance_variables = methods.flat_map(&:instance_variables).uniq
|
|
118
|
+
|
|
119
|
+
Models::ClassInfo.new(
|
|
120
|
+
name: name,
|
|
121
|
+
file_path: file_path,
|
|
122
|
+
line_start: node.loc.line,
|
|
123
|
+
line_end: node.loc.last_line,
|
|
124
|
+
methods: methods,
|
|
125
|
+
superclass: superclass,
|
|
126
|
+
includes: includes,
|
|
127
|
+
extends: extends,
|
|
128
|
+
instance_variables: instance_variables,
|
|
129
|
+
attr_readers: attr_readers,
|
|
130
|
+
attr_writers: attr_writers,
|
|
131
|
+
kind: kind,
|
|
132
|
+
dsl_calls: dsl_calls
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def traverse_body(node, methods, includes, extends, attr_readers, attr_writers,
|
|
137
|
+
dsl_calls, current_visibility)
|
|
138
|
+
return unless node.is_a?(::AST::Node)
|
|
139
|
+
|
|
140
|
+
case node.type
|
|
141
|
+
when :begin
|
|
142
|
+
node.children.each do |child|
|
|
143
|
+
current_visibility = traverse_body(child, methods, includes, extends,
|
|
144
|
+
attr_readers, attr_writers,
|
|
145
|
+
dsl_calls, current_visibility)
|
|
146
|
+
end
|
|
147
|
+
when :def
|
|
148
|
+
methods << build_method_info(node, current_visibility, kind: :instance)
|
|
149
|
+
when :defs
|
|
150
|
+
# Phase 2a: クラスメソッド (def self.xxx)
|
|
151
|
+
# :defs ノード構造: [receiver, name, args, body]
|
|
152
|
+
methods << build_method_info(node, :public, kind: :class)
|
|
153
|
+
when :send
|
|
154
|
+
current_visibility = handle_send_node(node, current_visibility, includes, extends,
|
|
155
|
+
attr_readers, attr_writers, dsl_calls)
|
|
156
|
+
when :class, :module
|
|
157
|
+
# ネストしたクラス/モジュールはtraverse_bodyではスキップ
|
|
158
|
+
# extract_nested_definitionsで別途処理する
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
current_visibility
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle_send_node(node, current_visibility, includes, extends,
|
|
165
|
+
attr_readers, attr_writers, dsl_calls)
|
|
166
|
+
method_name = node.children[1]
|
|
167
|
+
|
|
168
|
+
case method_name
|
|
169
|
+
when :private, :protected, :public
|
|
170
|
+
method_name
|
|
171
|
+
when :include
|
|
172
|
+
includes << extract_class_name(node.children[2]) if node.children[2]
|
|
173
|
+
current_visibility
|
|
174
|
+
when :extend
|
|
175
|
+
extends << extract_class_name(node.children[2]) if node.children[2]
|
|
176
|
+
current_visibility
|
|
177
|
+
when :attr_reader
|
|
178
|
+
node.children[2..].each { |arg| attr_readers << arg.children[0] if arg.type == :sym }
|
|
179
|
+
current_visibility
|
|
180
|
+
when :attr_accessor
|
|
181
|
+
node.children[2..].each do |arg|
|
|
182
|
+
next unless arg.type == :sym
|
|
183
|
+
|
|
184
|
+
attr_readers << arg.children[0]
|
|
185
|
+
attr_writers << arg.children[0]
|
|
186
|
+
end
|
|
187
|
+
current_visibility
|
|
188
|
+
when :attr_writer
|
|
189
|
+
node.children[2..].each { |arg| attr_writers << arg.children[0] if arg.type == :sym }
|
|
190
|
+
current_visibility
|
|
191
|
+
else
|
|
192
|
+
# Phase 2a: Rails DSL認識
|
|
193
|
+
if RAILS_DSL_METHODS.include?(method_name)
|
|
194
|
+
dsl_calls << method_name
|
|
195
|
+
end
|
|
196
|
+
current_visibility
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Issue #12: factory-style method names that count as instantiation.
|
|
201
|
+
# Mirrors DipAnalyzer::FACTORY_METHODS so the bonus tracks the same set
|
|
202
|
+
# of patterns that are penalised as concrete dependencies.
|
|
203
|
+
MEMOIZED_FACTORY_METHODS = %i[new create build call open].freeze
|
|
204
|
+
|
|
205
|
+
# Issue #10: Statement-like AST nodes that count toward the effective body size.
|
|
206
|
+
#
|
|
207
|
+
# Notes on what we *don't* count and why:
|
|
208
|
+
# - :begin / :rescue / :ensure are sequence wrappers (their handler bodies
|
|
209
|
+
# are counted via their children).
|
|
210
|
+
# - :when is a child of :case and would double-count the dispatch.
|
|
211
|
+
# - :ivar / :lvar are lookups, not statements.
|
|
212
|
+
#
|
|
213
|
+
# Notes on what we *do* count:
|
|
214
|
+
# - :send and :csend (safe-navigation) treat method calls as a statement;
|
|
215
|
+
# chained calls intentionally accumulate because each step is observable
|
|
216
|
+
# behaviour.
|
|
217
|
+
# - :masgn (multiple assignment) joins the explicit assignment forms.
|
|
218
|
+
EFFECTIVE_STATEMENT_TYPES = %i[
|
|
219
|
+
send csend
|
|
220
|
+
if case return
|
|
221
|
+
ivasgn lvasgn gvasgn cvasgn masgn
|
|
222
|
+
and_asgn or_asgn op_asgn
|
|
223
|
+
yield block
|
|
224
|
+
while until for
|
|
225
|
+
next break retry redo super zsuper
|
|
226
|
+
].freeze
|
|
227
|
+
|
|
228
|
+
# Nested method/lambda boundaries — count_effective_statements stops at
|
|
229
|
+
# these so that statements inside an inner lambda don't leak into the
|
|
230
|
+
# outer method's count.
|
|
231
|
+
NESTED_DEFINITION_TYPES = %i[def defs].freeze
|
|
232
|
+
|
|
233
|
+
# :def ノード構造: [name, args, body]
|
|
234
|
+
# :defs ノード構造: [receiver, name, args, body]
|
|
235
|
+
def build_method_info(node, visibility, kind: :instance)
|
|
236
|
+
name, args, body = extract_method_parts(node, kind)
|
|
237
|
+
|
|
238
|
+
instance_vars = []
|
|
239
|
+
called_methods = []
|
|
240
|
+
raises = []
|
|
241
|
+
method_calls = []
|
|
242
|
+
calls_super = false
|
|
243
|
+
case_when_count = 0
|
|
244
|
+
memoized_receiver = nil
|
|
245
|
+
effective_count = 0
|
|
246
|
+
|
|
247
|
+
if body
|
|
248
|
+
collect_method_details(body, instance_vars, called_methods, raises, method_calls)
|
|
249
|
+
calls_super = contains_super?(body)
|
|
250
|
+
case_when_count = count_case_when_branches(body)
|
|
251
|
+
memoized_receiver = detect_memoized_factory_receiver(body)
|
|
252
|
+
effective_count = count_effective_statements(body)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
parameters = extract_parameters(args)
|
|
256
|
+
complexity = calculate_cyclomatic_complexity(body)
|
|
257
|
+
|
|
258
|
+
Models::MethodInfo.new(
|
|
259
|
+
name: name,
|
|
260
|
+
visibility: visibility,
|
|
261
|
+
line_start: node.loc.line,
|
|
262
|
+
line_end: node.loc.last_line,
|
|
263
|
+
instance_variables: instance_vars.uniq,
|
|
264
|
+
called_methods: called_methods.uniq,
|
|
265
|
+
parameters: parameters,
|
|
266
|
+
cyclomatic_complexity: complexity,
|
|
267
|
+
raises: raises,
|
|
268
|
+
calls_super: calls_super,
|
|
269
|
+
method_calls: method_calls,
|
|
270
|
+
case_when_count: case_when_count,
|
|
271
|
+
kind: kind,
|
|
272
|
+
effective_statement_count: effective_count,
|
|
273
|
+
memoized_factory_receiver: memoized_receiver
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Issue #12: Detects the `@ivar ||= ConstantClass.new(...)` memoised
|
|
278
|
+
# factory pattern (and the broader factory-method variants such as
|
|
279
|
+
# `.create`, `.build`, `.call`, `.open`). Returns the constant's
|
|
280
|
+
# qualified name (e.g. "ProvisioningService", "Foo::Bar") so the
|
|
281
|
+
# analyzer can apply the standard-library whitelist consistently
|
|
282
|
+
# with the concrete-dependency penalty path.
|
|
283
|
+
def detect_memoized_factory_receiver(node)
|
|
284
|
+
return nil unless node.is_a?(::AST::Node)
|
|
285
|
+
|
|
286
|
+
receiver = memoized_factory_receiver_for(node)
|
|
287
|
+
return receiver if receiver
|
|
288
|
+
|
|
289
|
+
node.children.each do |child|
|
|
290
|
+
found = detect_memoized_factory_receiver(child)
|
|
291
|
+
return found if found
|
|
292
|
+
end
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def memoized_factory_receiver_for(node)
|
|
297
|
+
return nil unless node.type == :or_asgn
|
|
298
|
+
|
|
299
|
+
lhs, rhs = node.children
|
|
300
|
+
return nil unless lhs.is_a?(::AST::Node) && lhs.type == :ivasgn
|
|
301
|
+
return nil unless rhs.is_a?(::AST::Node) && rhs.type == :send
|
|
302
|
+
|
|
303
|
+
receiver, method_name = rhs.children[0], rhs.children[1]
|
|
304
|
+
return nil unless receiver.is_a?(::AST::Node) && receiver.type == :const
|
|
305
|
+
return nil unless MEMOIZED_FACTORY_METHODS.include?(method_name)
|
|
306
|
+
|
|
307
|
+
extract_class_name(receiver)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def count_effective_statements(node, count = 0, root: true)
|
|
311
|
+
return count unless node.is_a?(::AST::Node)
|
|
312
|
+
# Don't recurse into nested method/lambda definitions when counting
|
|
313
|
+
# the parent body. The nested definition's own effective count is
|
|
314
|
+
# tracked separately by build_method_info.
|
|
315
|
+
return count if !root && NESTED_DEFINITION_TYPES.include?(node.type)
|
|
316
|
+
|
|
317
|
+
count += 1 if EFFECTIVE_STATEMENT_TYPES.include?(node.type)
|
|
318
|
+
node.children.each { |child| count = count_effective_statements(child, count, root: false) }
|
|
319
|
+
count
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def extract_method_parts(node, kind)
|
|
323
|
+
case kind
|
|
324
|
+
when :class
|
|
325
|
+
[node.children[1], node.children[2], node.children[3]]
|
|
326
|
+
else
|
|
327
|
+
[node.children[0], node.children[1], node.children[2]]
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Phase 1 改善: レシーバ情報を含むメソッド呼び出し情報を収集
|
|
332
|
+
def collect_method_details(node, instance_vars, called_methods, raises, method_calls)
|
|
333
|
+
return unless node.is_a?(::AST::Node)
|
|
334
|
+
|
|
335
|
+
case node.type
|
|
336
|
+
when :ivar, :ivasgn
|
|
337
|
+
instance_vars << node.children[0]
|
|
338
|
+
when :send
|
|
339
|
+
receiver_node = node.children[0]
|
|
340
|
+
method_name = node.children[1]
|
|
341
|
+
|
|
342
|
+
called_methods << method_name
|
|
343
|
+
|
|
344
|
+
# Collect detailed method call info with receiver
|
|
345
|
+
method_calls << build_method_call_info(receiver_node, method_name)
|
|
346
|
+
|
|
347
|
+
if %i[raise fail].include?(method_name)
|
|
348
|
+
raise_class = node.children[2]
|
|
349
|
+
raises << extract_class_name(raise_class) if raise_class.is_a?(::AST::Node) && raise_class.type == :const
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
node.children.each do |child|
|
|
354
|
+
collect_method_details(child, instance_vars, called_methods, raises, method_calls)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Phase 1 改善: メソッド呼び出し情報を構築
|
|
359
|
+
def build_method_call_info(receiver_node, method_name)
|
|
360
|
+
receiver, receiver_type = extract_receiver_info(receiver_node)
|
|
361
|
+
|
|
362
|
+
Models::MethodCallInfo.new(
|
|
363
|
+
method_name: method_name,
|
|
364
|
+
receiver: receiver,
|
|
365
|
+
receiver_type: receiver_type
|
|
366
|
+
)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Phase 1 改善: レシーバ情報を抽出
|
|
370
|
+
def extract_receiver_info(node)
|
|
371
|
+
return [nil, :self] if node.nil?
|
|
372
|
+
return [nil, :unknown] unless node.is_a?(::AST::Node)
|
|
373
|
+
|
|
374
|
+
case node.type
|
|
375
|
+
when :const
|
|
376
|
+
[extract_class_name(node), :const]
|
|
377
|
+
when :ivar
|
|
378
|
+
[node.children[0].to_s, :ivar]
|
|
379
|
+
when :lvar
|
|
380
|
+
[node.children[0].to_s, :lvar]
|
|
381
|
+
when :self
|
|
382
|
+
[nil, :self]
|
|
383
|
+
when :send
|
|
384
|
+
# Chained method call (e.g., foo.bar.baz)
|
|
385
|
+
[nil, :send]
|
|
386
|
+
else
|
|
387
|
+
[nil, :unknown]
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Phase 1 改善: case/when 分岐数をカウント
|
|
392
|
+
def count_case_when_branches(node, count = 0)
|
|
393
|
+
return count unless node.is_a?(::AST::Node)
|
|
394
|
+
|
|
395
|
+
if node.type == :case
|
|
396
|
+
when_count = node.children.count { |child| child.is_a?(::AST::Node) && child.type == :when }
|
|
397
|
+
count += when_count
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
node.children.each do |child|
|
|
401
|
+
count = count_case_when_branches(child, count)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
count
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def contains_super?(node)
|
|
408
|
+
return false unless node.is_a?(::AST::Node)
|
|
409
|
+
return true if %i[super zsuper].include?(node.type)
|
|
410
|
+
|
|
411
|
+
node.children.any? { |child| contains_super?(child) }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def extract_parameters(args_node)
|
|
415
|
+
return [] unless args_node
|
|
416
|
+
|
|
417
|
+
args_node.children.map do |arg|
|
|
418
|
+
[arg.type, arg.children[0]]
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def calculate_cyclomatic_complexity(node, complexity = 1)
|
|
423
|
+
return complexity unless node.is_a?(::AST::Node)
|
|
424
|
+
|
|
425
|
+
case node.type
|
|
426
|
+
when :if, :while, :until, :for, :when, :and, :or, :rescue
|
|
427
|
+
complexity += 1
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
node.children.each do |child|
|
|
431
|
+
complexity = calculate_cyclomatic_complexity(child, complexity)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
complexity
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def extract_class_name(node)
|
|
438
|
+
return nil unless node.is_a?(::AST::Node)
|
|
439
|
+
|
|
440
|
+
case node.type
|
|
441
|
+
when :const
|
|
442
|
+
parent = node.children[0]
|
|
443
|
+
name = node.children[1].to_s
|
|
444
|
+
parent ? "#{extract_class_name(parent)}::#{name}" : name
|
|
445
|
+
else
|
|
446
|
+
node.to_s
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Presets
|
|
5
|
+
RAILS = {
|
|
6
|
+
paths: %w[
|
|
7
|
+
app/models
|
|
8
|
+
app/controllers
|
|
9
|
+
app/services
|
|
10
|
+
app/jobs
|
|
11
|
+
app/mailers
|
|
12
|
+
app/forms
|
|
13
|
+
app/presenters
|
|
14
|
+
app/serializers
|
|
15
|
+
lib
|
|
16
|
+
],
|
|
17
|
+
exclude: %w[
|
|
18
|
+
spec/**
|
|
19
|
+
test/**
|
|
20
|
+
vendor/**
|
|
21
|
+
db/**
|
|
22
|
+
tmp/**
|
|
23
|
+
],
|
|
24
|
+
weights: {
|
|
25
|
+
srp: 0.25,
|
|
26
|
+
ocp: 0.15,
|
|
27
|
+
lsp: 0.10,
|
|
28
|
+
isp: 0.20,
|
|
29
|
+
dip: 0.30
|
|
30
|
+
},
|
|
31
|
+
dip_whitelist: %w[
|
|
32
|
+
Rails
|
|
33
|
+
Logger
|
|
34
|
+
FileUtils
|
|
35
|
+
Pathname
|
|
36
|
+
URI
|
|
37
|
+
JSON
|
|
38
|
+
CSV
|
|
39
|
+
Net::HTTP
|
|
40
|
+
OpenSSL
|
|
41
|
+
Digest
|
|
42
|
+
SecureRandom
|
|
43
|
+
ERB
|
|
44
|
+
ActionMailer
|
|
45
|
+
ActiveJob
|
|
46
|
+
ActiveStorage
|
|
47
|
+
]
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
REGISTRY = {
|
|
51
|
+
"rails" => RAILS
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
def self.fetch(name)
|
|
55
|
+
REGISTRY.fetch(name.to_s) do
|
|
56
|
+
raise ArgumentError, "Unknown preset: #{name}. Available: #{REGISTRY.keys.join(', ')}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.available
|
|
61
|
+
REGISTRY.keys
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
class Runner
|
|
5
|
+
attr_reader :results
|
|
6
|
+
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = config
|
|
9
|
+
@parser = Parser::RubyParser.new
|
|
10
|
+
@scorer = Scorer.new(weights: config.weights, dip_whitelist: config.dip_whitelist)
|
|
11
|
+
@results = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
files = collect_files
|
|
16
|
+
classes = files.flat_map { |f| parse_file(f) }
|
|
17
|
+
@results = @scorer.score_all(classes)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def passing?
|
|
21
|
+
return true if @results.empty?
|
|
22
|
+
|
|
23
|
+
@results.all? { |r| meets_thresholds?(r) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def formatted_output
|
|
27
|
+
formatter = build_formatter
|
|
28
|
+
formatter.format(@results)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def collect_files
|
|
34
|
+
files = @config.paths.flat_map do |path|
|
|
35
|
+
if File.file?(path)
|
|
36
|
+
[path]
|
|
37
|
+
else
|
|
38
|
+
Dir.glob(File.join(path, "**", "*.rb"))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
files.reject { |f| excluded?(f) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def excluded?(file)
|
|
46
|
+
@config.exclude.any? do |pattern|
|
|
47
|
+
# **/dir/** パターンは部分一致で処理(File.fnmatch?は絶対パスで非対応)
|
|
48
|
+
if pattern.start_with?("**/") && pattern.end_with?("/**")
|
|
49
|
+
dir = pattern.delete_prefix("**/").delete_suffix("/**")
|
|
50
|
+
file.include?("/#{dir}/")
|
|
51
|
+
else
|
|
52
|
+
File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_file(file)
|
|
58
|
+
@parser.parse_file(file)
|
|
59
|
+
rescue ::Parser::SyntaxError
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def meets_thresholds?(result)
|
|
64
|
+
return false if result.total < @config.thresholds[:total]
|
|
65
|
+
|
|
66
|
+
%i[srp ocp lsp isp dip].all? do |principle|
|
|
67
|
+
result.send(principle) >= @config.thresholds[principle]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_formatter
|
|
72
|
+
case @config.format
|
|
73
|
+
when :json
|
|
74
|
+
Formatters::JsonFormatter.new
|
|
75
|
+
when :html
|
|
76
|
+
Formatters::HtmlFormatter.new
|
|
77
|
+
else
|
|
78
|
+
Formatters::TextFormatter.new
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
class Scorer
|
|
5
|
+
def initialize(weights: Models::ScoreResult::DEFAULT_WEIGHTS, dip_whitelist: [])
|
|
6
|
+
@weights = weights
|
|
7
|
+
@srp_analyzer = Analyzers::SrpAnalyzer.new
|
|
8
|
+
@ocp_analyzer = Analyzers::OcpAnalyzer.new
|
|
9
|
+
@lsp_analyzer = Analyzers::LspAnalyzer.new
|
|
10
|
+
@isp_analyzer = Analyzers::IspAnalyzer.new
|
|
11
|
+
@dip_analyzer = Analyzers::DipAnalyzer.new(user_whitelist: dip_whitelist)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def score(class_info)
|
|
15
|
+
srp = @srp_analyzer.analyze_with_breakdown(class_info)
|
|
16
|
+
|
|
17
|
+
Models::ScoreResult.new(
|
|
18
|
+
class_name: class_info.name,
|
|
19
|
+
file_path: class_info.file_path,
|
|
20
|
+
srp: srp[:score],
|
|
21
|
+
ocp: @ocp_analyzer.analyze(class_info),
|
|
22
|
+
lsp: @lsp_analyzer.analyze(class_info),
|
|
23
|
+
isp: @isp_analyzer.analyze(class_info),
|
|
24
|
+
dip: @dip_analyzer.analyze(class_info),
|
|
25
|
+
weights: @weights,
|
|
26
|
+
class_info: class_info,
|
|
27
|
+
subscores: { srp: srp[:breakdown] }
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def score_all(class_infos)
|
|
32
|
+
class_infos.map { |ci| score(ci) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/solid_score.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "solid_score/version"
|
|
4
|
+
require_relative "solid_score/models/method_info"
|
|
5
|
+
require_relative "solid_score/models/class_info"
|
|
6
|
+
require_relative "solid_score/models/score_result"
|
|
7
|
+
require_relative "solid_score/parser/ruby_parser"
|
|
8
|
+
require_relative "solid_score/analyzers/base_analyzer"
|
|
9
|
+
require_relative "solid_score/analyzers/mitigations"
|
|
10
|
+
require_relative "solid_score/analyzers/srp_analyzer"
|
|
11
|
+
require_relative "solid_score/analyzers/ocp_analyzer"
|
|
12
|
+
require_relative "solid_score/analyzers/lsp_analyzer"
|
|
13
|
+
require_relative "solid_score/analyzers/isp_analyzer"
|
|
14
|
+
require_relative "solid_score/analyzers/dip_analyzer"
|
|
15
|
+
require_relative "solid_score/scorer"
|
|
16
|
+
require_relative "solid_score/presets"
|
|
17
|
+
require_relative "solid_score/configuration"
|
|
18
|
+
require_relative "solid_score/formatters/base_formatter"
|
|
19
|
+
require_relative "solid_score/formatters/text_formatter"
|
|
20
|
+
require_relative "solid_score/formatters/json_formatter"
|
|
21
|
+
require_relative "solid_score/formatters/html_formatter"
|
|
22
|
+
require_relative "solid_score/diff_analyzer"
|
|
23
|
+
require_relative "solid_score/diff_classifier"
|
|
24
|
+
require_relative "solid_score/runner"
|
|
25
|
+
require_relative "solid_score/cli"
|
|
26
|
+
|
|
27
|
+
module SolidScore
|
|
28
|
+
end
|