mutineer 0.6.2 → 0.8.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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "set"
4
5
  require_relative "parser"
5
6
  require_relative "subject"
6
7
 
@@ -8,12 +9,17 @@ module Mutineer
8
9
  # Subject discovery: parse each path and walk its AST for method definitions,
9
10
  # tracking the enclosing class/module namespace.
10
11
  class Project
11
- # Returns Array<Subject>. `only` filters by qualified name (string equality).
12
+ # Discovers subjects from source paths.
13
+ #
14
+ # @param paths [Array<String>] source file paths.
15
+ # @param only [String, nil] optional qualified-name filter.
16
+ # @return [Array<Mutineer::Subject>] discovered subjects.
12
17
  def self.discover(paths, only: nil)
13
18
  subjects = Array(paths).flat_map do |path|
14
19
  result = Parser.parse_file(path)
15
20
  visitor = SubjectVisitor.new(path)
16
21
  visitor.visit(result.value)
22
+ visitor.promote_module_functions!
17
23
  visitor.subjects
18
24
  end
19
25
  only ? subjects.select { |s| s.qualified_name == only } : subjects
@@ -24,31 +30,87 @@ module Mutineer
24
30
  class SubjectVisitor < Prism::Visitor
25
31
  attr_reader :subjects
26
32
 
33
+ # Builds a subject visitor.
34
+ #
35
+ # @param file [String] source file path being visited.
27
36
  def initialize(file)
28
37
  @file = file
29
38
  @namespace_stack = []
30
39
  @subjects = []
31
40
  @singleton_depth = 0
41
+ @module_function_active = false # bareword `module_function` seen in this module body
42
+ @module_function_names = [] # names from `module_function :a, :b` / `module_function def`
32
43
  super()
33
44
  end
34
45
 
46
+ # Promote `module_function :name` / `module_function def name` subjects to
47
+ # singleton after the full walk — the naming call may appear before or after
48
+ # the def, so it can't be decided at visit_def_node time (#20).
49
+ #
50
+ # @return [void]
51
+ def promote_module_functions!
52
+ return if @module_function_names.empty?
53
+
54
+ names = @module_function_names.to_set
55
+ @subjects.each { |s| s.singleton = true if names.include?(s.name) }
56
+ end
57
+
58
+ # Visits class nodes and tracks namespace nesting.
59
+ #
60
+ # @param node [Prism::ClassNode] class node.
61
+ # @return [void]
35
62
  def visit_class_node(node)
36
63
  @namespace_stack.push(extract_constant_name(node.constant_path))
64
+ saved = @module_function_active
65
+ @module_function_active = false # module_function state does not cross a class boundary
37
66
  super
67
+ @module_function_active = saved
38
68
  @namespace_stack.pop
39
69
  end
40
70
 
71
+ # Visits module nodes and tracks namespace nesting.
72
+ #
73
+ # @param node [Prism::ModuleNode] module node.
74
+ # @return [void]
41
75
  def visit_module_node(node)
42
76
  @namespace_stack.push(extract_constant_name(node.constant_path))
77
+ saved = @module_function_active
78
+ @module_function_active = false # each module body starts without module_function active
43
79
  super
80
+ @module_function_active = saved
44
81
  @namespace_stack.pop
45
82
  end
46
83
 
84
+ # Track `module_function` so its methods are recorded as singletons (#20) —
85
+ # the called form is the singleton method on the module object. Bareword
86
+ # `module_function` flips all SUBSEQUENT defs in this body; the argument
87
+ # forms (`:sym`, `def`) name methods promoted after the walk.
88
+ #
89
+ # @param node [Prism::CallNode] call node.
90
+ # @return [void]
91
+ def visit_call_node(node)
92
+ if node.name == :module_function && node.receiver.nil?
93
+ args = node.arguments&.arguments || []
94
+ if args.empty?
95
+ @module_function_active = true
96
+ else
97
+ args.each do |arg|
98
+ @module_function_names << arg.value.to_sym if arg.is_a?(Prism::SymbolNode)
99
+ @module_function_names << arg.name if arg.is_a?(Prism::DefNode)
100
+ end
101
+ end
102
+ end
103
+ super
104
+ end
105
+
47
106
  # Methods inside `class << self` are class methods of the enclosing
