solid_score 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 465bf41b4b60ca3d4f6c9eecc7de89bb6bcf6dae598a0fa8052bca0e34a16dbf
4
- data.tar.gz: 235fd46777ef84ab2fd31620a0171f269c1f56ff9347bdac4e98731f7a76c5c0
3
+ metadata.gz: 7ee2dc0b2b384805ab33e1feb4278ff65f196763453e98747536d3dad3cebbb4
4
+ data.tar.gz: 9dd4b65bad2542e7255f681c152eccf6e8005ed6af18b2a6e25884da2e1c3cce
5
5
  SHA512:
6
- metadata.gz: 5522bbc639d6d4b88a89973f10dda2f58d59266d378361441c0bec332b355a0134255297e8d9a4888b486d1566c78b3791ab35aab8e3ef303dd88a6016d718c8
7
- data.tar.gz: a81c10321ab5f6e75ca1d134caa1b8111112c98e9a0d3108b9f1313a338491be83c1fbad17f4b1fc55d6eaefc143692e0054fb9d39c47557837ce78903c3040a
6
+ metadata.gz: 86f7674c77418367188bcf18b695c7c46116d4efe87db725e583b19b0df8626b7431dfe543b1060781953350b1ac9757165d291db298fcac7043fc48046a0db6
7
+ data.tar.gz: ec1b3636db05fa9f5f3c47d5a535ebc4fe18d6fdcf7f64ac545c921f773a6ccd01fef4a7fe46766c016d0dd07fc9cec9bde66847f6df1064c7913b29e11d97fc
@@ -21,7 +21,7 @@ module RuboCop
21
21
 
22
22
  class_infos = parser.parse_file(file_path)
23
23
  scorer.score_all(class_infos)
24
- rescue ::Parser::SyntaxError
24
+ rescue ::SolidScore::Parser::SyntaxError
25
25
  []
26
26
  end
