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.
@@ -3,17 +3,23 @@
3
3
  require_relative "parser"
4
4
 
5
5
  module Mutineer
6
- # One atomic byte-range edit. Immutable. One mutation per mutant — never
7
- # combine. Source is mutated textually, never regenerated from the AST.
6
+ # One atomic byte-range edit.
7
+ #
8
+ # Immutable. One mutation per mutant — never combine. Source is mutated
9
+ # textually, never regenerated from the AST.
8
10
  Mutation = Data.define(:start_offset, :end_offset, :replacement, :operator) do
9
- # Pure: returns a new string, does not mutate `source`. Prism offsets are
10
- # BYTE offsets, so all slicing is byte-based (byteslice) — char slicing would
11
- # corrupt any source containing a multibyte char before the mutation point.
11
+ # Applies the mutation to source text.
12
+ #
13
+ # @param source [String] original source text.
14
+ # @return [String] mutated source text.
12
15
  def apply(source)
13
16
  source.byteslice(0, start_offset) + replacement + source.byteslice(end_offset..)
14
17
  end
15
18
 
16
- # Validity rule: a mutation is valid iff the mutated source re-parses clean.
19
+ # Checks whether the mutated source still parses cleanly.
20
+ #
21
+ # @param source [String] original source text.
22
+ # @return [Boolean] true when the mutated source re-parses without errors.
17
23
  def valid?(source)
18
24
  Parser.parse_string(apply(source)).errors.empty?
19
25
  end
@@ -10,13 +10,16 @@ require_relative "mutators/literal_mutation"
10
10
  require_relative "mutators/condition_negation"
11
11
 
12
12
  module Mutineer
13
- # Maps operator name -> operator class. DEFAULT_NAMES is the v1 default set
13
+ # Maps operator names to operator classes.
14
+ #
15
+ # DEFAULT_NAMES is the v1 default set
14
16
  # (the M4 Tier-1 + statement-removal operators per locked decision #2). The
15
17
  # three Tier-2 operators live in ALL but are OFF by default — they only run
16
18
  # when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
17
19
  # DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
18
20
  # survivor set unchanged.
19
21
  class MutatorRegistry
22
+ # All available mutator classes keyed by operator name.
20
23
  ALL = {
21
24
  "arithmetic" => Mutators::Arithmetic,
22
25
  "comparison" => Mutators::Comparison,
@@ -28,9 +31,12 @@ module Mutineer
28
31
  "condition_negation" => Mutators::ConditionNegation
29
32
  }.freeze
30
33
 
34
+ # The default Tier-1 operator set.
31
35
  DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
36
+ # Tier-2 operators that remain opt-in.
32
37
  TIER2_NAMES = %w[return_nil literal_mutation condition_negation].freeze
33
38
 
39
+ # Short human-readable descriptions for each operator.
34
40
  DESCRIPTIONS = {
35
41
  "arithmetic" => "+ <-> -, * <-> /, % -> *, ** -> *",
36
42
  "comparison" => "< <-> <=, > <-> >=, == <-> !=",
@@ -42,13 +48,25 @@ module Mutineer
42
48
  "condition_negation" => "wrap if/unless/ternary condition in !( ... )"
43
49
  }.freeze
44
50
 
45
- # Returns the operator classes for the given names. Unknown names raise
46
- # ArgumentError immediately (caught at the CLI boundary -> exit 2).
51
+ # Resolves operator names to classes.
52
+ #
53
+ # @param names [Array<String>] operator names to resolve.
54
+ # @return [Array<Class>] mutator classes in the requested order.
55
+ # @raise [ArgumentError] when a name is unknown.
47
56
  def self.resolve(names = DEFAULT_NAMES)
48
57
  names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
49
58
  end
50
59
 
60
+ # Returns whether the operator is part of the default Tier-1 set.
61
+ #
62
+ # @param name [String] operator name.
63
+ # @return [Boolean] true when the operator is default.
51
64
  def self.default?(name) = DEFAULT_NAMES.include?(name)
