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.
@@ -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
- owner = qualify(node.constant_path, scope, declaration: true)
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 = node.receiver && !node.receiver.is_a?(Prism::SelfNode) ? qualify(node.receiver, scope) : scope
116
+ owner = method_owner(node, scope)
35
117
  return [] unless owner
36
118
 
37
- name = "#{'self.' if singleton || node.receiver}#{node.name}"
38
- lines = body_lines(node.body)
39
- complexity = 1 + decisions(node.parameters) + decisions(node.body)
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
- (node.parent ? qualify(node.parent, scope) : [])&.+([node.name.to_s])
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
- def decisions(node)
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
- decision = [:if_node, :unless_node, :while_node, :until_node, :for_node,
62
- :when_node, :in_node, :rescue_node, :rescue_modifier_node, :and_node, :or_node].include?(node.type) ||
63
- node.type.to_s.match?(/_(?:and|or)_write_node\z/)
64
- count = decision ? 1 : 0
65
- count += 1 if node.respond_to?(:safe_navigation?) && node.safe_navigation?
66
- count + node.compact_child_nodes.sum { |child| decisions(child) }
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? { |type| node.is_a?(type) }
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
- def body_lines(node)
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
- lines = (node.location.start_line..node.location.end_line).to_a
77
- excluded = []
78
- collect_boundaries(node, excluded)
79
- lines - excluded
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 collect_boundaries(node, excluded)
83
- if boundary?(node)
84
- excluded.concat((node.location.start_line..node.location.end_line).to_a)
85
- else
86
- node.compact_child_nodes.each { |child| collect_boundaries(child, excluded) }
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.flat_map do |path|
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)") { |path| options[:root] = path }
43
- cli.on("--source-root PATH", "Analyze this directory; repeat for multiple roots") { |path| options[:source_roots] << path }
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)") { |path| options[:coverage] = path }
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)") { options[:existing] = true }
48
- cli.on("--coverage-command COMMAND", "Run a shell command that refreshes the resultset") { |command| options[:command] = command }
49
- cli.on("--threshold NUMBER", "Exit 2 when CRAP exceeds this value") do |value|
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 OptionParser::InvalidArgument, "threshold must be finite and nonnegative" unless number.finite? && number >= 0
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] = number
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
- if options[:command] && (options[:no_coverage] || options[:existing])
65
- raise Error, "--coverage-command cannot be combined with --no-coverage or --use-existing-coverage"
66
- end
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
- if threshold && entries.any? { |entry| entry.crap && entry.crap > threshold }
71
- @stderr.puts("CRAP threshold exceeded: #{threshold}")
72
- 2
73
- else
74
- 0
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
- raise Error, "coverage command failed (exit #{status.exitstatus || "signal #{status.termsig}"})" unless status.success?
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]
@@ -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, "#{path}: cannot read coverage report; generate SimpleCov coverage or use --no-coverage (#{error.message})"
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.0 unless lines
49
+ return Rational(0) unless lines
27
50
 
28
- hits = method.lines.filter_map { |line| lines[line - 1] }
51
+ hits = body.filter_map { |line| lines[line - 1] }
29
52
  return nil if hits.empty?
30
53
 
31
- hits.count(&:positive?).fdiv(hits.size)
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
- raise Error, "expected a coverage object for each suite"
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
@@ -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
- files = roots.flat_map { |path| File.file?(path) ? [path] : walk(path) }
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