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,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Formatters
|
|
5
|
+
class HtmlFormatter < BaseFormatter
|
|
6
|
+
def format(results)
|
|
7
|
+
return empty_html if results.empty?
|
|
8
|
+
|
|
9
|
+
build_html(results)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def empty_html
|
|
15
|
+
build_page("No classes found to analyze.", "")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_html(results)
|
|
19
|
+
summary = build_summary(results)
|
|
20
|
+
chart = build_chart(results)
|
|
21
|
+
table = build_table(results)
|
|
22
|
+
|
|
23
|
+
build_page(summary + chart, table)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_page(header_content, body_content)
|
|
27
|
+
<<~HTML
|
|
28
|
+
<!DOCTYPE html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="utf-8">
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
33
|
+
<title>solid-score Report</title>
|
|
34
|
+
<style>#{css}</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div class="container">
|
|
38
|
+
<h1>solid-score Report <span class="version">v#{VERSION}</span></h1>
|
|
39
|
+
#{header_content}
|
|
40
|
+
#{body_content}
|
|
41
|
+
<footer>Generated by solid-score v#{VERSION}</footer>
|
|
42
|
+
</div>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
45
|
+
HTML
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_summary(results)
|
|
49
|
+
avg_total = (results.sum(&:total) / results.size).round(1)
|
|
50
|
+
avgs = %i[srp ocp lsp isp dip].map do |p|
|
|
51
|
+
[p, (results.sum(&p) / results.size).round(1)]
|
|
52
|
+
end.to_h
|
|
53
|
+
|
|
54
|
+
<<~HTML
|
|
55
|
+
<div class="summary">
|
|
56
|
+
<div class="summary-card summary-total">
|
|
57
|
+
<div class="summary-label">Average Score</div>
|
|
58
|
+
<div class="summary-value #{score_color_class(avg_total)}">#{avg_total}</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="summary-card">
|
|
61
|
+
<div class="summary-label">Classes Analyzed</div>
|
|
62
|
+
<div class="summary-value">#{results.size}</div>
|
|
63
|
+
</div>
|
|
64
|
+
#{avgs.map { |p, v| principle_card(p, v) }.join}
|
|
65
|
+
</div>
|
|
66
|
+
HTML
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def principle_card(principle, value)
|
|
70
|
+
<<~HTML
|
|
71
|
+
<div class="summary-card">
|
|
72
|
+
<div class="summary-label">#{principle.to_s.upcase}</div>
|
|
73
|
+
<div class="summary-value #{score_color_class(value)}">#{value}</div>
|
|
74
|
+
</div>
|
|
75
|
+
HTML
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_chart(results)
|
|
79
|
+
avgs = %i[srp ocp lsp isp dip].map do |p|
|
|
80
|
+
[p.to_s.upcase, (results.sum(&p) / results.size).round(1)]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
bar_height = 32
|
|
84
|
+
gap = 8
|
|
85
|
+
chart_height = avgs.size * (bar_height + gap) + 20
|
|
86
|
+
label_width = 40
|
|
87
|
+
chart_width = 500
|
|
88
|
+
|
|
89
|
+
bars = avgs.each_with_index.map do |(label, value), i|
|
|
90
|
+
y = i * (bar_height + gap) + 10
|
|
91
|
+
bar_w = [(value / 100.0 * (chart_width - label_width - 60)).round(1), 0].max
|
|
92
|
+
color = score_hex_color(value)
|
|
93
|
+
|
|
94
|
+
<<~SVG
|
|
95
|
+
<text x="#{label_width - 5}" y="#{y + bar_height / 2 + 5}" text-anchor="end" class="chart-label">#{label}</text>
|
|
96
|
+
<rect x="#{label_width}" y="#{y}" width="#{bar_w}" height="#{bar_height}" rx="4" fill="#{color}" />
|
|
97
|
+
<text x="#{label_width + bar_w + 5}" y="#{y + bar_height / 2 + 5}" class="chart-value">#{value}</text>
|
|
98
|
+
SVG
|
|
99
|
+
end.join
|
|
100
|
+
|
|
101
|
+
<<~HTML
|
|
102
|
+
<div class="chart-container">
|
|
103
|
+
<h2>Score Overview</h2>
|
|
104
|
+
<svg width="#{chart_width}" height="#{chart_height}" class="chart">
|
|
105
|
+
#{bars}
|
|
106
|
+
</svg>
|
|
107
|
+
</div>
|
|
108
|
+
HTML
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_table(results)
|
|
112
|
+
rows = results.sort_by(&:total).map { |r| table_row(r) }.join
|
|
113
|
+
|
|
114
|
+
<<~HTML
|
|
115
|
+
<h2>Details (#{results.size} classes)</h2>
|
|
116
|
+
<div class="table-wrapper">
|
|
117
|
+
<table>
|
|
118
|
+
<thead>
|
|
119
|
+
<tr>
|
|
120
|
+
<th>Class</th>
|
|
121
|
+
<th>File</th>
|
|
122
|
+
<th>SRP</th>
|
|
123
|
+
<th>OCP</th>
|
|
124
|
+
<th>LSP</th>
|
|
125
|
+
<th>ISP</th>
|
|
126
|
+
<th>DIP</th>
|
|
127
|
+
<th>Total</th>
|
|
128
|
+
</tr>
|
|
129
|
+
</thead>
|
|
130
|
+
<tbody>
|
|
131
|
+
#{rows}
|
|
132
|
+
</tbody>
|
|
133
|
+
</table>
|
|
134
|
+
</div>
|
|
135
|
+
HTML
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def table_row(result)
|
|
139
|
+
<<~HTML
|
|
140
|
+
<tr>
|
|
141
|
+
<td class="class-name">#{escape_html(result.class_name)}</td>
|
|
142
|
+
<td class="file-path">#{escape_html(result.file_path)}</td>
|
|
143
|
+
#{score_cell(result.srp)}
|
|
144
|
+
#{score_cell(result.ocp)}
|
|
145
|
+
#{score_cell(result.lsp)}
|
|
146
|
+
#{score_cell(result.isp)}
|
|
147
|
+
#{score_cell(result.dip)}
|
|
148
|
+
#{score_cell(result.total.round(1))}
|
|
149
|
+
</tr>
|
|
150
|
+
HTML
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def score_cell(value)
|
|
154
|
+
%(<td class="score #{score_color_class(value)}">#{value}</td>)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def score_color_class(score)
|
|
158
|
+
if score >= 80
|
|
159
|
+
"score-green"
|
|
160
|
+
elsif score >= 50
|
|
161
|
+
"score-yellow"
|
|
162
|
+
else
|
|
163
|
+
"score-red"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def score_hex_color(score)
|
|
168
|
+
if score >= 80
|
|
169
|
+
"#22c55e"
|
|
170
|
+
elsif score >= 50
|
|
171
|
+
"#eab308"
|
|
172
|
+
else
|
|
173
|
+
"#ef4444"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def escape_html(text)
|
|
178
|
+
text.to_s
|
|
179
|
+
.gsub("&", "&")
|
|
180
|
+
.gsub("<", "<")
|
|
181
|
+
.gsub(">", ">")
|
|
182
|
+
.gsub('"', """)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def css
|
|
186
|
+
<<~CSS
|
|
187
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
188
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f8fafc; color: #1e293b; line-height: 1.6; }
|
|
189
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
|
190
|
+
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; }
|
|
191
|
+
h1 .version { font-weight: normal; color: #64748b; font-size: 0.875rem; }
|
|
192
|
+
h2 { font-size: 1.125rem; margin: 1.5rem 0 0.75rem; }
|
|
193
|
+
|
|
194
|
+
.summary { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1.5rem; }
|
|
195
|
+
.summary-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem; min-width: 100px; text-align: center; }
|
|
196
|
+
.summary-total { border-width: 2px; }
|
|
197
|
+
.summary-label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
198
|
+
.summary-value { font-size: 1.5rem; font-weight: 700; }
|
|
199
|
+
|
|
200
|
+
.chart-container { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; }
|
|
201
|
+
.chart-label { font-size: 13px; fill: #475569; font-family: inherit; }
|
|
202
|
+
.chart-value { font-size: 13px; fill: #475569; font-family: inherit; }
|
|
203
|
+
|
|
204
|
+
.table-wrapper { overflow-x: auto; }
|
|
205
|
+
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 0.875rem; }
|
|
206
|
+
thead { background: #f1f5f9; }
|
|
207
|
+
th { padding: 0.625rem 0.75rem; text-align: left; font-weight: 600; border-bottom: 2px solid #e2e8f0; }
|
|
208
|
+
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f1f5f9; }
|
|
209
|
+
tr:hover { background: #f8fafc; }
|
|
210
|
+
.class-name { font-weight: 500; white-space: nowrap; }
|
|
211
|
+
.file-path { color: #64748b; font-size: 0.8rem; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
212
|
+
.score { text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; }
|
|
213
|
+
|
|
214
|
+
.score-green { color: #16a34a; }
|
|
215
|
+
.score-yellow { color: #ca8a04; }
|
|
216
|
+
.score-red { color: #dc2626; }
|
|
217
|
+
|
|
218
|
+
footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.75rem; text-align: center; }
|
|
219
|
+
CSS
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidScore
|
|
6
|
+
module Formatters
|
|
7
|
+
class JsonFormatter < BaseFormatter
|
|
8
|
+
def format(results)
|
|
9
|
+
data = {
|
|
10
|
+
version: VERSION,
|
|
11
|
+
classes: results.map { |r| format_result(r) },
|
|
12
|
+
summary: build_summary(results)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
JSON.pretty_generate(data)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def format_result(result)
|
|
21
|
+
{
|
|
22
|
+
class_name: result.class_name,
|
|
23
|
+
file_path: result.file_path,
|
|
24
|
+
srp: result.srp,
|
|
25
|
+
ocp: result.ocp,
|
|
26
|
+
lsp: result.lsp,
|
|
27
|
+
isp: result.isp,
|
|
28
|
+
dip: result.dip,
|
|
29
|
+
total: result.total.round(1),
|
|
30
|
+
confidence: result.confidence.transform_values(&:to_s),
|
|
31
|
+
subscores: result.subscores
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_summary(results)
|
|
36
|
+
return { total_classes: 0, average_score: 0.0 } if results.empty?
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
total_classes: results.size,
|
|
40
|
+
average_score: (results.sum(&:total) / results.size).round(1)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Formatters
|
|
5
|
+
class TextFormatter < BaseFormatter
|
|
6
|
+
def format(results)
|
|
7
|
+
return "No classes found to analyze.\n" if results.empty?
|
|
8
|
+
|
|
9
|
+
lines = []
|
|
10
|
+
lines << "solid-score v#{VERSION}\n"
|
|
11
|
+
lines << "Analyzed #{results.size} class(es)\n\n"
|
|
12
|
+
lines << header_line
|
|
13
|
+
lines << separator_line
|
|
14
|
+
|
|
15
|
+
results.each { |r| lines << result_line(r) }
|
|
16
|
+
|
|
17
|
+
lines << separator_line
|
|
18
|
+
lines << average_line(results)
|
|
19
|
+
lines << ""
|
|
20
|
+
|
|
21
|
+
lines.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def header_line
|
|
27
|
+
"Class SRP OCP LSP ISP DIP Total"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def separator_line
|
|
31
|
+
"-" * 75
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def result_line(result)
|
|
35
|
+
Kernel.format("%-40s %5.1f %5.1f %5.1f %5.1f %5.1f %7.1f",
|
|
36
|
+
truncate(result.class_name, 40),
|
|
37
|
+
result.srp, result.ocp, result.lsp, result.isp, result.dip, result.total)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def average_line(results)
|
|
41
|
+
avg = ->(method) { results.sum(&method) / results.size.to_f }
|
|
42
|
+
|
|
43
|
+
Kernel.format("%-40s %5.1f %5.1f %5.1f %5.1f %5.1f %7.1f",
|
|
44
|
+
"Average",
|
|
45
|
+
avg.call(:srp), avg.call(:ocp), avg.call(:lsp),
|
|
46
|
+
avg.call(:isp), avg.call(:dip), avg.call(:total))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def truncate(str, max)
|
|
50
|
+
str.length > max ? "#{str[0..(max - 4)]}..." : str
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Models
|
|
5
|
+
class ClassInfo
|
|
6
|
+
attr_reader :name, :file_path, :line_start, :line_end,
|
|
7
|
+
:methods, :superclass, :includes, :extends,
|
|
8
|
+
:instance_variables, :attr_readers, :attr_writers,
|
|
9
|
+
:kind, :dsl_calls
|
|
10
|
+
|
|
11
|
+
def initialize(name:, file_path: "", line_start: 0, line_end: 0,
|
|
12
|
+
methods: [], superclass: nil, includes: [], extends: [],
|
|
13
|
+
instance_variables: [], attr_readers: [], attr_writers: [],
|
|
14
|
+
kind: :class, dsl_calls: [])
|
|
15
|
+
@name = name
|
|
16
|
+
@file_path = file_path
|
|
17
|
+
@line_start = line_start
|
|
18
|
+
@line_end = line_end
|
|
19
|
+
@methods = methods
|
|
20
|
+
@superclass = superclass
|
|
21
|
+
@includes = includes
|
|
22
|
+
@extends = extends
|
|
23
|
+
@instance_variables = instance_variables
|
|
24
|
+
@attr_readers = attr_readers
|
|
25
|
+
@attr_writers = attr_writers
|
|
26
|
+
@kind = kind
|
|
27
|
+
@dsl_calls = dsl_calls
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def module?
|
|
31
|
+
kind == :module
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def public_methods_list
|
|
35
|
+
methods.select { |m| m.public? && m.name != :initialize }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def has_superclass?
|
|
39
|
+
!superclass.nil? && !superclass.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def data_class?
|
|
43
|
+
all_attrs = attr_readers + attr_writers
|
|
44
|
+
return false if all_attrs.empty?
|
|
45
|
+
|
|
46
|
+
non_init_methods = methods.reject { |m| m.name == :initialize }
|
|
47
|
+
non_init_methods.all? { |m| all_attrs.include?(m.name) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Phase 2c: レイヤー判別
|
|
51
|
+
# file_pathからRailsのレイヤーを自動判別する
|
|
52
|
+
def layer
|
|
53
|
+
@layer ||= determine_layer
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Phase 2c: フレームワーク基盤クラスかどうか
|
|
57
|
+
# ApplicationController, ApplicationRecord 等
|
|
58
|
+
def framework_base_class?
|
|
59
|
+
return false unless has_superclass?
|
|
60
|
+
|
|
61
|
+
FRAMEWORK_DIRECT_BASES.include?(superclass)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Issue #9: Inspection / diagnostic class detection.
|
|
65
|
+
# Console-only or read-only diagnostic helpers grow naturally and should
|
|
66
|
+
# not be penalised for their public surface.
|
|
67
|
+
#
|
|
68
|
+
# Suffixes are anchored with `\z` to avoid matching mid-word names
|
|
69
|
+
# (e.g. `DiagnosticReport` should not be treated as a diagnostic
|
|
70
|
+
# helper). `Tools` and `Debug` are intentionally narrow because they
|
|
71
|
+
# appear in many domain models.
|
|
72
|
+
INSPECTION_NAME_PATTERNS = [
|
|
73
|
+
/Inspect\z/, /Console\z/, /Debug\z/,
|
|
74
|
+
/Diagnostic\z/, /Diagnostics\z/, /Tools\z/
|
|
75
|
+
].freeze
|
|
76
|
+
INSPECTION_PATH_PATTERN = %r{/(inspectors?|diagnostics?|debug)/}
|
|
77
|
+
|
|
78
|
+
def inspection_class?
|
|
79
|
+
return true if INSPECTION_NAME_PATTERNS.any? { |p| name.match?(p) }
|
|
80
|
+
return true if file_path.match?(INSPECTION_PATH_PATTERN)
|
|
81
|
+
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Phase 2c: HTTPクライアントパターンかどうか
|
|
86
|
+
# 全publicメソッドが共通のクライアント系インスタンス変数を参照する構造
|
|
87
|
+
def http_client_pattern?
|
|
88
|
+
client_ivars = instance_variables.select do |iv|
|
|
89
|
+
iv.to_s.match?(/client|http|connection|api/)
|
|
90
|
+
end
|
|
91
|
+
return false if client_ivars.empty?
|
|
92
|
+
|
|
93
|
+
pub = public_methods_list
|
|
94
|
+
return false if pub.size < 2
|
|
95
|
+
|
|
96
|
+
pub.all? do |method|
|
|
97
|
+
client_ivars.any? { |iv| method.instance_variables.include?(iv) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
FRAMEWORK_DIRECT_BASES = %w[
|
|
104
|
+
ActiveRecord::Base ActionController::Base ActionController::API
|
|
105
|
+
ActiveJob::Base ActionMailer::Base
|
|
106
|
+
ActionCable::Channel::Base ActionCable::Connection::Base
|
|
107
|
+
].freeze
|
|
108
|
+
|
|
109
|
+
LAYER_PATH_PATTERNS = {
|
|
110
|
+
controller: "/controllers/",
|
|
111
|
+
model: "/models/",
|
|
112
|
+
service: "/services/",
|
|
113
|
+
job: "/jobs/",
|
|
114
|
+
mailer: "/mailers/",
|
|
115
|
+
form: "/forms/",
|
|
116
|
+
presenter: "/presenters/",
|
|
117
|
+
serializer: "/serializers/",
|
|
118
|
+
validator: "/validators/",
|
|
119
|
+
lib: "/lib/"
|
|
120
|
+
}.freeze
|
|
121
|
+
|
|
122
|
+
SUPERCLASS_LAYERS = {
|
|
123
|
+
"ApplicationRecord" => :model,
|
|
124
|
+
"ActiveRecord::Base" => :model,
|
|
125
|
+
"ApplicationController" => :controller,
|
|
126
|
+
"ActionController::Base" => :controller,
|
|
127
|
+
"ActionController::API" => :controller,
|
|
128
|
+
"ApplicationJob" => :job,
|
|
129
|
+
"ActiveJob::Base" => :job,
|
|
130
|
+
"ApplicationMailer" => :mailer,
|
|
131
|
+
"ActionMailer::Base" => :mailer
|
|
132
|
+
}.freeze
|
|
133
|
+
|
|
134
|
+
def determine_layer
|
|
135
|
+
# file_pathから判別(最も確実)
|
|
136
|
+
LAYER_PATH_PATTERNS.each do |layer, pattern|
|
|
137
|
+
return layer if file_path.include?(pattern)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# superclassから判別(file_pathで判別できない場合のフォールバック)
|
|
141
|
+
if has_superclass?
|
|
142
|
+
layer = SUPERCLASS_LAYERS[superclass]
|
|
143
|
+
return layer if layer
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
:unknown
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Models
|
|
5
|
+
# Represents information about a single method call.
|
|
6
|
+
# Phase 1 改善: レシーバ情報を収集するために追加
|
|
7
|
+
class MethodCallInfo
|
|
8
|
+
attr_reader :method_name, :receiver, :receiver_type
|
|
9
|
+
|
|
10
|
+
# @param method_name [Symbol] メソッド名
|
|
11
|
+
# @param receiver [String, nil] レシーバの名前(定数名、変数名など)
|
|
12
|
+
# @param receiver_type [Symbol] レシーバの種類 (:const, :ivar, :lvar, :self, :unknown)
|
|
13
|
+
def initialize(method_name:, receiver: nil, receiver_type: :unknown)
|
|
14
|
+
@method_name = method_name
|
|
15
|
+
@receiver = receiver
|
|
16
|
+
@receiver_type = receiver_type
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Check if this is a .new call on a constant (class instantiation)
|
|
20
|
+
def new_call_on_const?
|
|
21
|
+
method_name == :new && receiver_type == :const
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class MethodInfo
|
|
26
|
+
attr_reader :name, :visibility, :line_start, :line_end,
|
|
27
|
+
:instance_variables, :called_methods, :parameters,
|
|
28
|
+
:cyclomatic_complexity, :raises, :calls_super,
|
|
29
|
+
:method_calls, :case_when_count, :kind,
|
|
30
|
+
:effective_statement_count, :memoized_factory_receiver
|
|
31
|
+
|
|
32
|
+
def initialize(name:, visibility: :public, line_start: 0, line_end: 0,
|
|
33
|
+
instance_variables: [], called_methods: [], parameters: [],
|
|
34
|
+
cyclomatic_complexity: 1, raises: [], calls_super: false,
|
|
35
|
+
method_calls: [], case_when_count: 0, kind: :instance,
|
|
36
|
+
effective_statement_count: 0,
|
|
37
|
+
memoized_factory_receiver: nil, memoized_factory: nil)
|
|
38
|
+
@name = name
|
|
39
|
+
@visibility = visibility
|
|
40
|
+
@line_start = line_start
|
|
41
|
+
@line_end = line_end
|
|
42
|
+
@instance_variables = instance_variables
|
|
43
|
+
@called_methods = called_methods
|
|
44
|
+
@parameters = parameters
|
|
45
|
+
@cyclomatic_complexity = cyclomatic_complexity
|
|
46
|
+
@raises = raises
|
|
47
|
+
@calls_super = calls_super
|
|
48
|
+
@method_calls = method_calls
|
|
49
|
+
@case_when_count = case_when_count
|
|
50
|
+
@kind = kind
|
|
51
|
+
@effective_statement_count = effective_statement_count
|
|
52
|
+
# Issue #12: store the receiver constant of a memoised factory call
|
|
53
|
+
# (e.g. "ProvisioningService" for `@svc ||= ProvisioningService.new`).
|
|
54
|
+
# Older callers used `memoized_factory: true/false` as a boolean flag;
|
|
55
|
+
# accept that for backwards-compat in tests by mapping `true` to a
|
|
56
|
+
# placeholder string. New code should provide the receiver name.
|
|
57
|
+
@memoized_factory_receiver = memoized_factory_receiver
|
|
58
|
+
@memoized_factory_receiver ||= "<unknown>" if memoized_factory == true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def public?
|
|
62
|
+
visibility == :public
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def memoized_factory?
|
|
66
|
+
!@memoized_factory_receiver.nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def class_method?
|
|
70
|
+
kind == :class
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def instance_method?
|
|
74
|
+
kind == :instance
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def empty?
|
|
78
|
+
line_start == line_end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidScore
|
|
4
|
+
module Models
|
|
5
|
+
class ScoreResult
|
|
6
|
+
DEFAULT_WEIGHTS = {
|
|
7
|
+
srp: 0.30,
|
|
8
|
+
ocp: 0.15,
|
|
9
|
+
lsp: 0.10,
|
|
10
|
+
isp: 0.20,
|
|
11
|
+
dip: 0.25
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# 基本信頼度(解析手法の本質的な精度)
|
|
15
|
+
BASE_CONFIDENCE = {
|
|
16
|
+
srp: :high,
|
|
17
|
+
ocp: :medium,
|
|
18
|
+
lsp: :medium,
|
|
19
|
+
isp: :medium_high,
|
|
20
|
+
dip: :high
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
CONFIDENCE_ORDER = %i[low low_medium medium medium_high high].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :class_name, :file_path, :srp, :ocp, :lsp, :isp, :dip,
|
|
26
|
+
:weights, :class_info, :subscores
|
|
27
|
+
|
|
28
|
+
def initialize(class_name:, file_path: "", srp: 0.0, ocp: 0.0, lsp: 0.0, isp: 0.0, dip: 0.0,
|
|
29
|
+
weights: DEFAULT_WEIGHTS, class_info: nil, subscores: {})
|
|
30
|
+
@class_name = class_name
|
|
31
|
+
@file_path = file_path
|
|
32
|
+
@srp = srp
|
|
33
|
+
@ocp = ocp
|
|
34
|
+
@lsp = lsp
|
|
35
|
+
@isp = isp
|
|
36
|
+
@dip = dip
|
|
37
|
+
@weights = weights
|
|
38
|
+
@class_info = class_info
|
|
39
|
+
@subscores = subscores
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def total
|
|
43
|
+
(srp * weights[:srp]) +
|
|
44
|
+
(ocp * weights[:ocp]) +
|
|
45
|
+
(lsp * weights[:lsp]) +
|
|
46
|
+
(isp * weights[:isp]) +
|
|
47
|
+
(dip * weights[:dip])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Phase 2b: class_infoに基づく動的な信頼度計算
|
|
51
|
+
def confidence
|
|
52
|
+
return BASE_CONFIDENCE unless class_info
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
srp: adjusted_confidence(:srp),
|
|
56
|
+
ocp: adjusted_confidence(:ocp),
|
|
57
|
+
lsp: adjusted_confidence(:lsp),
|
|
58
|
+
isp: adjusted_confidence(:isp),
|
|
59
|
+
dip: adjusted_confidence(:dip)
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def adjusted_confidence(principle)
|
|
66
|
+
base = BASE_CONFIDENCE[principle]
|
|
67
|
+
|
|
68
|
+
# メソッド数が少ない場合、全原則の信頼度が低下
|
|
69
|
+
base = lower_confidence(base) if few_methods?
|
|
70
|
+
|
|
71
|
+
case principle
|
|
72
|
+
when :lsp
|
|
73
|
+
# モジュールの場合はLSPは常にhigh(継承なしで100点固定)
|
|
74
|
+
return :high if class_info.module?
|
|
75
|
+
# 継承なしもhigh
|
|
76
|
+
return :high unless class_info.has_superclass?
|
|
77
|
+
when :srp
|
|
78
|
+
# メソッド数が非常に少ない場合、LCOM4の意味が薄い
|
|
79
|
+
base = lower_confidence(base) if class_info.methods.size <= 2
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
base
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def few_methods?
|
|
86
|
+
class_info.methods.size <= 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def lower_confidence(level)
|
|
90
|
+
idx = CONFIDENCE_ORDER.index(level) || 0
|
|
91
|
+
CONFIDENCE_ORDER[[idx - 1, 0].max]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|