27
27
 
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module SolidScore
6
+ module Parser
7
+ # Builds a Models::ClassInfo from a single Prism ClassNode/ModuleNode by
8
+ # scanning its direct body statements: method definitions, visibility
9
+ # modifiers, mixins, attr_* declarations and Rails DSL calls.
10
+ class ClassBodyScanner
11
+ # Rails/ActiveSupport DSL methods treated as declarations rather than
12
+ # ordinary method calls.
13
+ RAILS_DSL_METHODS = %i[
14
+ has_many has_one belongs_to has_and_belongs_to_many
15
+ validates validate validates_presence_of validates_uniqueness_of
16
+ validates_format_of validates_length_of validates_numericality_of
17
+ validates_inclusion_of validates_exclusion_of validates_associated
18
+ before_action after_action around_action skip_before_action
19
+ before_filter after_filter around_filter
20
+ before_validation after_validation
21
+ before_create after_create before_update after_update
22
+ before_save after_save before_destroy after_destroy
23
+ after_commit after_rollback
24
+ scope enum delegate
25
+ serialize store
26
+ accepts_nested_attributes_for
27
+ ].freeze
28
+
29
+ VISIBILITY_MODIFIERS = %i[private protected public].freeze
30
+
31
+ # Direct statements of a class/module body. The body is a
32
+ # StatementsNode normally, but a BeginNode when the class itself has
33
+ # a rescue/ensure clause, in which case the statements sit inside it.
34
+ def self.body_statements(node)
35
+ body = node.body
36
+ body = body.statements if body.is_a?(::Prism::BeginNode)
37
+ body&.body || []
38
+ end
39
+
40
+ def initialize(node, file_path, namespace)
41
+ @node = node
42
+ @file_path = file_path
43
+ @namespace = namespace
44
+ @methods = []
45
+ @includes = []
46
+ @extends = []
47
+ @attr_readers = []
48
+ @attr_writers = []
49
+ @dsl_calls = []
50
+ @visibility = :public
51
+ end
52
+
53
+ def class_info
54
+ statements.each { |statement| scan(statement) }
55
+
56
+ Models::ClassInfo.new(
57
+ name: qualified_name,
58
+ file_path: @file_path,
59
+ line_start: @node.location.start_line,
60
+ line_end: @node.location.end_line,
61
+ methods: @methods,
62
+ superclass: superclass_name,
63
+ includes: @includes,
64
+ extends: @extends,
65
+ instance_variables: @methods.flat_map(&:instance_variables).uniq,
66
+ attr_readers: @attr_readers,
67
+ attr_writers: @attr_writers,
68
+ kind: @node.is_a?(::Prism::ModuleNode) ? :module : :class,
69
+ dsl_calls: @dsl_calls
70
+ )
71
+ end
72
+
73
+ private
74
+
75
+ def statements
76
+ self.class.body_statements(@node)
77
+ end
78
+
79
+ def qualified_name
80
+ raw = ConstantName.of(@node.constant_path)
81
+ @namespace ? "#{@namespace}::#{raw}" : raw
82
+ end
83
+
84
+ def superclass_name
85
+ return nil unless @node.is_a?(::Prism::ClassNode)
86
+
87
+ ConstantName.of(@node.superclass)
88
+ end
89
+
90
+ def scan(statement)
91
+ case statement
92
+ when ::Prism::DefNode then scan_def(statement)
93
+ when ::Prism::CallNode then scan_call(statement)
94
+ end
95
+ end
96
+
97
+ def scan_def(node)
98
+ @methods << if node.receiver
99
+ MethodAnalyzer.new(node, visibility: :public, kind: :class).method_info
100
+ else
101
+ MethodAnalyzer.new(node, visibility: @visibility, kind: :instance).method_info
102
+ end
103
+ end
104
+
105
+ def scan_call(node)
106
+ name = node.name
107
+ args = node.arguments&.arguments || []
108
+
109
+ case name
110
+ when *VISIBILITY_MODIFIERS then @visibility = name
111
+ when :include then @includes << ConstantName.of(args.first) if args.first
112
+ when :extend then @extends << ConstantName.of(args.first) if args.first
113
+ when :attr_reader then @attr_readers.concat(symbol_args(args))
114
+ when :attr_writer then @attr_writers.concat(symbol_args(args))
115
+ when :attr_accessor
116
+ names = symbol_args(args)
117
+ @attr_readers.concat(names)
118
+ @attr_writers.concat(names)
119
+ else
120
+ @dsl_calls << name if RAILS_DSL_METHODS.include?(name)
121
+ end
122
+ end
123
+
124
+ def symbol_args(args)
125
+ args.grep(::Prism::SymbolNode).map { |arg| arg.unescaped.to_sym }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module SolidScore
6
+ module Parser
7
+ # Renders a Prism constant node as its qualified name ("Foo::Bar").
8
+ # Non-constant expressions (e.g. `Struct.new(:a)` as a superclass)
9
+ # fall back to their source text so callers still get a stable string.
10
+ module ConstantName
11
+ module_function
12
+
13
+ def of(node)
14
+ case node
15
+ when nil then nil
16
+ when ::Prism::ConstantReadNode then node.name.to_s
17
+ when ::Prism::ConstantPathNode then "#{of(node.parent)}::#{node.name}"
18
+ else node.slice
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module SolidScore
6
+ module Parser
7
+ # Walks a Prism syntax tree and collects every class/module definition
8
+ # as Models::ClassInfo, qualifying nested names with their namespace.
9
+ #
10
+ # Top-level definitions are found anywhere in the tree (e.g. inside an
11
+ # `if defined?(...)` guard). Inside a class/module body only direct
12
+ # child definitions are collected, matching the previous parser.
13
+ class DefinitionCollector < ::Prism::Visitor
14
+ attr_reader :classes
15
+
16
+ def initialize(file_path)
17
+ super()
18
+ @file_path = file_path
19
+ @namespace = nil
20
+ @classes = []
21
+ end
22
+
23
+ def visit_class_node(node)
24
+ collect_definition(node)
25
+ end
26
+
27
+ def visit_module_node(node)
28
+ collect_definition(node)
29
+ end
30
+
31
+ private
32
+
33
+ def collect_definition(node)
34
+ info = ClassBodyScanner.new(node, @file_path, @namespace).class_info
35
+ @classes << info
36
+ collect_nested(node, info.name)
37
+ end
38
+
39
+ def collect_nested(node, namespace)
40
+ with_namespace(namespace) do
41
+ ClassBodyScanner.body_statements(node)
42
+ .select { |child| child.is_a?(::Prism::ClassNode) || child.is_a?(::Prism::ModuleNode) }
43
+ .each { |child| visit(child) }
44
+ end
45
+ end
46
+
47
+ def with_namespace(namespace)
48
+ previous = @namespace
49
+ @namespace = namespace
50
+ yield
51
+ ensure
52
+ @namespace = previous
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module SolidScore
6
+ module Parser
7
+ # Builds a Models::MethodInfo from a Prism DefNode by walking the method
8
+ # body once and collecting every metric the analyzers need.
9
+ class MethodAnalyzer
10
+ # Factory-style method names that count as instantiation for the
11
+ # `@ivar ||= Const.new` memoisation pattern (Issue #12).
12
+ # Mirrors DipAnalyzer::FACTORY_METHODS.
13
+ FACTORY_METHODS = %i[new create build call open].freeze
14
+
15
+ # Instance variable nodes: reads, every assignment form, and
16
+ # multiple-assignment targets (`@a, @b = ...`).
17
+ INSTANCE_VARIABLE_NODES = [
18
+ ::Prism::InstanceVariableReadNode,
19
+ ::Prism::InstanceVariableWriteNode,
20
+ ::Prism::InstanceVariableOrWriteNode,
21
+ ::Prism::InstanceVariableAndWriteNode,
22
+ ::Prism::InstanceVariableOperatorWriteNode,
23
+ ::Prism::InstanceVariableTargetNode
24
+ ].freeze
25
+
26
+ # Statement-like nodes that count toward the effective body size
27
+ # (Issue #10). Calls, conditionals, assignments, loops, jumps and
28
+ # blocks each count once; `||=`-style operator assignments count as
29
+ # both the assignment and the operator (see OPERATOR_ASSIGNMENT_NODES).
30
+ #
31
+ # Deliberately not counted: `case/in` pattern matching, `begin`,
32
+ # `rescue`/`ensure` wrappers, `when` clauses (the enclosing `case`
33
+ # already counts) and variable reads.
34
+ EFFECTIVE_STATEMENT_NODES = [
35
+ ::Prism::CallNode,
36
+ ::Prism::IfNode, ::Prism::UnlessNode, ::Prism::CaseNode, ::Prism::ReturnNode,
37
+ ::Prism::InstanceVariableWriteNode, ::Prism::LocalVariableWriteNode,
38
+ ::Prism::GlobalVariableWriteNode, ::Prism::ClassVariableWriteNode,
39
+ ::Prism::MultiWriteNode,
40
+ ::Prism::InstanceVariableTargetNode, ::Prism::LocalVariableTargetNode,
41
+ ::Prism::GlobalVariableTargetNode, ::Prism::ClassVariableTargetNode,
42
+ ::Prism::CallTargetNode, ::Prism::IndexTargetNode,
43
+ ::Prism::ConstantOrWriteNode, ::Prism::ConstantAndWriteNode, ::Prism::ConstantOperatorWriteNode,
44
+ ::Prism::ConstantPathOrWriteNode, ::Prism::ConstantPathAndWriteNode, ::Prism::ConstantPathOperatorWriteNode,
45
+ ::Prism::YieldNode, ::Prism::BlockNode, ::Prism::LambdaNode,
46
+ ::Prism::WhileNode, ::Prism::UntilNode, ::Prism::ForNode,
47
+ ::Prism::NextNode, ::Prism::BreakNode, ::Prism::RetryNode, ::Prism::RedoNode,
48
+ ::Prism::SuperNode, ::Prism::ForwardingSuperNode
49
+ ].freeze
50
+
51
+ # `x ||= v`, `x &&= v`, `x += v` on variables and attributes. Counted
52
+ # twice so the effective statement count matches the previous parser,
53
+ # which saw these as an assignment node wrapping an operator node.
54
+ # The attribute and index forms also read the target (`self.a ||= v`
55
+ # calls `a`), so they are recorded as method calls too.
56
+ OPERATOR_ASSIGNMENT_NODES = [
57
+ ::Prism::LocalVariableOrWriteNode, ::Prism::LocalVariableAndWriteNode,
58
+ ::Prism::LocalVariableOperatorWriteNode,
59
+ ::Prism::InstanceVariableOrWriteNode, ::Prism::InstanceVariableAndWriteNode,
60
+ ::Prism::InstanceVariableOperatorWriteNode,
61
+ ::Prism::GlobalVariableOrWriteNode, ::Prism::GlobalVariableAndWriteNode,
62
+ ::Prism::GlobalVariableOperatorWriteNode,
63
+ ::Prism::ClassVariableOrWriteNode, ::Prism::ClassVariableAndWriteNode,
64
+ ::Prism::ClassVariableOperatorWriteNode,
65
+ ::Prism::CallOrWriteNode, ::Prism::CallAndWriteNode, ::Prism::CallOperatorWriteNode,
66
+ ::Prism::IndexOrWriteNode, ::Prism::IndexAndWriteNode, ::Prism::IndexOperatorWriteNode
67
+ ].freeze
68
+
69
+ ATTRIBUTE_ASSIGNMENT_NODES = [
70
+ ::Prism::CallOrWriteNode, ::Prism::CallAndWriteNode, ::Prism::CallOperatorWriteNode
71
+ ].freeze
72
+
73
+ INDEX_ASSIGNMENT_NODES = [
74
+ ::Prism::IndexOrWriteNode, ::Prism::IndexAndWriteNode, ::Prism::IndexOperatorWriteNode
75
+ ].freeze
76
+
77
+ # Branching nodes that add one to cyclomatic complexity. Rescue is
78
+ # handled separately in visit_begin_node so that a multi-clause rescue
79
+ # counts once, as the previous parser did.
80
+ BRANCH_NODES = [
81
+ ::Prism::IfNode, ::Prism::UnlessNode, ::Prism::WhileNode, ::Prism::UntilNode,
82
+ ::Prism::ForNode, ::Prism::WhenNode, ::Prism::AndNode, ::Prism::OrNode,
83
+ ::Prism::RescueModifierNode
84
+ ].freeze
85
+
86
+ def initialize(node, visibility:, kind:)
87
+ @node = node
88
+ @visibility = visibility
89
+ @kind = kind
90
+ @instance_variables = []
91
+ @called_methods = []
92
+ @method_calls = []
93
+ @raises = []
94
+ @calls_super = false
95
+ @case_when_count = 0
96
+ @cyclomatic_complexity = 1
97
+ @effective_statement_count = 0
98
+ @memoized_factory_receiver = nil
99
+ @nested_definition_depth = 0
100
+ end
101
+
102
+ def method_info
103
+ walk(@node.body)
104
+
105
+ Models::MethodInfo.new(
106
+ name: @node.name,
107
+ visibility: @visibility,
108
+ line_start: @node.location.start_line,
109
+ line_end: @node.location.end_line,
110
+ instance_variables: @instance_variables.uniq,
111
+ called_methods: @called_methods.uniq,
112
+ parameters: parameters,
113
+ cyclomatic_complexity: @cyclomatic_complexity,
114
+ raises: @raises,
115
+ calls_super: @calls_super,
116
+ method_calls: @method_calls,
117
+ case_when_count: @case_when_count,
118
+ kind: @kind,
119
+ effective_statement_count: @effective_statement_count,
120
+ memoized_factory_receiver: @memoized_factory_receiver
121
+ )
122
+ end
123
+
124
+ private
125
+
126
+ # Pre-order walk. Generic bookkeeping first, then node-specific hooks,
127
+ # then recurse into children. A plain recursion is used instead of
128
+ # Prism::Visitor because every visit_* there iterates children itself,
129
+ # leaving no single place to hook all nodes.
130
+ def walk(node)
131
+ return unless node
132
+
133
+ record_instance_variable(node)
134
+ record_effective_statement(node)
135
+ @cyclomatic_complexity += 1 if BRANCH_NODES.any? { |type| node.is_a?(type) }
136
+ inspect_node(node)
137
+
138
+ nested = node.is_a?(::Prism::DefNode)
139
+ @nested_definition_depth += 1 if nested
140
+ node.compact_child_nodes.each { |child| walk(child) }
141
+ @nested_definition_depth -= 1 if nested
142
+ end
143
+
144
+ def inspect_node(node)
145
+ case node
146
+ when ::Prism::CallNode then inspect_call(node)
147
+ when *ATTRIBUTE_ASSIGNMENT_NODES then record_call(node.read_name, node.receiver)
148
+ when *INDEX_ASSIGNMENT_NODES then record_call(:[], node.receiver)
149
+ when ::Prism::CallTargetNode then record_call(node.name, node.receiver)
150
+ when ::Prism::IndexTargetNode then record_call(:[]=, node.receiver)
151
+ when ::Prism::InstanceVariableOrWriteNode
152
+ @memoized_factory_receiver ||= memoized_factory_receiver_for(node)
153
+ when ::Prism::SuperNode, ::Prism::ForwardingSuperNode then @calls_super = true
154
+ when ::Prism::CaseNode
155
+ @case_when_count += node.conditions.count { |condition| condition.is_a?(::Prism::WhenNode) }
156
+ when ::Prism::BeginNode then @cyclomatic_complexity += 1 if node.rescue_clause
157
+ end
158
+ end
159
+
160
+ def inspect_call(node)
161
+ record_call(node.name, node.receiver)
162
+ record_raise(node)
163
+ end
164
+
165
+ def record_call(name, receiver_node)
166
+ receiver, receiver_type = receiver_info(receiver_node)
167
+ @called_methods << name
168
+ @method_calls << Models::MethodCallInfo.new(method_name: name, receiver: receiver, receiver_type: receiver_type)
169
+ end
170
+
171
+ def record_instance_variable(node)
172
+ @instance_variables << node.name if INSTANCE_VARIABLE_NODES.any? { |type| node.is_a?(type) }
173
+ end
174
+
175
+ def record_effective_statement(node)
176
+ return if @nested_definition_depth.positive?
177
+
178
+ @effective_statement_count += 1 if EFFECTIVE_STATEMENT_NODES.any? { |type| node.is_a?(type) }
179
+ @effective_statement_count += 2 if OPERATOR_ASSIGNMENT_NODES.any? { |type| node.is_a?(type) }
180
+ end
181
+
182
+ def record_raise(node)
183
+ return unless %i[raise fail].include?(node.name)
184
+
185
+ raised = node.arguments&.arguments&.first
186
+ return unless raised.is_a?(::Prism::ConstantReadNode) || raised.is_a?(::Prism::ConstantPathNode)
187
+
188
+ @raises << ConstantName.of(raised)
189
+ end
190
+
191
+ def receiver_info(node)
192
+ case node
193
+ when nil, ::Prism::SelfNode then [nil, :self]
194
+ when ::Prism::ConstantReadNode, ::Prism::ConstantPathNode then [ConstantName.of(node), :const]
195
+ when ::Prism::InstanceVariableReadNode then [node.name.to_s, :ivar]
196
+ when ::Prism::LocalVariableReadNode then [node.name.to_s, :lvar]
197
+ when ::Prism::CallNode then [nil, :send]
198
+ else [nil, :unknown]
199
+ end
200
+ end
201
+
202
+ def memoized_factory_receiver_for(node)
203
+ call = node.value
204
+ return nil unless call.is_a?(::Prism::CallNode) && FACTORY_METHODS.include?(call.name)
205
+
206
+ receiver = call.receiver
207
+ return nil unless receiver.is_a?(::Prism::ConstantReadNode) || receiver.is_a?(::Prism::ConstantPathNode)
208
+
209
+ ConstantName.of(receiver)
210
+ end
211
+
212
+ def parameters
213
+ params = @node.parameters
214
+ return [] unless params
215
+
216
+ [
217
+ *params.requireds.map { |p| positional(p) },
218
+ *params.optionals.map { |p| [:optarg, p.name] },
219
+ *Array(params.rest).map { |p| rest(p) },
220
+ *params.posts.map { |p| positional(p) },
221
+ *params.keywords.map { |p| keyword(p) },
222
+ *Array(params.keyword_rest).map { |p| keyword_rest(p) },
223
+ *Array(params.block).map { |p| [:blockarg, p.name] }
224
+ ]
225
+ end
226
+
227
+ def positional(node)
228
+ node.is_a?(::Prism::MultiTargetNode) ? [:mlhs, nil] : [:arg, node.name]
229
+ end
230
+
231
+ def rest(node)
232
+ node.is_a?(::Prism::ImplicitRestNode) ? [:restarg, nil] : [:restarg, node.name]
233
+ end
234
+
235
+ def keyword(node)
236
+ node.is_a?(::Prism::RequiredKeywordParameterNode) ? [:kwarg, node.name] : [:kwoptarg, node.name]
237
+ end
238
+
239
+ def keyword_rest(node)
240
+ case node
241
+ when ::Prism::ForwardingParameterNode then [:forward_arg, nil]
242
+ when ::Prism::NoKeywordsParameterNode then [:kwnilarg, nil]
243
+ else [:kwrestarg, node.name]
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -1,450 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parser/current"
3
+ require "prism"
4
4
 
