mutineer 0.7.0 → 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.
@@ -4,15 +4,15 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
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).
7
+ # Return-nil mutator.
13
8
  #
14
- # Clean-room: from the spec's operator description, not the mutant gem.
9
+ # Replaces explicit return values and final expressions with nil.
15
10
  class ReturnNil < Base
11
+ # Collects return-nil mutations for a subject.
12
+ #
13
+ # @param subject [Mutineer::Subject] subject to inspect.
14
+ # @param source [String] full source text.
15
+ # @return [Array<Mutineer::Mutation>] collected mutations.
16
16
  def mutations_for(subject, source)
17
17
  @source = source
18
18
  @mutations = []
@@ -24,6 +24,10 @@ module Mutineer
24
24
  @mutations
25
25
  end
26
26
 
27
+ # Visits return nodes.
28
+ #
29
+ # @param node [Prism::ReturnNode] node to inspect.
30
+ # @return [void]
27
31
  def visit_return_node(node)
28
32
  args = node.arguments
29
33
  if args
@@ -37,10 +41,17 @@ module Mutineer
37
41
 
38
42
  # Nested method definitions are discovered as their own subjects; do not
39
43
  # recurse into them (prevents double-counting their statements).
44
+ #
45
+ # @param node [Prism::DefNode] nested definition node.
46
+ # @return [void]
40
47
  def visit_def_node(node); end
41
48
 
42
49
  private
43
50
 
51
+ # Mutates a method's final expression to nil when eligible.
52
+ #
53
+ # @param body [Prism::Node] method body node.
54
+ # @return [void]
44
55
  def final_expression_nil(body)
45
56
  return unless body.is_a?(Prism::StatementsNode)
46
57
 
@@ -50,6 +61,10 @@ module Mutineer
50
61
  emit(last.location)
51
62
  end
52
63
 
64
+ # Emits a return-nil mutation.
65
+ #
66
+ # @param loc [Prism::Location] source location.
67
+ # @return [void]
53
68
  def emit(loc)
54
69
  @mutations << Mutation.new(
55
70
  start_offset: loc.start_offset,
@@ -4,14 +4,14 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
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.
7
+ # Statement-removal mutator.
12
8
  #
13
- # Clean-room: from the spec's operator description, not the mutant gem.
9
+ # Replaces each non-final method statement with `nil`.
14
10
  class StatementRemoval < Base
11
+ # Visits statement nodes and emits removals.
12
+ #
13
+ # @param node [Prism::StatementsNode] statement list to inspect.
14
+ # @return [void]
15
15
  def visit_statements_node(node)
16
16
  stmts = node.body
17
17
  return if stmts.length < 2
@@ -25,9 +25,6 @@ module Mutineer
25
25
  operator: :statement_removal
26
26
  )
27
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
28
  end
32
29
  end
33
30
  end
@@ -4,18 +4,24 @@ module Mutineer
4
4
  # Source -> test pairing by path convention (#11). Pure stdlib path logic:
5
5
  # no Rails, no class loading, no process. Two jobs:
6
6
  # * expand_sources — a directory argument becomes its sorted **/*.rb files.
7
- # * infer_test — a source's test file by convention (app/ and lib/ sources
8
- # map to test/.../_test.rb or spec/.../_spec.rb), preserving
9
- # namespaced subdirectories. First EXISTING candidate wins.
7
+ # * infer_test — a source's test file by convention (app/ and lib/
8
+ # sources map to test/.../_test.rb or spec/.../_spec.rb),
9
+ # preserving namespaced subdirectories. First EXISTING
10
+ # candidate wins.
10
11
  #
11
- # Independently unit-testable: every method is pure in/out over the filesystem,
12
- # so the pairing contract is exercised with plain fixtures, no Rails, no fork.
12
+ # Independently unit-testable: every method is pure in/out over the
13
+ # filesystem, so the pairing contract is exercised with plain fixtures, no
14
+ # Rails, no fork.
13
15
  module Pairing
14
16
  module_function
15
17
 
16
18
  # Expand each positional source: a directory -> its sorted **/*.rb files