48
107
  # namespace, but their def nodes have no receiver — track the singleton
49
108
  # context so they're recorded as singleton (so redefine targets the
50
109
  # singleton_class, not instances). `class << some_other_obj` can't be
51
110
  # represented against the namespace, so its defs are skipped (not recursed).
111
+ #
112
+ # @param node [Prism::SingletonClassNode] singleton-class node.
113
+ # @return [void]
52
114
  def visit_singleton_class_node(node)
53
115
  return unless node.expression.is_a?(Prism::SelfNode)
54
116
 
@@ -57,12 +119,16 @@ module Mutineer
57
119
  @singleton_depth -= 1
58
120
  end
59
121
 
122
+ # Records a discovered method definition.
123
+ #
124
+ # @param node [Prism::DefNode] method definition node.
125
+ # @return [void]
60
126
  def visit_def_node(node)
61
127
  @subjects << Subject.new(
62
128
  file: @file,
63
129
  namespace: @namespace_stack.dup,
64
130
  name: node.name,
65
- singleton: !node.receiver.nil? || @singleton_depth.positive?,
131
+ singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
66
132
  def_node: node
67
133
  )
68
134
  super
@@ -70,6 +136,11 @@ module Mutineer
70
136
 
71
137
  private
72
138
 
139
+ # Extracts a constant name from a Prism constant node.
140
+ #
141
+ # @api private
142
+ # @param node [Prism::Node] constant node.
143
+ # @return [String, nil] constant name.
73
144
  def extract_constant_name(node)
74
145
  case node
75
146
  when Prism::ConstantReadNode
@@ -21,13 +21,14 @@ module Mutineer
21
21
  # Single entry point (R20/R21). Branches on `format` ("human" | "json") and
22
22
  # routes the rendered report to `output` (a file, with a stderr confirmation)
23
23
  # or to `out`. Diagnostics always go to `err`.
24
- def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil)
24
+ def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil)
25
25
  rendered =
26
26
  if format == "json"
27
- json_report
27
+ json_report(baseline)
28
28
  else
29
29
  sio = StringIO.new
30
30
  human_report(sio, err, threshold)
31
+ baseline_section(sio, baseline) if baseline
31
32
  sio.string
32
33
  end
33
34
 
@@ -40,6 +41,12 @@ module Mutineer
40
41
  end
41
42
  end
42
43
 
44
+ # Renders the human report.
45
+ #
46
+ # @param out [IO] output stream.
47
+ # @param err [IO] error stream.
48
+ # @param threshold [Float] score threshold.
49
+ # @return [void]
43
50
  def human_report(out, err, threshold)
44
51
  if @agg.total.zero?
45
52
  err.puts "No mutations generated — verify target files contain in-scope " \
@@ -53,6 +60,7 @@ module Mutineer
53
60
  summary(out)
54
61
  out.puts
55
62
  score_line(out, err)
63
+ per_source(out)
56
64
 
57
65
  survivors(out)
58
66
  verdict(out, threshold) if threshold && threshold.positive?
@@ -73,7 +81,12 @@ module Mutineer
73
81
  # Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
74
82
  # by (file, line, operator) so output is byte-stable regardless of --jobs
75
83
  # worker finish order (R22).
76
- def json_report
84
+ # Renders the JSON report.
85
+ #
86
+ # @api private
87
+ # @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
88
+ # @return [String] JSON text.
89
+ def json_report(baseline = nil)
77
90
  killed = @agg.killed_count
78
91
  survived = @agg.survived_count