5
5
  module SolidScore
6
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
7
+ # Raised when a source file cannot be parsed. Prism itself never raises
8
+ # on syntax errors (it returns a partial tree plus diagnostics), so the
9
+ # first error is surfaced here for callers that want to skip the file.
10
+ class SyntaxError < StandardError; end
36
11
 
12
+ # Parses Ruby source with Prism's native node API and extracts
13
+ # class/module definitions as Models::ClassInfo.
14
+ class RubyParser
37
15
  def parse_file(file_path)
38
- source = File.read(file_path)
39
- ast = ::Parser::CurrentRuby.parse(source)
40
- return [] unless ast
16
+ result = ::Prism.parse_file(file_path)
17
+ raise SyntaxError, format_error(file_path, result.errors.first) if result.failure?
41
18
 
42
- extract_definitions(ast, file_path)
19
+ collector = DefinitionCollector.new(file_path)
20
+ result.value.accept(collector)
21
+ collector.classes
43
22
  end
44
23
 
45
24
  private
46
25
 
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
26
+ def format_error(file_path, error)
27
+ "#{file_path}:#{error.location.start_line}:#{error.location.start_column}: #{error.message}"
448
28
  end
449
29
  end
450
30
  end
@@ -56,7 +56,7 @@ module SolidScore
56
56
 