65
+
66
+ # Returns the tier number for an operator name.
67
+ #
68
+ # @param name [String] operator name.
69
+ # @return [Integer] 2 for Tier-2 operators, otherwise 1.
52
70
  def self.tier(name) = TIER2_NAMES.include?(name) ? 2 : 1
53
71
  end
54
72
  end
@@ -4,13 +4,19 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Arithmetic operator: +<->-, *<->/, %->*, **->*. One mutation per
8
- # occurrence, rewriting the operator token (CallNode#message_loc).
7
+ # Arithmetic operator mutator.
8
+ #
9
+ # One mutation per occurrence, rewriting the operator token.
9
10
  class Arithmetic < Base
11
+ # Token replacements for arithmetic operators.
10
12
  REPLACEMENTS = {
11
13
  :+ => "-", :- => "+", :* => "/", :/ => "*", :% => "*", :** => "*"
12
14
  }.freeze
13
15
 
16
+ # Visits call nodes and emits arithmetic mutations.
17
+ #
18
+ # @param node [Prism::CallNode] call node to inspect.
19
+ # @return [void]
14
20
  def visit_call_node(node)
15
21
  replacement = REPLACEMENTS[node.name]
16
22
  loc = node.message_loc
@@ -4,14 +4,22 @@ require "prism"
4
4
  require_relative "../mutation"
5
5
 
6
6
  module Mutineer
7
+ # Namespace for all built-in mutator implementations.
7
8
  module Mutators
8
- # Base Prism visitor for operators. Subclasses override visit_* methods to
9
- # push Mutation objects onto @mutations. Visiting only def_node.body is the
10
- # body-only enforcement the def signature line is never touched.
9
+ # Base Prism visitor for operators.
10
+ #
11
+ # Subclasses override `visit_*` methods to push `Mutation` objects onto
12
+ # `@mutations`. Visiting only `def_node.body` is the body-only enforcement:
13
+ # the def signature line is never touched.
11
14
  #
12
15
  # ponytail: one implementor in M1; Base earns its keep at M4 when
13
16
  # comparison/boolean operators land and share this contract.
14
17
  class Base < Prism::Visitor
18
+ # Walks the subject body and collects mutations.
19
+ #
20
+ # @param subject [Mutineer::Subject] subject whose body is visited.
21
+ # @param source [String] full source text for byte-based slicing.
22
+ # @return [Array<Mutineer::Mutation>] collected mutations.
15
23
  def mutations_for(subject, source)
16
24
  @source = source
17
25
  @mutations = []
@@ -4,20 +4,26 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Boolean connector operator: && <-> ||, and <-> or. Replacement is derived
8
- # from the actual source token (operator_loc.slice) so symbolic and keyword
9
- # forms each map to their own form — never crossing && to `or`, which would
10
- # change precedence and surprise the reader (KTD-2).
7
+ # Boolean connector mutator.
11
8
  #
12
- # Clean-room: from the spec's operator table, not the mutant gem.
9
+ # Replaces symbolic and keyword connectors with their opposite form.
13
10
  class BooleanConnector < Base
11
+ # Token replacements for boolean connectors.
14
12
  REPLACEMENTS = { "&&" => "||", "||" => "&&", "and" => "or", "or" => "and" }.freeze
15
13
 
14
+ # Visits `and` nodes.
15
+ #
16
+ # @param node [Prism::AndNode] node to inspect.
17
+ # @return [void]
16
18
  def visit_and_node(node)
17
19
  emit(node)
18
20
  super
19
21
  end
20
22
 
23
+ # Visits `or` nodes.
24
+ #
25
+ # @param node [Prism::OrNode] node to inspect.
26
+ # @return [void]
21
27
  def visit_or_node(node)
22
28
  emit(node)
23
29
  super
@@ -25,6 +31,10 @@ module Mutineer
25
31
 
26
32
  private
27
33
 
34
+ # Emits a connector mutation when the token is replaceable.
35
+ #
36
+ # @param node [Prism::Node] connector node.
37
+ # @return [void]
28
38
  def emit(node)
29
39
  loc = node.operator_loc