79
92
  # C8: null (not 0.0) on an empty denominator, matching the nil-vs-0.0
@@ -82,27 +95,81 @@ module Mutineer
82
95
  score = @agg.mutation_score
83
96
 
84
97
  doc = {
85
- schema_version: "1.0",
98
+ schema_version: "1.1",
86
99
  summary: {
87
100
  total: @agg.total, killed: killed, survived: survived,
88
101
  no_coverage: @agg.no_coverage_count,
102
+ uncapturable: @agg.uncapturable_count,
89
103
  skipped_invalid: @agg.skipped_invalid_count,
90
104
  errored: @agg.errored_count, timeout: @agg.timeout_count,
105
+ ignored: @agg.ignored_count,
91
106
  score: score
92
107
  },
93
108
  survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
94
109
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
95
110
  no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
96
- .sort_by { |h| [h[:file], h[:line]] }
111
+ .sort_by { |h| [h[:file], h[:line]] },
112
+ # #9: same shape as no_coverage; additive key.
113
+ uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
114
+ .sort_by { |h| [h[:file], h[:line]] },
115
+ # #10: equivalent mutants the user suppressed — emitted with their stable
116
+ # id so the user can audit what is silenced (and copy ids for survivors
117
+ # they want to add). Excluded from the score; never in `survivors`.
118
+ ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
119
+ .sort_by { |h| [h[:file], h[:line], h[:operator]] },
120
+ # #11: per-source breakdown (additive; #13 consumes it). Sorted by file so
121
+ # output is byte-stable. Reuses AggregateResult via by_source.
122
+ per_source: @agg.by_source.map { |file, agg| per_source_json(file, agg) }
123
+ .sort_by { |h| h[:file] }
97
124
  }
125
+ # #13: additive baseline-delta block, present only with --baseline. Existing
126
+ # consumers ignore the extra key; schema_version stays 1.1.
127
+ doc[:baseline] = baseline_json(baseline) if baseline
98
128
  "#{JSON.generate(doc)}\n"
99
129
  end
100
130
 
131
+ # #13: the same delta facts the human report prints, for dashboards. new_survivors
132
+ # reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
133
+ # byte-stably so output doesn't depend on --jobs finish order.
134
+ def baseline_json(delta)
135
+ {
136
+ regressed: delta.regressed,
137
+ score_before: delta.score_before,
138
+ score_after: delta.score_after,
139
+ score_dropped: delta.score_drop,
140
+ new_survivors: delta.new_survivors.map { |r| ignored_json(r) }
141
+ .sort_by { |h| [h[:file], h[:line], h[:operator]] },
142
+ fixed_survivors: delta.fixed_survivors.map do |h|
143
+ { subject: h["subject"], file: h["file"], line: h["line"],
144
+ operator: h["operator"], id: h["id"] }
145
+ end.sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:operator].to_s] }
146
+ }
147
+ end
148
+
149
+ # Builds per-source JSON.
150
+ #
151
+ # @api private
152
+ # @param file [String] source file path.
153
+ # @param agg [Mutineer::AggregateResult] source aggregate.
154
+ # @return [Hash] per-source JSON object.
155
+ def per_source_json(file, agg)
156
+ {
157
+ file: file, total: agg.total,
158
+ killed: agg.killed_count, survived: agg.survived_count,
159
+ no_coverage: agg.no_coverage_count, score: agg.mutation_score
160
+ }
161
+ end
162
+
163
+ # Builds survivor JSON.
164
+ #
165
+ # @api private
166
+ # @param result [Mutineer::Result] survivor result.
167
+ # @return [Hash] survivor JSON object.
101
168
  def survivor_json(result)
102
169
  m = result.mutation
103
170
  file = result.subject.file
104
171
  source = @source_map[file] || File.read(file)
