crap4ruby 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +29 -1
- data/CODE_OF_CONDUCT.md +90 -0
- data/CONTRIBUTING.md +60 -3
- data/README.md +52 -24
- data/SECURITY.md +15 -0
- data/VERSIONING.md +30 -0
- data/lib/crap4ruby/analyzer.rb +219 -26
- data/lib/crap4ruby/cli.rb +95 -21
- data/lib/crap4ruby/coverage.rb +39 -7
- data/lib/crap4ruby/discovery.rb +57 -7
- data/lib/crap4ruby/report.rb +39 -3
- data/lib/crap4ruby/score.rb +12 -2
- data/lib/crap4ruby/version.rb +2 -1
- data/lib/crap4ruby.rb +39 -1
- data/oss-audit-report.before.md +159 -0
- data/oss-audit-report.md +110 -0
- data/scoring-audit.md +251 -0
- data/sig/crap4ruby.rbs +112 -0
- data/sig/manifest.yaml +8 -0
- data/sig/stdlib.rbs +10 -0
- metadata +11 -1
data/lib/crap4ruby/analyzer.rb
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Crap4Ruby
|
|
4
|
+
# Finds explicit methods and literal define_method blocks without executing code.
|
|
4
5
|
class Analyzer
|
|
6
|
+
# RuboCop 1.92.1 Metrics::Utils::IteratingBlock's method-name policy.
|
|
7
|
+
ITERATING_METHODS = %i[
|
|
8
|
+
all? any? chain chunk chunk_while collect collect_concat count cycle
|
|
9
|
+
detect drop drop_while each each_cons each_entry each_slice
|
|
10
|
+
each_with_index each_with_object entries filter filter_map find
|
|
11
|
+
find_all find_index flat_map grep grep_v group_by inject lazy map
|
|
12
|
+
max max_by min min_by minmax minmax_by none? one? partition reduce
|
|
13
|
+
reject reverse_each select slice_after slice_before slice_when sort
|
|
14
|
+
sort_by sum take take_while tally to_h uniq zip with_index with_object
|
|
15
|
+
bsearch bsearch_index collect! combination d_permutation delete_if
|
|
16
|
+
each_index keep_if map! permutation product reject! repeat
|
|
17
|
+
repeated_combination select! sort! each_key each_pair each_value
|
|
18
|
+
fetch fetch_values has_key? merge merge! transform_keys transform_keys!
|
|
19
|
+
transform_values transform_values!
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Analyzes method bodies with Prism and the RuboCop 1.92.1 counting rules.
|
|
23
|
+
#
|
|
24
|
+
# @param source [String] Ruby source text
|
|
25
|
+
# @param path [String] source label used in results and parse errors
|
|
26
|
+
# @return [Array<MethodInfo>] methods in traversal order, including nested definitions
|
|
27
|
+
# @raise [Error] if source cannot be parsed
|
|
28
|
+
# @example Analyze a method without loading it
|
|
29
|
+
# Analyzer.new.call("def eligible?(age) = age >= 18", path: "person.rb")
|
|
5
30
|
def call(source, path:)
|
|
6
31
|
result = Prism.parse(source)
|
|
7
32
|
unless result.success?
|
|
@@ -13,34 +38,104 @@ module Crap4Ruby
|
|
|
13
38
|
|
|
14
39
|
private
|
|
15
40
|
|
|
41
|
+
# Tracks lexical owners; skips owners that cannot be resolved statically.
|
|
42
|
+
#
|
|
43
|
+
# @param node [Prism::Node, nil] subtree to visit
|
|
44
|
+
# @param scope [Array<String>] lexical namespace components
|
|
45
|
+
# @param singleton [Boolean] whether definitions belong to a singleton class
|
|
46
|
+
# @param path [String] source label
|
|
47
|
+
# @return [Array<MethodInfo>] discovered method definitions
|
|
16
48
|
def discover(node, scope, singleton, path)
|
|
17
49
|
return [] unless node
|
|
18
50
|
|
|
19
51
|
case node
|
|
20
52
|
when Prism::ClassNode, Prism::ModuleNode
|
|
21
|
-
|
|
22
|
-
owner ? discover(node.body, owner, false, path) : []
|
|
53
|
+
discover_class(node, scope, singleton, path)
|
|
23
54
|
when Prism::SingletonClassNode
|
|
24
55
|
owner = node.expression.is_a?(Prism::SelfNode) ? scope : qualify(node.expression, scope)
|
|
25
56
|
owner ? discover(node.body, owner, true, path) : []
|
|
26
57
|
when Prism::DefNode
|
|
27
58
|
discover_method(node, scope, singleton, path)
|
|
59
|
+
when Prism::CallNode
|
|
60
|
+
discover_call(node, scope, singleton, path)
|
|
61
|
+
else
|
|
62
|
+
node.compact_child_nodes.flat_map { |child| discover(child, scope, singleton, path) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def discover_class(node, scope, singleton, path)
|
|
67
|
+
# @type var methods: Array[MethodInfo]
|
|
68
|
+
methods = node.is_a?(Prism::ClassNode) ? discover(node.superclass, scope, singleton, path) : []
|
|
69
|
+
owner = qualify(node.constant_path, scope, declaration: true)
|
|
70
|
+
methods + (owner ? discover(node.body, owner, false, path) : [])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def discover_call(node, scope, singleton, path)
|
|
74
|
+
name = defined_name(node)
|
|
75
|
+
block = node.block
|
|
76
|
+
if name && block.is_a?(Prism::BlockNode)
|
|
77
|
+
discover_block(block, name, scope, singleton, path)
|
|
28
78
|
else
|
|
29
79
|
node.compact_child_nodes.flat_map { |child| discover(child, scope, singleton, path) }
|
|
30
80
|
end
|
|
31
81
|
end
|
|
32
82
|
|
|
83
|
+
def discover_block(block, name, scope, singleton, path)
|
|
84
|
+
raise EncodingError unless name.valid_encoding?
|
|
85
|
+
|
|
86
|
+
name = name.encode(Encoding::UTF_8)
|
|
87
|
+
parameters = block.parameters
|
|
88
|
+
header = parameters.is_a?(Prism::BlockParametersNode) ? parameters.location : block.opening_loc
|
|
89
|
+
lines = body_lines(block.body, after: header.end_line, ending: block.location)
|
|
90
|
+
name = "self.#{name}" if singleton
|
|
91
|
+
[MethodInfo.new(name, scope.empty? ? "Object" : scope.join("::"), 1 + decisions(block.body), lines, path)] +
|
|
92
|
+
discover(block.parameters, scope, singleton, path) + discover(block.body, scope, singleton, path)
|
|
93
|
+
rescue EncodingError
|
|
94
|
+
raise Error, "#{path}:#{block.location.start_line}: define_method name must be representable in UTF-8"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def defined_name(node)
|
|
98
|
+
return unless node.is_a?(Prism::CallNode) && node.name == :define_method && node.receiver.nil?
|
|
99
|
+
return unless node.block.is_a?(Prism::BlockNode)
|
|
100
|
+
|
|
101
|
+
arguments = node.arguments&.arguments
|
|
102
|
+
return unless arguments && arguments.size == 1
|
|
103
|
+
|
|
104
|
+
name = arguments.first
|
|
105
|
+
name.unescaped if name.is_a?(Prism::SymbolNode) || name.is_a?(Prism::StringNode)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Builds this method's entry and discovers nested definitions.
|
|
109
|
+
#
|
|
110
|
+
# @param node [Prism::DefNode] method definition
|
|
111
|
+
# @param scope [Array<String>] lexical namespace components
|
|
112
|
+
# @param singleton [Boolean] whether the enclosing scope is a singleton class
|
|
113
|
+
# @param path [String] source label
|
|
114
|
+
# @return [Array<MethodInfo>] this method and its nested definitions
|
|
33
115
|
def discover_method(node, scope, singleton, path)
|
|
34
|
-
owner =
|
|
116
|
+
owner = method_owner(node, scope)
|
|
35
117
|
return [] unless owner
|
|
36
118
|
|
|
37
|
-
name = "#{
|
|
38
|
-
|
|
39
|
-
|
|
119
|
+
name = "#{"self." if singleton || node.receiver}#{node.name}"
|
|
120
|
+
header = node.equal_loc || node.rparen_loc || node.parameters&.location || node.name_loc
|
|
121
|
+
lines = body_lines(node.body, after: header.end_line, ending: node.location)
|
|
122
|
+
complexity = 1 + decisions(node.body)
|
|
40
123
|
[MethodInfo.new(name, owner.empty? ? "Object" : owner.join("::"), complexity, lines, path)] +
|
|
124
|
+
discover(node.parameters, scope, singleton, path) +
|
|
41
125
|
discover(node.body, scope, singleton, path)
|
|
42
126
|
end
|
|
43
127
|
|
|
128
|
+
def method_owner(node, scope)
|
|
129
|
+
node.receiver && !node.receiver.is_a?(Prism::SelfNode) ? qualify(node.receiver, scope) : scope
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Resolves constant syntax without looking up runtime constants.
|
|
133
|
+
# Declarations append names; references can reuse a lexical ancestor.
|
|
134
|
+
#
|
|
135
|
+
# @param node [Prism::Node, nil] namespace expression
|
|
136
|
+
# @param scope [Array<String>] lexical namespace components
|
|
137
|
+
# @param declaration [Boolean] whether the expression declares a namespace
|
|
138
|
+
# @return [Array<String>, nil] qualified components, or nil for dynamic expressions
|
|
44
139
|
def qualify(node, scope, declaration: false)
|
|
45
140
|
case node
|
|
46
141
|
when Prism::ParenthesesNode
|
|
@@ -50,41 +145,139 @@ module Crap4Ruby
|
|
|
50
145
|
ancestor = scope.rindex(name) unless declaration
|
|
51
146
|
ancestor ? scope.take(ancestor + 1) : scope + [name]
|
|
52
147
|
when Prism::ConstantPathNode
|
|
53
|
-
|
|
148
|
+
# @type var parent: Array[String]?
|
|
149
|
+
parent = node.parent ? qualify(node.parent, scope) : []
|
|
150
|
+
parent&.+([node.name.to_s])
|
|
54
151
|
end
|
|
55
152
|
end
|
|
56
153
|
|
|
57
|
-
|
|
154
|
+
# Counts the entire body, with RuboCop's per-method safe-navigation discount.
|
|
155
|
+
#
|
|
156
|
+
# @param node [Prism::Node, nil] subtree to measure
|
|
157
|
+
# @return [Integer] number of decisions, excluding the method's base complexity
|
|
158
|
+
# @param seen [Hash<Symbol, Boolean>] local receivers since their last assignment
|
|
159
|
+
# @param target [Boolean] whether a target node belongs to an assignment
|
|
160
|
+
def decisions(node, seen = {}, target: false)
|
|
58
161
|
return 0 unless node
|
|
59
|
-
return 0 if boundary?(node)
|
|
60
162
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
163
|
+
case node
|
|
164
|
+
when Prism::LocalVariableWriteNode, Prism::LocalVariableAndWriteNode,
|
|
165
|
+
Prism::LocalVariableOrWriteNode, Prism::LocalVariableOperatorWriteNode
|
|
166
|
+
seen.delete(node.name)
|
|
167
|
+
when Prism::LocalVariableTargetNode
|
|
168
|
+
seen.delete(node.name) if target
|
|
169
|
+
end
|
|
170
|
+
count = decision?(node) ? 1 : 0
|
|
171
|
+
count += 1 if iteration?(node)
|
|
172
|
+
count += 1 if safe_navigation?(node, seen)
|
|
173
|
+
count + child_decisions(node, seen, target)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def child_decisions(node, seen, target)
|
|
177
|
+
# RuboCop visits unless branches and guarded patterns in this order.
|
|
178
|
+
if node.is_a?(Prism::UnlessNode)
|
|
179
|
+
return decisions(node.predicate, seen) + decisions(node.else_clause, seen) + decisions(node.statements, seen)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if node.is_a?(Prism::InNode)
|
|
183
|
+
guard = node.pattern
|
|
184
|
+
if guard.is_a?(Prism::IfNode) || guard.is_a?(Prism::UnlessNode)
|
|
185
|
+
return decisions(guard.statements, seen) + decisions(guard.predicate, seen) + decisions(node.statements, seen)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
node.compact_child_nodes.sum { |child| decisions(child, seen, target: assignment_target?(node, child, target)) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def assignment_target?(node, child, target)
|
|
192
|
+
case node
|
|
193
|
+
when Prism::MultiWriteNode then child != node.value
|
|
194
|
+
when Prism::MultiTargetNode, Prism::SplatNode then target
|
|
195
|
+
when Prism::ForNode then child == node.index
|
|
196
|
+
when Prism::RescueNode then child == node.reference
|
|
197
|
+
else false
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def decision?(node)
|
|
202
|
+
return !node.begin_modifier? if node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode)
|
|
203
|
+
|
|
204
|
+
%i[if_node unless_node for_node
|
|
205
|
+
when_node in_node rescue_modifier_node and_node or_node].include?(node.type) ||
|
|
206
|
+
node.type.to_s.match?(/_(?:and|or)_write_node\z/) ||
|
|
207
|
+
(node.is_a?(Prism::BeginNode) && !node.rescue_clause.nil?)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def safe_navigation?(node, seen)
|
|
211
|
+
case node
|
|
212
|
+
when Prism::CallNode, Prism::CallAndWriteNode, Prism::CallOrWriteNode,
|
|
213
|
+
Prism::CallOperatorWriteNode, Prism::CallTargetNode, Prism::IndexAndWriteNode,
|
|
214
|
+
Prism::IndexOrWriteNode, Prism::IndexOperatorWriteNode, Prism::IndexTargetNode
|
|
215
|
+
return false unless node.safe_navigation?
|
|
216
|
+
|
|
217
|
+
receiver = node.receiver
|
|
218
|
+
return true unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
219
|
+
return false if seen[receiver.name]
|
|
220
|
+
|
|
221
|
+
seen[receiver.name] = true
|
|
222
|
+
else
|
|
223
|
+
false
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def iteration?(node)
|
|
228
|
+
return false unless node.is_a?(Prism::CallNode) && ITERATING_METHODS.include?(node.name)
|
|
229
|
+
|
|
230
|
+
block = node.block
|
|
231
|
+
return false unless block
|
|
232
|
+
return true unless block.is_a?(Prism::BlockNode)
|
|
233
|
+
|
|
234
|
+
parameters = block.parameters
|
|
235
|
+
!parameters || parameters.is_a?(Prism::BlockParametersNode)
|
|
67
236
|
end
|
|
68
237
|
|
|
69
238
|
def boundary?(node)
|
|
70
|
-
[Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].any?
|
|
239
|
+
[Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].any? do |type|
|
|
240
|
+
node.is_a?(type)
|
|
241
|
+
end ||
|
|
242
|
+
!defined_name(node).nil?
|
|
71
243
|
end
|
|
72
244
|
|
|
73
|
-
|
|
245
|
+
# Selects body lines without nested methods, classes, or modules.
|
|
246
|
+
#
|
|
247
|
+
# @param node [Prism::Node, nil] method body
|
|
248
|
+
# @param after [Integer] last line of the method declaration
|
|
249
|
+
# @param ending [Prism::Location] definition location, including its closing delimiter
|
|
250
|
+
# @return [Array<Integer>, nil] body lines; nil when shared with the declaration
|
|
251
|
+
def body_lines(node, after:, ending:)
|
|
74
252
|
return [] unless node
|
|
75
253
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
254
|
+
# @type var start: Prism::Node?
|
|
255
|
+
start = node.is_a?(Prism::BeginNode) && !node.begin_keyword_loc ? node.compact_child_nodes.first : node
|
|
256
|
+
return [] unless start
|
|
257
|
+
return nil if start.location.start_line <= after
|
|
258
|
+
|
|
259
|
+
excluded = [] #: Array[Integer]
|
|
260
|
+
last_line = body_end_line(node, excluded)
|
|
261
|
+
lines = (start.location.start_line..last_line).to_a - excluded
|
|
262
|
+
return nil if lines.include?(ending.end_line) && trailing_code?(ending)
|
|
263
|
+
|
|
264
|
+
lines
|
|
80
265
|
end
|
|
81
266
|
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
267
|
+
def trailing_code?(location)
|
|
268
|
+
tail = location.slice_lines.lines.last.to_s.byteslice(location.end_column..).to_s
|
|
269
|
+
!tail.match?(/\A[;\s]*(?:#|\z)/)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Heredoc interpolations can extend beyond their parent node's location.
|
|
273
|
+
def body_end_line(node, excluded)
|
|
274
|
+
last_line = node.location.end_line
|
|
275
|
+
node.compact_child_nodes.each do |child|
|
|
276
|
+
child_line = body_end_line(child, excluded)
|
|
277
|
+
last_line = child_line if child_line > last_line
|
|
87
278
|
end
|
|
279
|
+
excluded.concat((node.location.start_line..last_line).to_a) if boundary?(node)
|
|
280
|
+
last_line
|
|
88
281
|
end
|
|
89
282
|
end
|
|
90
283
|
end
|
data/lib/crap4ruby/cli.rb
CHANGED
|
@@ -3,12 +3,25 @@
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
|
|
5
5
|
module Crap4Ruby
|
|
6
|
+
# Coordinates file discovery, coverage, scoring, and CLI output.
|
|
6
7
|
class CLI
|
|
8
|
+
# Sets output destinations without running an analysis.
|
|
9
|
+
#
|
|
10
|
+
# @param stdout [#puts, #write] destination for reports, help, and version output
|
|
11
|
+
# @param stderr [#puts, #write] destination for diagnostics and coverage command output
|
|
7
12
|
def initialize(stdout: $stdout, stderr: $stderr)
|
|
8
13
|
@stdout = stdout
|
|
9
14
|
@stderr = stderr
|
|
10
15
|
end
|
|
11
16
|
|
|
17
|
+
# Runs the requested analysis and replaces the snapshot after successful scoring.
|
|
18
|
+
# Help and version requests produce output without writing a snapshot.
|
|
19
|
+
# Expected usage, input, filesystem, and command errors are printed to stderr.
|
|
20
|
+
#
|
|
21
|
+
# @param arguments [Array<String>] command-line tokens; option parsing mutates this array
|
|
22
|
+
# @return [Integer] 0 on success, 1 on error or an uncertain threshold, 2 on a measured violation
|
|
23
|
+
# @example Analyze complexity without coverage
|
|
24
|
+
# CLI.new.run(["--no-coverage", "--source-root", "lib"])
|
|
12
25
|
def run(arguments)
|
|
13
26
|
options, parser, paths = parse_options(arguments)
|
|
14
27
|
return @stdout.puts(parser) || 0 if options[:help]
|
|
@@ -20,10 +33,7 @@ module Crap4Ruby
|
|
|
20
33
|
|
|
21
34
|
files = Discovery.call(root: root, source_roots: options[:source_roots], paths: paths, changed: options[:changed])
|
|
22
35
|
coverage = load_coverage(options, root) unless options[:no_coverage] || files.empty?
|
|
23
|
-
entries = files
|
|
24
|
-
Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
|
|
25
|
-
end
|
|
26
|
-
entries = Report.sorted(entries)
|
|
36
|
+
entries = Report.sorted(analyze(files, coverage))
|
|
27
37
|
path = Report.write(entries, root: root)
|
|
28
38
|
@stdout.write(Report.table(entries))
|
|
29
39
|
@stderr.puts("Wrote #{path}")
|
|
@@ -35,22 +45,52 @@ module Crap4Ruby
|
|
|
35
45
|
|
|
36
46
|
private
|
|
37
47
|
|
|
48
|
+
def analyze(files, coverage)
|
|
49
|
+
files.flat_map do |path|
|
|
50
|
+
Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Removes parsed options from arguments and returns the remaining path selectors.
|
|
55
|
+
#
|
|
56
|
+
# @param arguments [Array<String>] mutable command-line tokens
|
|
57
|
+
# @return [Array(Hash, OptionParser, Array<String>)] options, parser, and remaining selectors
|
|
58
|
+
# @raise [OptionParser::ParseError] if an option or threshold is invalid
|
|
38
59
|
def parse_options(arguments)
|
|
60
|
+
# @type var options: options
|
|
39
61
|
options = {root: Dir.pwd, source_roots: [], changed: false, coverage: "coverage/.resultset.json"}
|
|
40
62
|
parser = OptionParser.new do |cli|
|
|
41
63
|
cli.banner = "Usage: crap4ruby [options] [file-or-directory-or-filter ...]"
|
|
42
|
-
cli.on("--root PATH", "Project root (default: current directory)")
|
|
43
|
-
|
|
64
|
+
cli.on("--root PATH", "Project root (default: current directory)") do |path|
|
|
65
|
+
# @type var path: String
|
|
66
|
+
options[:root] = path
|
|
67
|
+
end
|
|
68
|
+
cli.on("--source-root PATH", "Analyze this directory; repeat for multiple roots") do |path|
|
|
69
|
+
# @type var path: String
|
|
70
|
+
options[:source_roots] << path
|
|
71
|
+
end
|
|
44
72
|
cli.on("--changed", "Analyze Git additions and edits, including untracked files") { options[:changed] = true }
|
|
45
|
-
cli.on("--coverage PATH", "SimpleCov resultset (default: coverage/.resultset.json)")
|
|
73
|
+
cli.on("--coverage PATH", "SimpleCov resultset (default: coverage/.resultset.json)") do |path|
|
|
74
|
+
# @type var path: String
|
|
75
|
+
options[:coverage] = path
|
|
76
|
+
end
|
|
46
77
|
cli.on("--no-coverage", "Complexity only; coverage and CRAP are N/A") { options[:no_coverage] = true }
|
|
47
|
-
cli.on("--use-existing-coverage", "Read reports without running tests (the default)")
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
cli.on("--use-existing-coverage", "Read reports without running tests (the default)") do
|
|
79
|
+
options[:existing] = true
|
|
80
|
+
end
|
|
81
|
+
cli.on("--coverage-command COMMAND", "Run a shell command that refreshes the resultset") do |command|
|
|
82
|
+
# @type var command: String
|
|
83
|
+
options[:command] = command
|
|
84
|
+
end
|
|
85
|
+
cli.on("--threshold NUMBER", "Exit 2 above limit; exit 1 if unknown scores could exceed it") do |value|
|
|
86
|
+
# @type var value: String
|
|
50
87
|
number = Float(value)
|
|
51
|
-
raise
|
|
88
|
+
raise ArgumentError unless number.finite?
|
|
89
|
+
|
|
90
|
+
threshold = Rational(value, exception: false) || number.to_r
|
|
91
|
+
raise ArgumentError if threshold.negative?
|
|
52
92
|
|
|
53
|
-
options[:threshold] =
|
|
93
|
+
options[:threshold] = threshold
|
|
54
94
|
rescue ArgumentError
|
|
55
95
|
raise OptionParser::InvalidArgument, "threshold must be finite and nonnegative"
|
|
56
96
|
end
|
|
@@ -60,19 +100,36 @@ module Crap4Ruby
|
|
|
60
100
|
[options, parser, parser.parse(arguments)]
|
|
61
101
|
end
|
|
62
102
|
|
|
103
|
+
# Rejects a coverage command when coverage is disabled or explicitly read-only.
|
|
104
|
+
#
|
|
105
|
+
# @param options [Hash] parsed CLI options
|
|
106
|
+
# @return [void]
|
|
107
|
+
# @raise [Error] if coverage options conflict
|
|
63
108
|
def validate_options(options)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
109
|
+
return unless options[:command] && (options[:no_coverage] || options[:existing])
|
|
110
|
+
|
|
111
|
+
raise Error, "--coverage-command cannot be combined with --no-coverage or --use-existing-coverage"
|
|
67
112
|
end
|
|
68
113
|
|
|
114
|
+
# Checks measured scores and bounds unknown scores at zero coverage.
|
|
115
|
+
#
|
|
116
|
+
# @param entries [Array<Entry>] scored methods
|
|
117
|
+
# @param threshold [Rational, nil] maximum allowed score, or nil to disable the check
|
|
118
|
+
# @return [Integer] 2 for a measured violation, 1 if uncertain, otherwise 0
|
|
69
119
|
def threshold_status(entries, threshold)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
120
|
+
return 0 unless threshold
|
|
121
|
+
|
|
122
|
+
if entries.any? { |entry| entry.crap && entry.crap > threshold }
|
|
123
|
+
@stderr.puts("CRAP threshold exceeded")
|
|
124
|
+
return 2
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
uncertain = entries.select { |entry| !entry.crap && entry.complexity * (entry.complexity + 1) > threshold }
|
|
128
|
+
uncertain.each do |entry|
|
|
129
|
+
@stderr.puts("Cannot verify CRAP threshold: #{Report.label("#{entry.namespace}##{entry.name}")} " \
|
|
130
|
+
"has unknown coverage (maximum #{entry.complexity * (entry.complexity + 1)})")
|
|
75
131
|
end
|
|
132
|
+
uncertain.empty? ? 0 : 1
|
|
76
133
|
end
|
|
77
134
|
|
|
78
135
|
def load_coverage(options, root)
|
|
@@ -81,6 +138,15 @@ module Crap4Ruby
|
|
|
81
138
|
Coverage.load(report, root: root)
|
|
82
139
|
end
|
|
83
140
|
|
|
141
|
+
# Runs a shell command at root and forwards its combined output to stderr.
|
|
142
|
+
# Requires success and a changed report stamp before coverage can be consumed.
|
|
143
|
+
#
|
|
144
|
+
# @param command [String] command passed to `sh -c` with the caller's environment
|
|
145
|
+
# @param root [String] command working directory
|
|
146
|
+
# @param report [String] expected coverage report path
|
|
147
|
+
# @return [void]
|
|
148
|
+
# @raise [Error] if the command is empty, fails, or leaves the report stamp unchanged
|
|
149
|
+
# @raise [SystemCallError] if the command cannot start or report metadata cannot be read
|
|
84
150
|
def run_coverage(command, root, report)
|
|
85
151
|
raise Error, "--coverage-command must not be empty" if command.strip.empty?
|
|
86
152
|
|
|
@@ -90,10 +156,18 @@ module Crap4Ruby
|
|
|
90
156
|
output.each_line { |line| @stderr.write(line) }
|
|
91
157
|
process.value
|
|
92
158
|
end
|
|
93
|
-
|
|
159
|
+
unless status.success?
|
|
160
|
+
raise Error,
|
|
161
|
+
"coverage command failed (exit #{status.exitstatus || "signal #{status.termsig}"})"
|
|
162
|
+
end
|
|
94
163
|
raise Error, "coverage command did not refresh #{report}" if report_stamp(report) == before
|
|
95
164
|
end
|
|
96
165
|
|
|
166
|
+
# Captures metadata used to detect a newly generated or refreshed report.
|
|
167
|
+
#
|
|
168
|
+
# @param path [String] coverage report path
|
|
169
|
+
# @return [Array(Integer, Time, Time, Integer), nil] inode, modification time,
|
|
170
|
+
# change time, and size; nil if the path does not exist
|
|
97
171
|
def report_stamp(path)
|
|
98
172
|
stat = File.stat(path)
|
|
99
173
|
[stat.ino, stat.mtime, stat.ctime, stat.size]
|
data/lib/crap4ruby/coverage.rb
CHANGED
|
@@ -3,15 +3,28 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Crap4Ruby
|
|
6
|
+
# Merges SimpleCov line coverage across named test suites.
|
|
6
7
|
class Coverage
|
|
8
|
+
# Reads a SimpleCov resultset, accepting legacy arrays and line-coverage objects.
|
|
9
|
+
#
|
|
10
|
+
# @param path [String] JSON resultset path, relative to the working directory or absolute
|
|
11
|
+
# @param root [String] base directory for source paths inside the report
|
|
12
|
+
# @return [Coverage] merged coverage
|
|
13
|
+
# @raise [Error] if the report cannot be read, parsed, or validated
|
|
7
14
|
def self.load(path, root:)
|
|
8
15
|
new(JSON.parse(File.read(path)), root: root)
|
|
9
16
|
rescue SystemCallError => error
|
|
10
|
-
raise Error,
|
|
17
|
+
raise Error,
|
|
18
|
+
"#{path}: cannot read coverage report; generate SimpleCov coverage or use --no-coverage (#{error.message})"
|
|
11
19
|
rescue JSON::ParserError, Error => error
|
|
12
20
|
raise Error, "#{path}: invalid SimpleCov report (#{error.message})"
|
|
13
21
|
end
|
|
14
22
|
|
|
23
|
+
# Validates and merges suite reports by the maximum hit count for each line.
|
|
24
|
+
#
|
|
25
|
+
# @param suites [Hash{String => Hash}] parsed resultset keyed by suite name
|
|
26
|
+
# @param root [String] base directory for relative source paths
|
|
27
|
+
# @raise [Error] if suites or line coverage are malformed
|
|
15
28
|
def initialize(suites, root:)
|
|
16
29
|
raise Error, "expected named test suites" unless suites.is_a?(Hash) && !suites.empty?
|
|
17
30
|
|
|
@@ -19,27 +32,43 @@ module Crap4Ruby
|
|
|
19
32
|
suites.each_value { |suite| add_suite(suite, root) }
|
|
20
33
|
end
|
|
21
34
|
|
|
35
|
+
# Measures executable body lines with positive hit counts.
|
|
36
|
+
# Null and out-of-range lines do not enter the denominator.
|
|
37
|
+
#
|
|
38
|
+
# @param method [MethodInfo] method whose path and body lines select coverage
|
|
39
|
+
# @return [Rational, nil] exact fraction from 0 to 1; zero for an absent file,
|
|
40
|
+
# or nil when body lines are ambiguous or no reported lines are measurable
|
|
22
41
|
def fraction(method)
|
|
42
|
+
body = method.lines
|
|
43
|
+
return nil unless body
|
|
44
|
+
|
|
23
45
|
path = File.expand_path(method.path)
|
|
24
46
|
path = File.realpath(path) if File.exist?(path)
|
|
47
|
+
# @type var lines: Array[Integer?]?
|
|
25
48
|
lines = @files[path]
|
|
26
|
-
return 0
|
|
49
|
+
return Rational(0) unless lines
|
|
27
50
|
|
|
28
|
-
hits =
|
|
51
|
+
hits = body.filter_map { |line| lines[line - 1] }
|
|
29
52
|
return nil if hits.empty?
|
|
30
53
|
|
|
31
|
-
hits.count(&:positive?)
|
|
54
|
+
Rational(hits.count(&:positive?), hits.size)
|
|
32
55
|
end
|
|
33
56
|
|
|
34
57
|
private
|
|
35
58
|
|
|
36
59
|
def add_suite(suite, root)
|
|
37
|
-
unless suite.is_a?(Hash) && suite["coverage"].is_a?(Hash)
|
|
38
|
-
|
|
39
|
-
end
|
|
60
|
+
raise Error, "expected a coverage object for each suite" unless suite.is_a?(Hash) && suite["coverage"].is_a?(Hash)
|
|
61
|
+
|
|
40
62
|
suite["coverage"].each { |path, data| add_file(path, data, root) }
|
|
41
63
|
end
|
|
42
64
|
|
|
65
|
+
# Normalizes a source path and merges line counts across suites.
|
|
66
|
+
#
|
|
67
|
+
# @param path [String] source path from a suite report
|
|
68
|
+
# @param data [Array<Integer, nil>, Hash] legacy counts or an object with a `lines` array
|
|
69
|
+
# @param root [String] base directory for relative source paths
|
|
70
|
+
# @return [void]
|
|
71
|
+
# @raise [Error] if line counts are invalid
|
|
43
72
|
def add_file(path, data, root)
|
|
44
73
|
key = File.expand_path(path, root)
|
|
45
74
|
key = File.realpath(key) if File.exist?(key)
|
|
@@ -47,6 +76,9 @@ module Crap4Ruby
|
|
|
47
76
|
unless lines.is_a?(Array) && lines.all? { |hit| hit.nil? || (hit.is_a?(Integer) && hit >= 0) }
|
|
48
77
|
raise Error, "#{path}: expected nonnegative line counts or null"
|
|
49
78
|
end
|
|
79
|
+
|
|
80
|
+
# Every element was validated above; Steep cannot narrow an array through all?.
|
|
81
|
+
lines = lines #: Array[Integer?]
|
|
50
82
|
previous = @files[key] || []
|
|
51
83
|
@files[key] = Array.new([previous.size, lines.size].max) do |index|
|
|
52
84
|
[previous[index], lines[index]].compact.max
|
data/lib/crap4ruby/discovery.rb
CHANGED
|
@@ -4,11 +4,45 @@ require "find"
|
|
|
4
4
|
require "open3"
|
|
5
5
|
|
|
6
6
|
module Crap4Ruby
|
|
7
|
+
# Selects Ruby source files from explicit paths, directories, and Git changes.
|
|
7
8
|
module Discovery
|
|
9
|
+
# Directory names excluded during recursive traversal.
|
|
8
10
|
EXCLUDED = %w[test tests spec features vendor node_modules target coverage tmp log pkg build dist].freeze
|
|
9
11
|
|
|
12
|
+
# Combines source roots and existing paths, then applies filters and Git selection.
|
|
13
|
+
# Explicit files bypass traversal exclusions but must have the `.rb` extension.
|
|
14
|
+
# Defaults to the project root when no source roots or existing paths are given.
|
|
15
|
+
#
|
|
16
|
+
# @param root [String] absolute project directory
|
|
17
|
+
# @param source_roots [Array<String>] source directories, absolute or relative to root
|
|
18
|
+
# @param paths [Array<String>] existing paths or substring filters relative to root
|
|
19
|
+
# @param changed [Boolean] restrict results to staged, unstaged, and untracked files
|
|
20
|
+
# @return [Array<String>] sorted, unique real paths; substring filters use OR
|
|
21
|
+
# @raise [Error] if a source root is not a directory or Git fails
|
|
22
|
+
# @raise [SystemCallError] if paths cannot be read or resolved
|
|
10
23
|
def self.call(root:, source_roots:, paths:, changed:)
|
|
11
24
|
existing, filters = paths.partition { |path| File.exist?(File.expand_path(path, root)) }
|
|
25
|
+
roots = selection_roots(root, source_roots, existing)
|
|
26
|
+
files = roots.flat_map { |path| File.file?(path) ? [path] : walk(path) }
|
|
27
|
+
files = files.select { |path| File.extname(path) == ".rb" }.map { |path| File.realpath(path) }.uniq.sort
|
|
28
|
+
unless filters.empty?
|
|
29
|
+
files.select! do |path|
|
|
30
|
+
filters.any? do |filter|
|
|
31
|
+
path.delete_prefix("#{root}/").include?(filter)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
files &= changed_files(root) if changed
|
|
36
|
+
files
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Validates directory roots and combines them with explicit paths.
|
|
40
|
+
#
|
|
41
|
+
# @param root [String] absolute project directory
|
|
42
|
+
# @param source_roots [Array<String>] explicit source directories
|
|
43
|
+
# @param existing [Array<String>] existing positional paths
|
|
44
|
+
# @return [Array<String>] expanded roots, defaulting to the project directory
|
|
45
|
+
def self.selection_roots(root, source_roots, existing)
|
|
12
46
|
roots = source_roots.map do |path|
|
|
13
47
|
directory = File.expand_path(path, root)
|
|
14
48
|
raise Error, "#{path}: source root must be a directory" unless File.directory?(directory)
|
|
@@ -17,15 +51,17 @@ module Crap4Ruby
|
|
|
17
51
|
end
|
|
18
52
|
roots.concat(existing.map { |path| File.expand_path(path, root) })
|
|
19
53
|
roots << root if roots.empty?
|
|
20
|
-
|
|
21
|
-
files = files.select { |path| File.extname(path) == ".rb" }.map { |path| File.realpath(path) }.uniq.sort
|
|
22
|
-
files.select! { |path| filters.any? { |filter| path.delete_prefix("#{root}/").include?(filter) } } unless filters.empty?
|
|
23
|
-
files &= changed_files(root) if changed
|
|
24
|
-
files
|
|
54
|
+
roots
|
|
25
55
|
end
|
|
26
56
|
|
|
57
|
+
# Walks a directory without following symlinks or entering excluded directories.
|
|
58
|
+
# Hidden files and test/spec filenames are excluded; extensions are not filtered.
|
|
59
|
+
#
|
|
60
|
+
# @param root [String] directory to traverse, including an explicitly selected hidden directory
|
|
61
|
+
# @return [Array<String>] candidate file paths in traversal order
|
|
62
|
+
# @raise [SystemCallError] if traversal fails
|
|
27
63
|
def self.walk(root)
|
|
28
|
-
paths = []
|
|
64
|
+
paths = [] #: Array[String]
|
|
29
65
|
Find.find(root) do |path|
|
|
30
66
|
name = File.basename(path)
|
|
31
67
|
if File.directory?(path)
|
|
@@ -37,6 +73,13 @@ module Crap4Ruby
|
|
|
37
73
|
paths
|
|
38
74
|
end
|
|
39
75
|
|
|
76
|
+
# Runs Git in the project directory without a shell.
|
|
77
|
+
#
|
|
78
|
+
# @param root [String] working directory for Git
|
|
79
|
+
# @param arguments [Array<String>] command and arguments
|
|
80
|
+
# @return [String] standard output, preserving NUL separators
|
|
81
|
+
# @raise [Error] if Git exits unsuccessfully
|
|
82
|
+
# @raise [SystemCallError] if Git cannot be started
|
|
40
83
|
def self.git(root, *arguments)
|
|
41
84
|
output, error, status = Open3.capture3("git", "-C", root, *arguments)
|
|
42
85
|
raise Error, "git: #{error.strip}" unless status.success?
|
|
@@ -44,11 +87,18 @@ module Crap4Ruby
|
|
|
44
87
|
output
|
|
45
88
|
end
|
|
46
89
|
|
|
90
|
+
# Finds existing staged, unstaged, and untracked files under the project directory.
|
|
91
|
+
# Deleted files are omitted and renames are treated as deletion and addition.
|
|
92
|
+
#
|
|
93
|
+
# @param root [String] directory inside a Git working tree
|
|
94
|
+
# @return [Array<String>] real paths, without extension or traversal filtering
|
|
95
|
+
# @raise [Error] if Git cannot inspect the working tree
|
|
96
|
+
# @raise [SystemCallError] if Git cannot start or a path cannot be resolved
|
|
47
97
|
def self.changed_files(root)
|
|
48
98
|
top = git(root, "rev-parse", "--show-toplevel").chomp
|
|
49
99
|
git(root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--", ".")
|
|
50
100
|
.split("\0").filter_map do |entry|
|
|
51
|
-
path = File.join(top, entry[3..])
|
|
101
|
+
path = File.join(top, entry[3..].to_s)
|
|
52
102
|
File.realpath(path) if File.file?(path)
|
|
53
103
|
end
|
|
54
104
|
end
|