30
40
  replacement = REPLACEMENTS[loc.slice]
@@ -4,24 +4,32 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Mutates true/false AND nil literals — "boolean_literal" is the spec's name
8
- # for the family (§4), so nil is in-scope by design even though it is not
9
- # strictly a boolean. true<->false, and nil->true (nil->true catches more
10
- # return-value gaps than nil->false). Rewrites the whole node location;
11
- # these nodes have no sub-token location.
7
+ # Boolean literal mutator.
12
8
  #
13
- # Clean-room: from the spec's operator table, not the mutant gem.
9
+ # Mutates true/false and nil literals in the boolean_literal family.
14
10
  class BooleanLiteral < Base
11
+ # Visits true literals.
12
+ #
13
+ # @param node [Prism::TrueNode] node to inspect.
14
+ # @return [void]
15
15
  def visit_true_node(node)
16
16
  emit(node, "false")
17
17
  super
18
18
  end
19
19
 
20
+ # Visits false literals.
21
+ #
22
+ # @param node [Prism::FalseNode] node to inspect.
23
+ # @return [void]
20
24
  def visit_false_node(node)
21
25
  emit(node, "true")
22
26
  super
23
27
  end
24
28
 
29
+ # Visits nil literals.
30
+ #
31
+ # @param node [Prism::NilNode] node to inspect.
32
+ # @return [void]
25
33
  def visit_nil_node(node)
26
34
  emit(node, "true")
27
35
  super
@@ -29,6 +37,11 @@ module Mutineer
29
37
 
30
38
  private
31
39
 
40
+ # Emits a boolean-literal mutation.
41
+ #
42
+ # @param node [Prism::Node] literal node.
43
+ # @param replacement [String] replacement token.
44
+ # @return [void]
32
45
  def emit(node, replacement)
33
46
  loc = node.location
34
47
  @mutations << Mutation.new(
@@ -4,23 +4,22 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Comparison / boundary operator: <->-<=, >->-=>, ==->!=, etc. The single
8
- # highest-value Tier-1 family (spec §4) — exposes off-by-one and boundary
9
- # gaps line coverage never catches. Rewrites the operator token
10
- # (CallNode#message_loc), one mutation per occurrence.
7
+ # Comparison and boundary mutator.
11
8
  #
12
- # Clean-room: implemented from the spec's operator table and public
13
- # mutation-testing literature, not the mutant gem.
9
+ # Rewrites comparison operators one occurrence at a time.
14
10
  class Comparison < Base
11
+ # Token replacements for comparison operators.
15
12
  REPLACEMENTS = {
16
13
  :< => "<=", :<= => "<", :> => ">=", :>= => ">", :== => "!=", :!= => "=="
17
14
  }.freeze
18
15
 
16
+ # Visits call nodes and emits comparison mutations.
17
+ #
18
+ # @param node [Prism::CallNode] call node to inspect.
19
+ # @return [void]
19
20
  def visit_call_node(node)
20
21
  replacement = REPLACEMENTS[node.name]
21
22
  loc = node.message_loc
22
- # receiver guard: reject any unary call accidentally named like an
23
- # operator; binary comparisons always have a receiver.
24
23
  if replacement && loc && node.receiver
25
24
  @mutations << Mutation.new(
26
25
  start_offset: loc.start_offset,
@@ -4,18 +4,23 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
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).
7
+ # Condition-negation mutator.
11
8
  #
12
- # Clean-room: from the spec's operator description, not the mutant gem.
9
+ # Wraps if/unless predicates in `!( ... )` textually.
13
10
  class ConditionNegation < Base
11
+ # Visits if nodes.
12
+ #
13
+ # @param node [Prism::IfNode] node to inspect.
14
+ # @return [void]
14
15
  def visit_if_node(node)
15
16
  wrap(node.predicate)
16
17
  super
17
18
  end
18
19
 
20
+ # Visits unless nodes.
21
+ #
22
+ # @param node [Prism::UnlessNode] node to inspect.
23
+ # @return [void]
19
24
  def visit_unless_node(node)
20
25
  wrap(node.predicate)
21
26
  super
@@ -23,6 +28,10 @@ module Mutineer
23
28
 
24
29
  private
25
30
 
31
+ # Wraps a predicate in negation.
32
+ #
33
+ # @param predicate [Prism::Node, nil] predicate node.
34
+ # @return [void]
26
35
  def wrap(predicate)
27
36
  return unless predicate
28
37
 
@@ -4,12 +4,14 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
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).
7
+ # Literal mutation mutator.
10
8
  #