57
57
  def parse_file(file)
58
58
  @parser.parse_file(file)
59
- rescue ::Parser::SyntaxError
59
+ rescue ::SolidScore::Parser::SyntaxError
60
60
  []
61
61
  end
62
62
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidScore
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/solid_score.rb CHANGED
@@ -4,6 +4,10 @@ require_relative "solid_score/version"
4
4
  require_relative "solid_score/models/method_info"
5
5
  require_relative "solid_score/models/class_info"
6
6
  require_relative "solid_score/models/score_result"
7
+ require_relative "solid_score/parser/constant_name"
8
+ require_relative "solid_score/parser/method_analyzer"
9
+ require_relative "solid_score/parser/class_body_scanner"
10
+ require_relative "solid_score/parser/definition_collector"
7
11
  require_relative "solid_score/parser/ruby_parser"
8
12
  require_relative "solid_score/analyzers/base_analyzer"
9
13
  require_relative "solid_score/analyzers/mitigations"
metadata CHANGED
@@ -1,43 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_score
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - harachan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: ast
14
+ name: prism
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.4'
19
+ version: '1.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.4'
27
- - !ruby/object:Gem::Dependency
28
- name: parser
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '3.3'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '3.3'
26
+ version: '1.0'
41
27
  description: Static analysis tool that scores Ruby classes/modules against SOLID principles
42
28
  using AST analysis
43
29
  email:
@@ -80,6 +66,10 @@ files:
80
66
  - lib/solid_score/models/class_info.rb
81
67
  - lib/solid_score/models/method_info.rb
82
68
  - lib/solid_score/models/score_result.rb
69
+ - lib/solid_score/parser/class_body_scanner.rb
70
+ - lib/solid_score/parser/constant_name.rb
71
+ - lib/solid_score/parser/definition_collector.rb
72
+ - lib/solid_score/parser/method_analyzer.rb
83
73
  - lib/solid_score/parser/ruby_parser.rb
84
74
  - lib/solid_score/presets.rb
85
75
  - lib/solid_score/runner.rb