17
- # (relative to project_root); a file (or glob, or anything non-directory) ->
18
- # itself. Flattened, deduped, order-stable.
19
+ # (relative to project_root); a file (or glob, or anything non-directory)
20
+ # -> itself. Flattened, deduped, order-stable.
21
+ #
22
+ # @param args [Array<String>] source paths or directories.
23
+ # @param project_root [String] repository root for relative expansion.
24
+ # @return [Array<String>] flattened, deduped source file list.
19
25
  def expand_sources(args, project_root:)
20
26
  root = File.expand_path(project_root)
21
27
  Array(args).flat_map do |arg|
@@ -30,8 +36,13 @@ module Mutineer
30
36
 
31
37
  # The first EXISTING candidate test path for a source (relative to
32
38
  # project_root), or nil. `prefer` is the resolved framework ("minitest" |
33
- # "rspec"): its candidates are tried first, the other framework's as fallback,
34
- # so a minitest default still finds a spec and vice-versa.
39
+ # "rspec"): its candidates are tried first, the other framework's as
40
+ # fallback, so a minitest default still finds a spec and vice-versa.
41
+ #
42
+ # @param source_rel [String] relative source path.
43
+ # @param project_root [String] repository root for existence checks.
44
+ # @param prefer [String] preferred framework name.
45
+ # @return [String, nil] existing test path or nil.
35
46
  def infer_test(source_rel, project_root:, prefer: "minitest")
36
47
  base, lib = logical_path(source_rel)
37
48
  candidates(base, lib, prefer).find do |rel|
@@ -39,10 +50,13 @@ module Mutineer
39
50
  end
40
51
  end
41
52
 
42
- # Strip the source root to a logical path (no ".rb") and flag lib/ sources.
43
- # app/foo/bar.rb and lib/foo/bar.rb both -> "foo/bar"; anything else -> the
44
- # path minus ".rb" (still attempted). Namespaced subdirs are preserved
45
- # verbatim — structural, never constant resolution.
53
+ # Strip the source root to a logical path (no ".rb") and flag lib/
54
+ # sources. app/foo/bar.rb and lib/foo/bar.rb both -> "foo/bar"; anything
55
+ # else -> the path minus ".rb" (still attempted). Namespaced subdirs are
56
+ # preserved verbatim — structural, never constant resolution.
57
+ #
58
+ # @param source_rel [String] relative source path.
59
+ # @return [Array(String, Boolean)] logical base path and whether it came from lib/.
46
60
  def logical_path(source_rel)
47
61
  no_ext = source_rel.sub(/\.rb\z/, "")
48
62
  if no_ext.start_with?("app/")
@@ -56,6 +70,11 @@ module Mutineer
56
70
 
57
71
  # Ordered candidate test paths. lib/ sources also get test/lib/... and
58
72
  # spec/lib/... (Rails apps put lib tests under either layout).
73
+ #
74
+ # @param base [String] logical source path without extension.
75
+ # @param lib [Boolean] whether the source originated from lib/.
76
+ # @param prefer [String] preferred framework name.
77
+ # @return [Array<String>] candidate test paths in preferred order.
59
78
  def candidates(base, lib, prefer)
60
79
  minitest = ["test/#{base}_test.rb"]
61
80
  minitest << "test/lib/#{base}_test.rb" if lib
@@ -3,22 +3,32 @@
3
3
  require "prism"
4
4
 
5
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.
6
+ # Raised only for I/O failures while reading a source file.
7
+ #
8
+ # Prism syntax errors are NOT raised — they are in-band via ParseResult#errors.
8
9
  class ParseError < StandardError; end
9
10
 
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.
11
+ # Thin boundary around Prism.
12
+ #
13
+ # Both methods return a Prism::ParseResult so all callers use result.value,
14
+ # result.source.source (raw bytes), and result.errors uniformly. No wrapping
15
+ # struct.
13
16
  class Parser
14
- # Returns Prism::ParseResult. Re-raises I/O failures as Mutineer::ParseError.
17
+ # Parses a file with Prism.
18
+ #
19
+ # @param path [String] source file path.
20
+ # @return [Prism::ParseResult] Prism parse result.
21
+ # @raise [Mutineer::ParseError] when file I/O fails.
15
22
  def self.parse_file(path)
16
23
  Prism.parse_file(path)
17
24
  rescue SystemCallError => e
18
25
  raise ParseError, e.message