105
- start_line, original_block, mutated_block, = diff_for(m, source)
172
+ start_line, original_block, mutated_block, token = diff_for(m, source)
106
173
  minus = original_block.each_line.map { |l| "-#{l.chomp}" }.join("\n")
107
174
  plus = mutated_block.each_line.map { |l| "+#{l.chomp}" }.join("\n")
108
175
  {
@@ -110,10 +177,30 @@ module Mutineer
110
177
  file: file,
111
178
  line: start_line,
112
179
  operator: m.operator.to_s,
180
+ # #10: the stable, copy-pasteable id (next to the human-readable token) so a
181
+ # user can paste it straight into .mutineer.yml `ignore:`.
182
+ id: result.id,
183
+ token: token,
113
184
  diff: "--- a/#{file}\n+++ b/#{file}\n@@ -#{start_line} +#{start_line} @@\n#{minus}\n#{plus}\n"
114
185
  }
115
186
  end
116
187
 
188
+ # An entry under the JSON `ignored:` key — what the user already suppressed.
189
+ def ignored_json(result)
190
+ m = result.mutation
191
+ file = result.subject.file
192
+ source = @source_map[file] || File.read(file)
193
+ start_line, _orig, _mut, token = diff_for(m, source)
194
+ {
195
+ subject: result.subject.qualified_name,
196
+ file: file,
197
+ line: start_line,
198
+ operator: m.operator.to_s,
199
+ token: token,
200
+ id: result.id
201
+ }
202
+ end
203
+
117
204
  # Builds a line-aligned diff for a mutation whose byte range may span several
118
205
  # lines (e.g. statement-removal of a multi-line statement). Returns the
119
206
  # mutation's 1-based start line, the full original line-block it touches, the
@@ -133,6 +220,11 @@ module Mutineer
133
220
  [start_line, original_block, mutated_block, token]
134
221
  end
135
222
 
223
+ # Builds no-coverage JSON.
224
+ #
225
+ # @api private
226
+ # @param result [Mutineer::Result] result object.
227
+ # @return [Hash] no-coverage JSON object.
136
228
  def no_coverage_json(result)
137
229
  m = result.mutation
138
230
  file = result.subject.file
@@ -144,6 +236,10 @@ module Mutineer
144
236
  }
145
237
  end
146
238
 
239
+ # Writes the summary block.
240
+ #
241
+ # @param out [IO] output stream.
242
+ # @return [void]
147
243
  def summary(out)
148
244
  out.puts "Summary"
149
245
  out.puts "-------"
@@ -151,12 +247,23 @@ module Mutineer
151
247
  out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
152
248
  out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count,
153
249
  @agg.errored_count + @agg.timeout_count)
250
+ # #9: a broken harness, not a coverage gap — report it distinctly from No coverage.
251
+ out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
252
+ # #10: equivalent mutants the user suppressed; excluded from the denominator.
253
+ out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
154
254
  end
155
255
 
256
+ # Writes the score line.
257
+ #
258
+ # @param out [IO] output stream.
259
+ # @param err [IO] error stream.
260
+ # @return [void]
156
261
  def score_line(out, err)
157
262
  score = @agg.mutation_score
158
- excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.skipped_invalid_count} skipped, " \
159
- "#{@agg.errored_count + @agg.timeout_count} errored excluded"
263
+ excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
264
+ "#{@agg.skipped_invalid_count} skipped, " \
265
+ "#{@agg.errored_count + @agg.timeout_count} errored, " \
266
+ "#{@agg.ignored_count} ignored excluded"
160
267
  if score.nil?
161
268
  out.puts "Mutation score: N/A (no covered mutants)"
162
269
  err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
@@ -165,6 +272,52 @@ module Mutineer
165
272
  end
166
273
  end
167
274
 
