mutineer 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.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Condition-negation operator (Tier 2, OFF by default). Wraps an if/unless/
8
+ # ternary condition in `!( ... )` textually. Ruby ternaries parse as IfNode in
9
+ # Prism, so visit_if_node covers them too (R12). The standard validity re-parse
10
+ # downstream discards any wrap that fails to round-trip (R14).
11
+ #
12
+ # Clean-room: from the spec's operator description, not the mutant gem.
13
+ class ConditionNegation < Base
14
+ def visit_if_node(node)
15
+ wrap(node.predicate)
16
+ super
17
+ end
18
+
19
+ def visit_unless_node(node)
20
+ wrap(node.predicate)
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ def wrap(predicate)
27
+ return unless predicate
28
+
29
+ loc = predicate.location
30
+ original = @source.byteslice(loc.start_offset...loc.end_offset)
31
+ @mutations << Mutation.new(
32
+ start_offset: loc.start_offset,
33
+ end_offset: loc.end_offset,
34
+ replacement: "!( #{original} )",
35
+ operator: :condition_negation
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Literal-fuzzing operator (Tier 2, OFF by default). Integers emit up to
8
+ # three mutations (0, 1, n+1) with no-op guards for 0 and 1; strings collapse
9
+ # to "" unless already empty. One mutation per emitted candidate (R11).
10
+ #
11
+ # Clean-room: from the spec's operator description, not the mutant gem.
12
+ class LiteralMutation < Base
13
+ def visit_integer_node(node)
14
+ n = node.value
15
+ emit(node.location, "0") unless n.zero?
16
+ emit(node.location, "1") unless n == 1
17
+ emit(node.location, (n + 1).to_s)
18
+ super
19
+ end
20
+
21
+ def visit_string_node(node)
22
+ loc = node.location
23
+ token = @source.byteslice(loc.start_offset...loc.end_offset)
24
+ emit(loc, '""') unless token == '""' || token == "''" || node.unescaped.empty?
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def emit(loc, replacement)
31
+ @mutations << Mutation.new(
32
+ start_offset: loc.start_offset,
33
+ end_offset: loc.end_offset,
34
+ replacement: replacement,
35
+ operator: :literal_mutation
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Return-value-nil operator (Tier 2, OFF by default). Two rules:
8
+ # 1. an explicit `return <expr>` -> `return nil`, unless the value is
9
+ # already nil (no-op guard).
10
+ # 2. a method body whose final expression is neither a ReturnNode nor a
11
+ # NilNode -> that expression becomes `nil`.
12
+ # Nested defs are their own subjects, so we never descend into them (R10).
13
+ #
14
+ # Clean-room: from the spec's operator description, not the mutant gem.
15
+ class ReturnNil < Base
16
+ def mutations_for(subject, source)
17
+ @source = source
18
+ @mutations = []
19
+ body = subject.def_node.body
20
+ if body
21
+ body.accept(self) # rule 1 (explicit return nodes in this body)
22
+ final_expression_nil(body) # rule 2 (method's final expression)
23
+ end
24
+ @mutations
25
+ end
26
+
27
+ def visit_return_node(node)
28
+ args = node.arguments
29
+ if args
30
+ values = args.arguments
31
+ unless values.size == 1 && values.first.is_a?(Prism::NilNode)
32
+ emit(args.location)
33
+ end
34
+ end
35
+ super
36
+ end
37
+
38
+ # Nested method definitions are discovered as their own subjects; do not
39
+ # recurse into them (prevents double-counting their statements).
40
+ def visit_def_node(node); end
41
+
42
+ private
43
+
44
+ def final_expression_nil(body)
45
+ return unless body.is_a?(Prism::StatementsNode)
46
+
47
+ last = body.body.last
48
+ return if last.nil? || last.is_a?(Prism::ReturnNode) || last.is_a?(Prism::NilNode)
49
+
50
+ emit(last.location)
51
+ end
52
+
53
+ def emit(loc)
54
+ @mutations << Mutation.new(
55
+ start_offset: loc.start_offset,
56
+ end_offset: loc.end_offset,
57
+ replacement: "nil",
58
+ operator: :return_nil
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Statement-removal operator: replace each non-final method statement with
8
+ # "nil". Tests whether the suite detects a missing side effect. The final
9
+ # expression is always skipped — replacing the return value with nil is the
10
+ # M5 return-nil operator's distinct concern (KTD-1). A body with < 2
11
+ # statements has no non-final statement, so it generates nothing.
12
+ #
13
+ # Clean-room: from the spec's operator description, not the mutant gem.
14
+ class StatementRemoval < Base
15
+ def visit_statements_node(node)
16
+ stmts = node.body
17
+ return if stmts.length < 2
18
+
19
+ stmts[0...-1].each do |stmt|
20
+ loc = stmt.location
21
+ @mutations << Mutation.new(
22
+ start_offset: loc.start_offset,
23
+ end_offset: loc.end_offset,
24
+ replacement: "nil",
25
+ operator: :statement_removal
26
+ )
27
+ end
28
+ # ponytail: no super — recursing into a nested StatementsNode would
29
+ # re-emit removals already covered at the top level and double-count.
30
+ # Each subject's body is visited once.
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutineer
6
+ # Raised only for I/O failures while reading a source file. Prism syntax
7
+ # errors are NOT raised — they are in-band via ParseResult#errors.
8
+ class ParseError < StandardError; end
9
+
10
+ # Thin boundary around Prism. Both methods return a Prism::ParseResult so all
11
+ # callers use result.value (AST root), result.source.source (raw bytes), and
12
+ # result.errors uniformly. No wrapping struct.
13
+ class Parser
14
+ # Returns Prism::ParseResult. Re-raises I/O failures as Mutineer::ParseError.
15
+ def self.parse_file(path)
16
+ Prism.parse_file(path)
17
+ rescue SystemCallError => e
18
+ raise ParseError, e.message
19
+ end
20
+
21
+ # Returns Prism::ParseResult. Never raises; callers check .errors.empty?.
22
+ def self.parse_string(source)
23
+ Prism.parse(source)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "parser"
5
+ require_relative "subject"
6
+
7
+ module Mutineer
8
+ # Subject discovery: parse each path and walk its AST for method definitions,
9
+ # tracking the enclosing class/module namespace.
10
+ class Project
11
+ # Returns Array<Subject>. `only` filters by qualified name (string equality).
12
+ def self.discover(paths, only: nil)
13
+ subjects = Array(paths).flat_map do |path|
14
+ result = Parser.parse_file(path)
15
+ visitor = SubjectVisitor.new(path)
16
+ visitor.visit(result.value)
17
+ visitor.subjects
18
+ end
19
+ only ? subjects.select { |s| s.qualified_name == only } : subjects
20
+ end
21
+
22
+ # Walks an AST, maintaining a namespace stack, emitting Subjects.
23
+ # Nested inside Project to signal its private role.
24
+ class SubjectVisitor < Prism::Visitor
25
+ attr_reader :subjects
26
+
27
+ def initialize(file)
28
+ @file = file
29
+ @namespace_stack = []
30
+ @subjects = []
31
+ super()
32
+ end
33
+
34
+ def visit_class_node(node)
35
+ @namespace_stack.push(extract_constant_name(node.constant_path))
36
+ super
37
+ @namespace_stack.pop
38
+ end
39
+
40
+ def visit_module_node(node)
41
+ @namespace_stack.push(extract_constant_name(node.constant_path))
42
+ super
43
+ @namespace_stack.pop
44
+ end
45
+
46
+ def visit_def_node(node)
47
+ @subjects << Subject.new(
48
+ file: @file,
49
+ namespace: @namespace_stack.dup,
50
+ name: node.name,
51
+ singleton: !node.receiver.nil?,
52
+ def_node: node
53
+ )
54
+ super
55
+ end
56
+
57
+ private
58
+
59
+ def extract_constant_name(node)
60
+ case node
61
+ when Prism::ConstantReadNode
62
+ node.name.to_s
63
+ when Prism::ConstantPathNode
64
+ [extract_constant_name(node.parent), node.name.to_s].compact.join("::")
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+ require_relative "mutation"
6
+
7
+ module Mutineer
8
+ # Renders an AggregateResult: the summary block, mutation score, and per-file
9
+ # survivor diffs. Stream discipline (R14): the report goes to `out` (stdout),
10
+ # diagnostics/warnings go to `err` (stderr), so `mutineer ... > report.txt`
11
+ # captures only the report.
12
+ #
13
+ # `source_map` is { file_path => raw source string }, used to extract the
14
+ # containing source line for each survivor diff.
15
+ class Reporter
16
+ def initialize(aggregate, source_map)
17
+ @agg = aggregate
18
+ @source_map = source_map
19
+ end
20
+
21
+ # Single entry point (R20/R21). Branches on `format` ("human" | "json") and
22
+ # routes the rendered report to `output` (a file, with a stderr confirmation)
23
+ # or to `out`. Diagnostics always go to `err`.
24
+ def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil)
25
+ rendered =
26
+ if format == "json"
27
+ json_report
28
+ else
29
+ sio = StringIO.new
30
+ human_report(sio, err, threshold)
31
+ sio.string
32
+ end
33
+
34
+ if output
35
+ abs = File.expand_path(output)
36
+ File.write(abs, rendered)
37
+ err.puts "Report written to #{abs}"
38
+ else
39
+ out.print rendered
40
+ end
41
+ end
42
+
43
+ def human_report(out, err, threshold)
44
+ if @agg.total.zero?
45
+ err.puts "No mutations generated — verify target files contain in-scope " \
46
+ "operators and are reached by the suite."
47
+ return
48
+ end
49
+
50
+ out.puts "Mutineer — Mutation Results"
51
+ out.puts "========================="
52
+ out.puts
53
+ summary(out)
54
+ out.puts
55
+ score_line(out, err)
56
+
57
+ survivors(out)
58
+ verdict(out, threshold) if threshold && threshold.positive?
59
+ end
60
+
61
+ # 0 pass / 1 below threshold. Usage errors (exit 2) are the CLI's job.
62
+ def exit_code(threshold:)
63
+ return 0 if threshold.nil? || threshold <= 0
64
+
65
+ score = @agg.mutation_score
66
+ return 0 if score.nil? # no testable mutants — gate skipped (warning already emitted)
67
+
68
+ score >= threshold ? 0 : 1
69
+ end
70
+
71
+ private
72
+
73
+ # Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
74
+ # by (file, line, operator) so output is byte-stable regardless of --jobs
75
+ # worker finish order (R22).
76
+ def json_report
77
+ killed = @agg.killed_count
78
+ survived = @agg.survived_count
79
+ # C8: null (not 0.0) on an empty denominator, matching the nil-vs-0.0
80
+ # discipline in AggregateResult; and the SAME rounding as the human report
81
+ # (one run must not yield two scores by --format).
82
+ score = @agg.mutation_score
83
+
84
+ doc = {
85
+ schema_version: "1.0",
86
+ summary: {
87
+ total: @agg.total, killed: killed, survived: survived,
88
+ no_coverage: @agg.no_coverage_count,
89
+ skipped_invalid: @agg.skipped_invalid_count,
90
+ errored: @agg.errored_count, timeout: @agg.timeout_count,
91
+ score: score
92
+ },
93
+ survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
94
+ .sort_by { |h| [h[:file], h[:line], h[:operator]] },
95
+ no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
96
+ .sort_by { |h| [h[:file], h[:line]] }
97
+ }
98
+ "#{JSON.generate(doc)}\n"
99
+ end
100
+
101
+ def survivor_json(result)
102
+ m = result.mutation
103
+ file = result.subject.file
104
+ source = @source_map[file] || File.read(file)
105
+ start_line, original_block, mutated_block, = diff_for(m, source)
106
+ minus = original_block.each_line.map { |l| "-#{l.chomp}" }.join("\n")
107
+ plus = mutated_block.each_line.map { |l| "+#{l.chomp}" }.join("\n")
108
+ {
109
+ subject: result.subject.qualified_name,
110
+ file: file,
111
+ line: start_line,
112
+ operator: m.operator.to_s,
113
+ diff: "--- a/#{file}\n+++ b/#{file}\n@@ -#{start_line} +#{start_line} @@\n#{minus}\n#{plus}\n"
114
+ }
115
+ end
116
+
117
+ # Builds a line-aligned diff for a mutation whose byte range may span several
118
+ # lines (e.g. statement-removal of a multi-line statement). Returns the
119
+ # mutation's 1-based start line, the full original line-block it touches, the
120
+ # spliced mutated block, and a single-line token label for the header.
121
+ def diff_for(m, source)
122
+ # Byte math (C1): Prism offsets are byte offsets; byteindex/byterindex/
123
+ # byteslice keep line splicing correct for multibyte sources.
124
+ line_begin = m.start_offset.zero? ? 0 : (source.byterindex("\n", m.start_offset - 1) || -1) + 1
125
+ line_end = source.byteindex("\n", m.end_offset) || source.bytesize
126
+ before = source.byteslice(line_begin...m.start_offset)
127
+ after = source.byteslice(m.end_offset...line_end)
128
+ original_block = source.byteslice(line_begin...line_end)
129
+ mutated_block = "#{before}#{m.replacement}#{after}"
130
+ start_line = source.byteslice(0, m.start_offset).count("\n") + 1
131
+ token = source.byteslice(m.start_offset...m.end_offset).gsub(/\s+/, " ").strip
132
+ token = "#{token[0, 47]}..." if token.length > 50
133
+ [start_line, original_block, mutated_block, token]
134
+ end
135
+
136
+ def no_coverage_json(result)
137
+ m = result.mutation
138
+ file = result.subject.file
139
+ source = @source_map[file] || File.read(file)
140
+ {
141
+ subject: result.subject.qualified_name,
142
+ file: file,
143
+ line: source.byteslice(0, m.start_offset).count("\n") + 1
144
+ }
145
+ end
146
+
147
+ def summary(out)
148
+ out.puts "Summary"
149
+ out.puts "-------"
150
+ out.puts format("Total: %-6d Killed: %d", @agg.total, @agg.killed_count)
151
+ out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
152
+ out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count,
153
+ @agg.errored_count + @agg.timeout_count)
154
+ end
155
+
156
+ def score_line(out, err)
157
+ 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"
160
+ if score.nil?
161
+ out.puts "Mutation score: N/A (no covered mutants)"
162
+ err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
163
+ else
164
+ out.puts "Mutation score: #{score}% (killed / (killed + survived); #{excluded})"
165
+ end
166
+ end
167
+
168
+ def survivors(out)
169
+ mutants = @agg.surviving_mutants
170
+ return if mutants.empty?
171
+
172
+ out.puts
173
+ out.puts "Surviving Mutants"
174
+ out.puts "-----------------"
175
+ mutants.group_by { |r| r.subject.file }.sort.each do |file, group|
176
+ out.puts
177
+ out.puts file
178
+ group.sort_by { |r| r.mutation.start_offset }.each { |r| survivor(out, file, r) }
179
+ end
180
+ end
181
+
182
+ def survivor(out, file, result)
183
+ m = result.mutation
184
+ source = @source_map[file] || File.read(file)
185
+ start_line, original_block, mutated_block, token = diff_for(m, source)
186
+
187
+ out.puts " #{result.subject.qualified_name} (#{File.basename(file)}:#{start_line})"
188
+ out.puts " Operator: #{m.operator} (#{token} -> #{m.replacement})"
189
+ original_block.each_line { |l| out.puts " - #{l.chomp}" }
190
+ mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
191
+ end
192
+
193
+ def verdict(out, threshold)
194
+ score = @agg.mutation_score
195
+ return if score.nil?
196
+
197
+ if score >= threshold
198
+ out.puts "PASSED: #{score}% >= threshold #{threshold}%"
199
+ else
200
+ out.puts "FAILED: #{score}% < threshold #{threshold}%"
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
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.
11
+ #
12
+ # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
13
+ # 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.
16
+ #
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
34
+ end
35
+
36
+ # Aggregates a flat list of Results into counts, the mutation score, and the
37
+ # 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".
42
+ class AggregateResult
43
+ attr_reader :results
44
+
45
+ def initialize(results)
46
+ @results = results
47
+ @by_status = results.group_by(&:status)
48
+ end
49
+
50
+ def killed_count = count(:killed)
51
+ def survived_count = count(:survived)
52
+ def no_coverage_count = count(:no_coverage)
53
+ def skipped_invalid_count = count(:skipped)
54
+ def errored_count = count(:error)
55
+ def timeout_count = count(:timeout)
56
+
57
+ # Every generated, classified mutation. NOT the score denominator.
58
+ def total = @results.size
59
+
60
+ # The score denominator (also shown to the reader).
61
+ def covered_count = killed_count + survived_count
62
+
63
+ # killed / (killed + survived) as a rounded percentage, or nil when nothing
64
+ # was testable.
65
+ def mutation_score
66
+ return nil if covered_count.zero?
67
+
68
+ (killed_count.to_f / covered_count * 100).round(1)
69
+ end
70
+
71
+ def surviving_mutants = @results.select(&:survived?)
72
+
73
+ private
74
+
75
+ def count(status) = (@by_status[status] || []).size
76
+ end
77
+ end