11
- # Clean-room: from the spec's operator description, not the mutant gem.
9
+ # Mutates integers and strings with the Tier-2 literal rules.
12
10
  class LiteralMutation < Base
11
+ # Visits integer literals.
12
+ #
13
+ # @param node [Prism::IntegerNode] node to inspect.
14
+ # @return [void]
13
15
  def visit_integer_node(node)
14
16
  n = node.value
15
17
  emit(node.location, "0") unless n.zero?
@@ -18,6 +20,10 @@ module Mutineer
18
20
  super
19
21
  end
20
22
 
23
+ # Visits string literals.
24
+ #
25
+ # @param node [Prism::StringNode] node to inspect.
26
+ # @return [void]
21
27
  def visit_string_node(node)
22
28
  loc = node.location
23
29
  token = @source.byteslice(loc.start_offset...loc.end_offset)
@@ -27,6 +33,11 @@ module Mutineer
27
33
 
28
34
  private
29
35
 
36
+ # Emits a literal mutation.
37
+ #
38
+ # @param loc [Prism::Location] source location.
39
+ # @param replacement [String] replacement literal.
40
+ # @return [void]
30
41
  def emit(loc, replacement)
31
42
  @mutations << Mutation.new(
32
43
  start_offset: loc.start_offset,
@@ -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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Source -> test pairing by path convention (#11). Pure stdlib path logic:
5
+ # no Rails, no class loading, no process. Two jobs:
6
+ # * expand_sources — a directory argument becomes its sorted **/*.rb files.
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.
11
+ #
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.
15
+ module Pairing
16
+ module_function
17
+
18
+ # Expand each positional source: a directory -> its sorted **/*.rb files
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.
25
+ def expand_sources(args, project_root:)
26
+ root = File.expand_path(project_root)
27
+ Array(args).flat_map do |arg|
28
+ abs = File.expand_path(arg, root)
29
+ if File.directory?(abs)
30
+ Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| f.delete_prefix("#{root}/") }
31
+ else
32
+ [arg]
33
+ end
34
+ end.uniq
35
+ end
36
+
37
+ # The first EXISTING candidate test path for a source (relative to
38
+ # project_root), or nil. `prefer` is the resolved framework ("minitest" |
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.
46
+ def infer_test(source_rel, project_root:, prefer: "minitest")
47
+ base, lib = logical_path(source_rel)
48
+ candidates(base, lib, prefer).find do |rel|
49
+ File.exist?(File.expand_path(rel, project_root))
50
+ end
51
+ end
52
+
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/.
60
+ def logical_path(source_rel)
61
+ no_ext = source_rel.sub(/\.rb\z/, "")
62
+ if no_ext.start_with?("app/")
63
+ [no_ext.sub(%r{\Aapp/}, ""), false]
64
+ elsif no_ext.start_with?("lib/")
65
+ [no_ext.sub(%r{\Alib/}, ""), true]
66
+ else
67
+ [no_ext, false]
68
+ end
69
+ end
70
+
71
+ # Ordered candidate test paths. lib/ sources also get test/lib/... and
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.
78
+ def candidates(base, lib, prefer)
79
+ minitest = ["test/#{base}_test.rb"]
80
+ minitest << "test/lib/#{base}_test.rb" if lib
81
+ rspec = ["spec/#{base}_spec.rb"]
82
+ rspec << "spec/lib/#{base}_spec.rb" if lib
83
+ prefer == "rspec" ? rspec + minitest : minitest + rspec
84
+ end
85
+ end
86
+ end
@@ -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