275
+ # #11: one line per source after the global summary, so a multi-source run
276
+ # shows which file is weak. Omitted for a single-source run — the global
277
+ # summary already says everything (ponytail: no redundant one-line block).
278
+ # Writes the per-source breakdown.
279
+ #
280
+ # @param out [IO] output stream.
281
+ # @return [void]
282
+ def per_source(out)
283
+ sources = @agg.by_source
284
+ return if sources.size <= 1
285
+
286
+ out.puts
287
+ out.puts "Per-source"
288
+ out.puts "----------"
289
+ sources.sort.each do |file, agg|
290
+ score = agg.mutation_score
291
+ out.puts format("%s %s (%d killed / %d survived / %d no-cov)",
292
+ file, score.nil? ? "N/A" : "#{score}%",
293
+ agg.killed_count, agg.survived_count, agg.no_coverage_count)
294
+ end
295
+ end
296
+
297
+ # #13: the --baseline delta, appended after the normal report. Names every NEW
298
+ # survivor (subject (file:line) operator) and the score delta when it dropped,
299
+ # then a one-line REGRESSION/OK verdict so CI logs show which gate fired.
300
+ def baseline_section(out, delta)
301
+ out.puts
302
+ out.puts "Baseline comparison"
303
+ out.puts "-------------------"
304
+ out.puts "killed #{@agg.killed_count}, #{delta.new_survivors.size} new survivors vs baseline"
305
+ delta.new_survivors
306
+ .sort_by { |r| [r.subject.file, r.mutation.start_offset] }
307
+ .each do |r|
308
+ file = r.subject.file
309
+ source = @source_map[file] || File.read(file)
310
+ line, = diff_for(r.mutation, source)
311
+ out.puts " + #{r.subject.qualified_name} (#{file}:#{line}) #{r.mutation.operator}"
312
+ end
313
+ out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
314
+ out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
315
+ end
316
+
317
+ # Writes the survivors block.
318
+ #
319
+ # @param out [IO] output stream.
320
+ # @return [void]
168
321
  def survivors(out)
169
322
  mutants = @agg.surviving_mutants
170
323
  return if mutants.empty?
@@ -179,6 +332,12 @@ module Mutineer
179
332
  end
180
333
  end
181
334
 
335
+ # Writes one survivor entry.
336
+ #
337
+ # @param out [IO] output stream.
338
+ # @param file [String] source file path.
339
+ # @param result [Mutineer::Result] survivor result.
340
+ # @return [void]
182
341
  def survivor(out, file, result)
183
342
  m = result.mutation
184
343
  source = @source_map[file] || File.read(file)
@@ -190,6 +349,11 @@ module Mutineer
190
349
  mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
191
350
  end
192
351
 
352
+ # Writes the final verdict line.
353
+ #
354
+ # @param out [IO] output stream.
355
+ # @param threshold [Float] score threshold.
356
+ # @return [void]
193
357
  def verdict(out, threshold)
194
358
  score = @agg.mutation_score
195
359
  return if score.nil?
@@ -1,77 +1,173 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # Immutable outcome of running one mutant. Six distinct states:
5
- # killed — a test failed/errored, so the mutation was caught.
6
- # survived — every test passed, so the mutation went undetected.
7
- # error — the child crashed (unhandled exception): exit status 2.
8
- # timeout — the parent SIGKILLed a child that overran its wall clock.
9
- # skipped — the mutated source failed to re-parse (invalid); no fork.
10
- # no_coverage — no test exercises the mutated line; not run, not scored.
4
+ # Immutable outcome of running one mutant. Seven distinct states:
5
+ # killed — a test failed/errored, so the mutation was caught.
6
+ # survived — every test passed, so the mutation went undetected.
7
+ # error — the child crashed (unhandled exception): exit status 2.
8
+ # timeout — the parent SIGKILLed a child that overran its wall clock.
9
+ # skipped — the mutated source failed to re-parse (invalid); no fork.
10
+ # no_coverage — no test exercises the mutated line; not run, not scored.
11
+ # uncapturable — the line's would-be covering test errored during capture
12
+ # (#9), so coverage was lost. Excluded from the
13
+ # denominator exactly like no_coverage, but reported
14
+ # separately: it signals a broken harness (a test that
15
+ # failed to run), not a genuine coverage gap.
16
+ # ignored — a known-equivalent mutant the user suppressed (#10), via
17
+ # an inline `# mutineer:disable-line` comment or a
18
+ # `.mutineer.yml` `ignore:` id. A pre-fork classification
19
+ # (never run); excluded from the denominator so a strong
20
+ # file can reach 100%.
11
21
  #