19
26
  end
20
27
 
21
- # Returns Prism::ParseResult. Never raises; callers check .errors.empty?.
28
+ # Parses source text with Prism.
29
+ #
30
+ # @param source [String] source text.
31
+ # @return [Prism::ParseResult] Prism parse result.
22
32
  def self.parse_string(source)
23
33
  Prism.parse(source)
24
34
  end
@@ -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
@@ -41,6 +41,12 @@ module Mutineer
41
41
  end
42
42
  end
43
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]
44
50
  def human_report(out, err, threshold)
45
51
  if @agg.total.zero?
46
52
  err.puts "No mutations generated — verify target files contain in-scope " \
@@ -75,6 +81,11 @@ module Mutineer
75
81
  # Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
76
82
  # by (file, line, operator) so output is byte-stable regardless of --jobs
77
83
  # worker finish order (R22).
84
+ # Renders the JSON report.
85
+ #
86
+ # @api private
87
+ # @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
88
+ # @return [String] JSON text.
78
89
  def json_report(baseline = nil)
79
90
  killed = @agg.killed_count
80
91
  survived = @agg.survived_count
@@ -135,6 +146,12 @@ module Mutineer
135
146
  }
136
147
  end
137
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.
138
155
  def per_source_json(file, agg)
139
156
  {
140
157
  file: file, total: agg.total,
@@ -143,6 +160,11 @@ module Mutineer
143
160
  }
144
161
  end
145
162
 
163
+ # Builds survivor JSON.
164
+ #
165
+ # @api private
166
+ # @param result [Mutineer::Result] survivor result.
167
+ # @return [Hash] survivor JSON object.
146
168
  def survivor_json(result)
147
169
  m = result.mutation
148
170
  file = result.subject.file
@@ -198,6 +220,11 @@ module Mutineer
198
220
  [start_line, original_block, mutated_block, token]
199
221
  end
200
222
 
223
+ # Builds no-coverage JSON.
224
+ #
225
+ # @api private
226
+ # @param result [Mutineer::Result] result object.
227
+ # @return [Hash] no-coverage JSON object.
201
228
  def no_coverage_json(result)
202
229
  m = result.mutation
203
230
  file = result.subject.file
@@ -209,6 +236,10 @@ module Mutineer
209
236
  }
210
237
  end
211
238
 
239
+ # Writes the summary block.
240
+ #
241
+ # @param out [IO] output stream.
242
+ # @return [void]
212
243
  def summary(out)
213
244
  out.puts "Summary"
214
245
  out.puts "-------"
@@ -222,6 +253,11 @@ module Mutineer
222
253
  out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
223
254
  end
224
255
 
256
+ # Writes the score line.
257
+ #
258
+ # @param out [IO] output stream.
259
+ # @param err [IO] error stream.
260
+ # @return [void]
225
261
  def score_line(out, err)
226
262
  score = @agg.mutation_score
227
263
  excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
@@ -239,6 +275,10 @@ module Mutineer
239
275
  # #11: one line per source after the global summary, so a multi-source run
240
276
  # shows which file is weak. Omitted for a single-source run — the global
241
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]
242
282
  def per_source(out)
243
283
  sources = @agg.by_source
244
284
  return if sources.size <= 1
@@ -274,6 +314,10 @@ module Mutineer
274
314
  out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
275
315
  end
276
316
 
317
+ # Writes the survivors block.
318
+ #
319
+ # @param out [IO] output stream.
320
+ # @return [void]
277
321
  def survivors(out)
278
322
  mutants = @agg.surviving_mutants
279
323
  return if mutants.empty?
@@ -288,6 +332,12 @@ module Mutineer
288
332
  end
289
333
  end
290
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]
291
341
  def survivor(out, file, result)
292
342
  m = result.mutation
293
343
  source = @source_map[file] || File.read(file)
@@ -299,6 +349,11 @@ module Mutineer
299
349
  mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
300
350
  end
301
351
 
352
+ # Writes the final verdict line.
353
+ #
354
+ # @param out [IO] output stream.
355
+ # @param threshold [Float] score threshold.
356
+ # @return [void]
302
357
  def verdict(out, threshold)
303
358
  score = @agg.mutation_score
304
359
  return if score.nil?