12
22
  # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
13
23
  # validity failure (counted separately by the reporter), error is a runtime
14
- # crash. Never conflate them via `details` string parsing. `no_coverage` is a
15
- # pre-fork selection result (M3): excluded from the score denominator.
24
+ # crash. Never conflate them via `details` string parsing. `no_coverage` and
25
+ # `uncapturable` are pre-fork selection results (M3/#9): both excluded from
26
+ # the score denominator.
16
27
  #
17
- # `subject` and `mutation` are nil when the Result is built by Isolation/Runner
18
- # (which only know the outcome); the orchestrator attaches them afterwards via
19
- # `result.with(subject:, mutation:)` so the Reporter can render survivor diffs.
20
- Result = Data.define(:status, :details, :subject, :mutation) do
21
- def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil)
22
- def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil)
23
- def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil)
24
- def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil)
25
- def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil)
26
- def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil)
27
-
28
- def killed? = status == :killed
29
- def survived? = status == :survived
30
- def error? = status == :error
31
- def timeout? = status == :timeout
32
- def skipped? = status == :skipped
33
- def no_coverage? = status == :no_coverage
28
+ # `subject`, `mutation`, and `id` are nil when the Result is built by
29
+ # Isolation/Runner (which only know the outcome); the orchestrator attaches
30
+ # them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
31
+ # can render survivor diffs and emit the stable id. `id` is the content-based
32
+ # MutantId (#10).
33
+ Result = Data.define(:status, :details, :subject, :mutation, :id) do
34
+ # Builds a killed result.
35
+ #
36
+ # @return [Mutineer::Result] killed result.
37
+ def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
38
+
39
+ # Builds a survived result.
40
+ #
41
+ # @return [Mutineer::Result] survived result.
42
+ def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
43
+
44
+ # Builds an error result.
45
+ #
46
+ # @param details [String, nil] error details.
47
+ # @return [Mutineer::Result] error result.
48
+ def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
49
+
50
+ # Builds a timeout result.
51
+ #
52
+ # @return [Mutineer::Result] timeout result.
53
+ def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
54
+
55
+ # Builds a skipped result.
56
+ #
57
+ # @param details [String, nil] skip details.
58
+ # @return [Mutineer::Result] skipped result.
59
+ def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
60
+
61
+ # Builds a no_coverage result.
62
+ #
63
+ # @return [Mutineer::Result] no-coverage result.
64
+ def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
65
+
66
+ # Builds an uncapturable result.
67
+ #
68
+ # @return [Mutineer::Result] uncapturable result.
69
+ def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
70
+
71
+ # Builds an ignored result.
72
+ #
73
+ # @return [Mutineer::Result] ignored result.
74
+ def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
75
+
76
+ # @return [Boolean] true when the status is killed.
77
+ def killed? = status == :killed
78
+ # @return [Boolean] true when the status is survived.
79
+ def survived? = status == :survived
80
+ # @return [Boolean] true when the status is error.
81
+ def error? = status == :error
82
+ # @return [Boolean] true when the status is timeout.
83
+ def timeout? = status == :timeout
84
+ # @return [Boolean] true when the status is skipped.
85
+ def skipped? = status == :skipped
86
+ # @return [Boolean] true when the status is no_coverage.
87
+ def no_coverage? = status == :no_coverage
88
+ # @return [Boolean] true when the status is uncapturable.
89
+ def uncapturable? = status == :uncapturable
90
+ # @return [Boolean] true when the status is ignored.
91
+ def ignored? = status == :ignored
92
+
34
93
  end
35
94
 
36
95
  # Aggregates a flat list of Results into counts, the mutation score, and the
37
96
  # surviving-mutant list. The score denominator is killed + survived ONLY
38
- # (KTD-4): no-coverage, skipped (invalid), errored, and timeout are each
39
- # excluded and surfaced separately. An empty denominator yields a nil score
40
- # (rendered "N/A"), never 0.0 distinguishing "no testable mutants" from
41
- # "0% killed".
97
+ # (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
98
+ # and ignored (#10 equivalent-mutant suppression) are each excluded and
99
+ # surfaced separately so suppressing every survivor reaches 100%. An empty
100
+ # denominator yields a nil score (rendered "N/A"), never 0.0
101
+ # distinguishing "no testable mutants" from "0% killed".
42
102
  class AggregateResult
43
103
  attr_reader :results
44
104
 
105
+ # Builds an aggregate from results.
106
+ #
107
+ # @param results [Array<Mutineer::Result>] classified results.
45
108
  def initialize(results)
46
109
  @results = results
47
110
  @by_status = results.group_by(&:status)
48
111
  end
49
112
 
113
+ # @return [Integer] killed count.
50
114
  def killed_count = count(:killed)
115
+ # @return [Integer] survived count.
51
116
  def survived_count = count(:survived)
117
+ # @return [Integer] no-coverage count.
52
118
  def no_coverage_count = count(:no_coverage)
119
+ # @return [Integer] uncapturable count.
120
+ def uncapturable_count = count(:uncapturable)
121
+ # @return [Integer] skipped-invalid count.
53
122
  def skipped_invalid_count = count(:skipped)
123
+ # @return [Integer] errored count.
54
124
  def errored_count = count(:error)
125
+ # @return [Integer] timeout count.
55
126
  def timeout_count = count(:timeout)
127
+ # @return [Integer] ignored count.
128
+ def ignored_count = count(:ignored)
56
129
 
57
130
  # Every generated, classified mutation. NOT the score denominator.
131
+ #
132
+ # @return [Integer] total result count.
58
133
  def total = @results.size
59
134
 
60
135
  # The score denominator (also shown to the reader).
136
+ #
137
+ # @return [Integer] killed plus survived.
61
138
  def covered_count = killed_count + survived_count
62
139
 
63
- # killed / (killed + survived) as a rounded percentage, or nil when nothing
64
- # was testable.
140
+ # Computes the mutation score.
141
+ #
142
+ # @return [Float, nil] score percentage or nil when nothing was testable.
65
143
  def mutation_score
66
144
  return nil if covered_count.zero?
67
145
 
68
146
  (killed_count.to_f / covered_count * 100).round(1)
69
147
  end
70
148
 
149
+ # Returns the surviving mutants.
150
+ #
151
+ # @return [Array<Mutineer::Result>] surviving results.
71
152
  def surviving_mutants = @results.select(&:survived?)
72
153
 
154
+ # #11: split into { source_file => AggregateResult } so the Reporter
155
+ # (per-source breakdown) and #13 (per-source roll-up / baseline diff) can
156
+ # reuse the same aggregate math.
157
+ #
158
+ # @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
159
+ def by_source
160
+ @results.select { |r| r.subject }
161
+ .group_by { |r| r.subject.file }
162
+ .transform_values { |rs| AggregateResult.new(rs) }
163
+ end
164
+
73
165
  private
74
166
 
167
+ # Counts results for a status.
168
+ #
169
+ # @param status [Symbol] result status.
170
+ # @return [Integer] count for that status.
75
171
  def count(status) = (@by_status[status] || []).size
76
172
  end
77
173
  end