mutineer 1.1.0 → 1.3.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.
@@ -43,9 +43,10 @@ module Mutineer
43
43
  #
44
44
  # @param config [Mutineer::Config] run configuration (daemon set).
45
45
  # @param operator_classes [Array<Class>] resolved operators.
46
- # @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
46
+ # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
47
+ # source map, and the {Runner.collect_jobs} extras.
47
48
  def self.execute(config, operator_classes)
48
- jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
49
+ jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
49
50
  jobs = Runner.filter_since(jobs, source_map, config) if config.since
50
51
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
51
52
 
@@ -58,7 +59,7 @@ module Mutineer
58
59
  # tool-side. A file a hard-killed run left in app/models breaks the app's own
59
60
  # Zeitwerk boot, not just Mutineer's next run.
60
61
  Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB)
61
- return [AggregateResult.new(ignored_results), source_map]
62
+ return [AggregateResult.new(ignored_results), source_map, extras]
62
63
  end
63
64
 
64
65
  # Build the coverage map once (app-side). nil when the build fails: runners
@@ -84,7 +85,7 @@ module Mutineer
84
85
  run_serial(jobs, config, abs_tests, coverage_map, source_map)
85
86
  end
86
87
 
87
- [AggregateResult.new(results + ignored_results), source_map]
88
+ [AggregateResult.new(results + ignored_results), source_map, extras]
88
89
  end
89
90
 
90
91
  # Build the coverage map via a short-lived daemon (boots the app once, captures
@@ -99,7 +99,7 @@ module Mutineer
99
99
  # @param source_file [String] original source file path.
100
100
  # @return [Object] whatever `load` returns.
101
101
  def self.apply_whole_file(mutated, source_file)
102
- Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f|
102
+ Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
103
103
  f.write(mutated)
104
104
  f.flush
105
105
  load f.path
@@ -132,7 +132,7 @@ module Mutineer
132
132
  # namespace constants resolve exactly as the reload strategy would. A
133
133
  # bare redefinition on the owner would collapse Module.nesting to [owner]
134
134
  # and raise NameError on such constants (C2 scope-collapse).
135
- keywords = nesting_keywords(subject.namespace)
135
+ keywords = nesting_keywords(subject.lexical_namespace)
136
136
  prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
137
137
  prefix += "\n" unless prefix.empty?
138
138
 
@@ -188,13 +188,18 @@ module Mutineer
188
188
  # Foo], so an unqualified constant defined only in Foo would resolve under
189
189
  # redefine but not reload — a strategy disagreement.
190
190
  #
191
+ # A root-anchored element (`::Top`, #145) resolves from Object and keeps its
192
+ # `::` in the wrapper, so `module Outer; class ::Top` rebuilds nesting
193
+ # [Top, Outer] exactly as the source does.
194
+ #
191
195
  # @api private
192
- # @param namespace [Array<String>] namespace components.
196
+ # @param namespace [Array<String>] class/module chain as written.
193
197
  # @return [Array<[String, String]>] wrapper keywords and names.
194
198
  def self.nesting_keywords(namespace)
195
199
  mod = Object
196
200
  namespace.map do |name|
197
- mod = mod.const_get(name) # const_get resolves a compact "Foo::Bar" too
201
+ # const_get resolves a compact "Foo::Bar" too
202
+ mod = name.start_with?("::") ? Object.const_get(name.delete_prefix("::")) : mod.const_get(name)
198
203
  [mod.is_a?(Class) ? "class" : "module", name]
199
204
  end
200
205
  end
@@ -3,16 +3,18 @@
3
3
  require "digest"
4
4
 
5
5
  module Mutineer
6
- # Content-based stable id for a mutant — NOT byte offsets. Pure function, reused
6
+ # Content-based id for a mutant — NOT byte offsets. Pure function, reused
7
7
  # by the Runner (matching the ignore list), the Reporter (emitting a copy-
8
8
  # pasteable id per survivor), and #13 baseline gating (diffing id-sets run to
9
9
  # run). `digest` is stdlib, so zero new deps.
10
10
  #
11
- # Offset-free by design: keyed on the subject's qualified_name (a method, not a
12
- # byte position) + operator + the normalized mutated token + an occurrence
13
- # ordinal among same-(operator, token) twins WITHIN the subject. So it survives
14
- # any edit outside the subject method — where raw start/end offsets shift on
15
- # every edit earlier in the file and would silently stop matching.
11
+ # Offset-free by design: keyed on the subject's project-relative file path +
12
+ # qualified_name (a method, not a byte position) + operator + the normalized
13
+ # mutated token + an occurrence ordinal among same-(operator, token) twins
14
+ # WITHIN the subject + (only when positive) the subject's ordinal among
15
+ # same-named subjects in its file. So it survives any edit outside the subject method,
16
+ # where raw start/end offsets shift on every edit earlier in the file and
17
+ # would silently stop matching. Moving or renaming the file changes the id.
16
18
  module MutantId
17
19
  module_function
18
20
 
@@ -20,6 +22,9 @@ module Mutineer
20
22
  #
21
23
  # NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
22
24
  # with the separator; `SHA256[0,12]` gives a fixed-length, copy-pasteable key.
25
+ # The file path is part of the key, so the same qualified name in two files
26
+ # (an owner-less `def`, or a class reopened elsewhere) cannot collide. `path`
27
+ # is required so no caller can get the colliding legacy id by accident.
23
28
  #
24
29
  # @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
25
30
  # its `qualified_name` anchors the id to a method rather than a byte position.
@@ -27,12 +32,17 @@ module Mutineer
27
32
  # @param source [String] the full, unmutated source the mutation indexes into.
28
33
  # @param occurrence [Integer] 0-based ordinal among twins sharing the same
29
34
  # (operator, token) within the subject, disambiguating otherwise-identical mutants.
35
+ # @param path [String] the subject's file, normalized with {ProjectPath.relative}
36
+ # against the project root (an absolute real path when outside the root).
37
+ # @param subject_ordinal [Integer] 0-based ordinal among subjects in the same
38
+ # file sharing this qualified name (two owner-less `def index` in two DSL
39
+ # blocks). Hashed only when positive, so a subject whose name is unique in
40
+ # its file keeps the id it had without it.
30
41
  # @return [String] a 12-character hex id, stable across edits outside the subject.
31
- def for(subject, mutation, source, occurrence = 0)
32
- Digest::SHA256.hexdigest(
33
- [subject.qualified_name, mutation.operator,
34
- normalized_token(mutation, source), occurrence].join("\x00")
35
- )[0, 12]
42
+ def for(subject, mutation, source, occurrence = 0, path:, subject_ordinal: 0)
43
+ parts = [path, subject.qualified_name, mutation.operator, normalized_token(mutation, source), occurrence]
44
+ parts << subject_ordinal if subject_ordinal.positive?
45
+ digest(parts)
36
46
  end
37
47
 
38
48
  # Computes ids for a subject's full mutation list, in input order, assigning
@@ -42,17 +52,66 @@ module Mutineer
42
52
  # @param subject [Mutineer::Subject] the subject the mutations belong to.
43
53
  # @param source [String] the full, unmutated source for token normalization.
44
54
  # @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
55
+ # @param path [String] the subject's normalized file path (see {.for}).
56
+ # @param subject_ordinal [Integer] the subject's ordinal among same-named
57
+ # subjects in its file (see {.for}).
45
58
  # @return [Array<String>] one 12-character id per mutation, positionally aligned.
46
- def for_subject(subject, source, mutations)
59
+ def for_subject(subject, source, mutations, path:, subject_ordinal: 0)
60
+ with_occurrences(mutations, source) do |m, occ|
61
+ self.for(subject, m, source, occ, path: path, subject_ordinal: subject_ordinal)
62
+ end
63
+ end
64
+
65
+ # The pre-1.3 id: the {.for} formula without the path, so it collides across
66
+ # files. Kept only to match ignore entries and baselines stored in the old
67
+ # format; removed in 2.0.
68
+ #
69
+ # @param subject [Mutineer::Subject] the subject (method) the mutant lives in.
70
+ # @param mutation [Mutineer::Mutation] the atomic edit whose operator is hashed.
71
+ # @param source [String] the full, unmutated source the mutation indexes into.
72
+ # @param occurrence [Integer] 0-based ordinal among same-(operator, token) twins.
73
+ # @return [String] a 12-character hex id in the old format.
74
+ def legacy_for(subject, mutation, source, occurrence = 0)
75
+ digest([subject.qualified_name, mutation.operator,
76
+ normalized_token(mutation, source), occurrence])
77
+ end
78
+
79
+ # {.for_subject} for the pre-1.3 id format (see {.legacy_for}).
80
+ #
81
+ # @param subject [Mutineer::Subject] the subject the mutations belong to.
82
+ # @param source [String] the full, unmutated source for token normalization.
83
+ # @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
84
+ # @return [Array<String>] one old-format id per mutation, positionally aligned.
85
+ def legacy_for_subject(subject, source, mutations)
86
+ with_occurrences(mutations, source) { |m, occ| legacy_for(subject, m, source, occ) }
87
+ end
88
+
89
+ # Maps each mutation to the block's result, passing its 0-based occurrence
90
+ # among earlier mutations with the same (operator, token).
91
+ #
92
+ # @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
93
+ # @param source [String] the full, unmutated source for token normalization.
94
+ # @yieldparam mutation [Mutineer::Mutation] the current mutation.
95
+ # @yieldparam occurrence [Integer] its ordinal among same-(operator, token) twins.
96
+ # @return [Array] the block's results, positionally aligned.
97
+ def with_occurrences(mutations, source)
47
98
  seen = Hash.new(0)
48
99
  mutations.map do |m|
49
100
  key = [m.operator, normalized_token(m, source)]
50
101
  occ = seen[key]
51
102
  seen[key] += 1
52
- self.for(subject, m, source, occ)
103
+ yield m, occ
53
104
  end
54
105
  end
55
106
 
107
+ # NUL-joins the id parts and returns the first 12 hex chars of their SHA256.
108
+ #
109
+ # @param parts [Array] the values that make up the id.
110
+ # @return [String] a 12-character hex id.
111
+ def digest(parts)
112
+ Digest::SHA256.hexdigest(parts.join("\x00"))[0, 12]
113
+ end
114
+
56
115
  # Extracts the exact code being mutated, whitespace-collapsed — the same
57
116
  # normalization the Reporter's `diff_for` uses for its token label.
58
117
  #
@@ -15,6 +15,8 @@ require_relative "mutators/safe_navigation"
15
15
  require_relative "mutators/range_literal"
16
16
  require_relative "mutators/negation_removal"
17
17
  require_relative "mutators/chain_link"
18
+ require_relative "mutators/operand_removal"
19
+ require_relative "mutators/array_literal"
18
20
 
19
21
  module Mutineer
20
22
  # Maps operator names to operator classes.
@@ -41,7 +43,9 @@ module Mutineer
41
43
  "safe_navigation" => Mutators::SafeNavigation,
42
44
  "range" => Mutators::RangeLiteral,
43
45
  "negation_removal" => Mutators::NegationRemoval,
44
- "chain_link" => Mutators::ChainLink
46
+ "chain_link" => Mutators::ChainLink,
47
+ "operand_removal" => Mutators::OperandRemoval,
48
+ "array_literal" => Mutators::ArrayLiteral
45
49
  }.freeze
46
50
 
47
51
  # The default Tier-1 operator set.
@@ -49,7 +53,7 @@ module Mutineer
49
53
  # Tier-2 operators that remain opt-in.
50
54
  TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
51
55
  safe_navigation range negation_removal
52
- chain_link].freeze
56
+ chain_link operand_removal array_literal].freeze
53
57
 
54
58
  # Short human-readable descriptions for each operator.
55
59
  DESCRIPTIONS = {
@@ -67,7 +71,9 @@ module Mutineer
67
71
  "safe_navigation" => "&. -> .",
68
72
  "range" => ".. <-> ...",
69
73
  "negation_removal" => "!x, not x -> x",
70
- "chain_link" => "drop one call from a chain: a.b.c -> a.c"
74
+ "chain_link" => "drop one call from a chain: a.b.c -> a.c",
75
+ "operand_removal" => "a && b -> a, b",
76
+ "array_literal" => "[a, b] -> []"
71
77
  }.freeze
72
78
 
73
79
  # Resolves operator names to classes.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Array-literal mutator (Tier-2).
8
+ #
9
+ # Replaces a non-empty array literal with an empty one, one mutation per
10
+ # literal: `[a, b]` and `%i[a b]` become `[]`. The mutant survives when no
11
+ # test checks the contents of the array.
12
+ class ArrayLiteral < Base
13
+ # Visits array nodes and emits array-literal mutations.
14
+ #
15
+ # @param node [Prism::ArrayNode] array node to inspect.
16
+ # @return [void]
17
+ def visit_array_node(node)
18
+ emit(node)
19
+ super # nested arrays each get their own mutation
20
+ end
21
+
22
+ # Skips a nested method definition. The project finds it as a subject of
23
+ # its own, so a visit here counts its arrays twice.
24
+ #
25
+ # @param node [Prism::DefNode] nested definition node.
26
+ # @return [void]
27
+ def visit_def_node(node); end
28
+
29
+ private
30
+
31
+ # Emits a mutation for a non-empty array with brackets.
32
+ #
33
+ # The operator skips an implicit array (`x = 1, 2`), because it has no
34
+ # brackets. It also skips an array that holds a heredoc, because the
35
+ # heredoc body stays behind as code.
36
+ #
37
+ # @param node [Prism::ArrayNode] array node to inspect.
38
+ # @return [void]
39
+ def emit(node)
40
+ return if node.opening_loc.nil? || node.elements.empty? || heredoc?(node)
41
+
42
+ @mutations << Mutation.new(
43
+ start_offset: node.location.start_offset,
44
+ end_offset: node.location.end_offset,
45
+ replacement: "[]",
46
+ operator: :array_literal
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end
@@ -26,6 +26,21 @@ module Mutineer
26
26
  subject.def_node.body&.accept(self)
27
27
  @mutations
28
28
  end
29
+
30
+ private
31
+
32
+ # Returns whether a node is, or contains, a heredoc.
33
+ #
34
+ # A heredoc's body lies outside its node's byte range. A mutation that
35
+ # deletes the node leaves the body behind as code, so the mutant
36
+ # always raises.
37
+ #
38
+ # @param node [Prism::Node] node to inspect.
39
+ # @return [Boolean] true when a heredoc is inside the node.
40
+ def heredoc?(node)
41
+ (node.respond_to?(:heredoc?) && node.heredoc?) ||
42
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
43
+ end
29
44
  end
30
45
  end
31
46
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Operand-removal mutator (Tier-2).
8
+ #
9
+ # Replaces a boolean expression with one of its operands, two mutations
10
+ # per `&&`, `||`, `and` or `or`: `a && b` becomes `(a)` and `(b)`. The
11
+ # parentheses keep the precedence of `and` and `or`. A surviving mutant
12
+ # shows that no test needs the operand that was removed.
13
+ class OperandRemoval < Base
14
+ # Node types for jumps. A jump in a value context does not parse, so
15
+ # the operator never keeps a jump alone.
16
+ JUMPS = [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode].freeze
17
+
18
+ # Visits `and` nodes.
19
+ #
20
+ # @param node [Prism::AndNode] node to inspect.
21
+ # @return [void]
22
+ def visit_and_node(node)
23
+ emit(node)
24
+ super # nested connectors (a && b && c) each get their own mutations
25
+ end
26
+
27
+ # Visits `or` nodes.
28
+ #
29
+ # @param node [Prism::OrNode] node to inspect.
30
+ # @return [void]
31
+ def visit_or_node(node)
32
+ emit(node)
33
+ super
34
+ end
35
+
36
+ # Skips a nested method definition. The project finds it as a subject of
37
+ # its own, so a visit here counts its connectors twice.
38
+ #
39
+ # @param node [Prism::DefNode] nested definition node.
40
+ # @return [void]
41
+ def visit_def_node(node); end
42
+
43
+ private
44
+
45
+ # Emits one mutation that keeps the left operand, then one that keeps
46
+ # the right operand. The order is fixed, because the two mutants share
47
+ # a token and their occurrence ordinals tell them apart.
48
+ #
49
+ # @param node [Prism::AndNode, Prism::OrNode] connector node.
50
+ # @return [void]
51
+ def emit(node)
52
+ [[node.left, node.right], [node.right, node.left]].each do |kept, removed|
53
+ next if JUMPS.any? { |type| kept.is_a?(type) } || heredoc?(removed)
54
+
55
+ @mutations << Mutation.new(
56
+ start_offset: node.location.start_offset,
57
+ end_offset: node.location.end_offset,
58
+ replacement: "(#{kept.slice})",
59
+ operator: :operand_removal
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -5,9 +5,9 @@ module Mutineer
5
5
  # no Rails, no class loading, no process. Two jobs:
6
6
  # * expand_sources — a directory argument becomes its sorted **/*.rb files.
7
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.
8
+ # sources map to test/.../_test.rb, test/.../test_*.rb
9
+ # or spec/.../_spec.rb), preserving namespaced
10
+ # subdirectories. First EXISTING candidate wins.
11
11
  #
12
12
  # Independently unit-testable: every method is pure in/out over the
13
13
  # filesystem, so the pairing contract is exercised with plain fixtures, no
@@ -68,8 +68,8 @@ module Mutineer
68
68
  end
69
69
  end
70
70
 
71
- # Ordered candidate test paths. lib/ sources also get test/lib/... and
72
- # spec/lib/... (Rails apps put lib tests under either layout).
71
+ # Ordered candidate test paths: _test.rb, then Minitest's test_*.rb, then
72
+ # _spec.rb. lib/ sources also get the test/lib/... and spec/lib/... layouts.
73
73
  #
74
74
  # @param base [String] logical source path without extension.
75
75
  # @param lib [Boolean] whether the source originated from lib/.
@@ -78,6 +78,11 @@ module Mutineer
78
78
  def candidates(base, lib, prefer)
79
79
  minitest = ["test/#{base}_test.rb"]
80
80
  minitest << "test/lib/#{base}_test.rb" if lib
81
+ unless File.basename(base) == "helper" # test/test_helper.rb is Minitest's support file, not a test
82
+ prefixed = base.sub(%r{[^/]+\z}) { |name| "test_#{name}" }
83
+ minitest << "test/#{prefixed}.rb"
84
+ minitest << "test/lib/#{prefixed}.rb" if lib
85
+ end
81
86
  rspec = ["spec/#{base}_spec.rb"]
82
87
  rspec << "spec/lib/#{base}_spec.rb" if lib
83
88
  prefer == "rspec" ? rspec + minitest : minitest + rspec
@@ -36,23 +36,26 @@ module Mutineer
36
36
  def initialize(file)
37
37
  @file = file
38
38
  @namespace_stack = []
39
+ @lexical_stack = [] # class/module names as written, `::X` kept (#145)
39
40
  @subjects = []
40
41
  @singleton_depth = 0
41
42
  @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`
43
+ @module_function_names = [] # [namespace, name] from `module_function :a` / `module_function def` (#98)
43
44
  super()
44
45
  end
45
46
 
46
47
  # Promote `module_function :name` / `module_function def name` subjects to
47
48
  # 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
+ # the def, so it can't be decided at visit_def_node time (#20). Only methods
50
+ # of the module that made the call are promoted (#98); namespaces compare
51
+ # joined, since `module A::B` and nested `module A; module B` differ as arrays.
49
52
  #
50
53
  # @return [void]
51
54
  def promote_module_functions!
52
55
  return if @module_function_names.empty?
53
56
 
54
- names = @module_function_names.to_set
55
- @subjects.each { |s| s.singleton = true if names.include?(s.name) }
57
+ named = @module_function_names.to_set
58
+ @subjects.each { |s| s.singleton = true if named.include?([s.namespace.join("::"), s.name]) }
56
59
  end
57
60
 
58
61
  # Visits class nodes and tracks namespace nesting.
@@ -60,12 +63,7 @@ module Mutineer
60
63
  # @param node [Prism::ClassNode] class node.
61
64
  # @return [void]
62
65
  def visit_class_node(node)
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
66
- super
67
- @module_function_active = saved
68
- @namespace_stack.pop
66
+ with_namespace(node.constant_path) { super }
69
67
  end
70
68
 
71
69
  # Visits module nodes and tracks namespace nesting.
@@ -73,12 +71,7 @@ module Mutineer
73
71
  # @param node [Prism::ModuleNode] module node.
74
72
  # @return [void]
75
73
  def visit_module_node(node)
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
79
- super
80
- @module_function_active = saved
81
- @namespace_stack.pop
74
+ with_namespace(node.constant_path) { super }
82
75
  end
83
76
 
84
77
  # Track `module_function` so its methods are recorded as singletons (#20) —
@@ -94,9 +87,10 @@ module Mutineer
94
87
  if args.empty?
95
88
  @module_function_active = true
96
89
  else
90
+ namespace = @namespace_stack.join("::")
97
91
  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)
92
+ @module_function_names << [namespace, arg.value.to_sym] if arg.is_a?(Prism::SymbolNode)
93
+ @module_function_names << [namespace, arg.name] if arg.is_a?(Prism::DefNode)
100
94
  end
101
95
  end
102
96
  end
@@ -127,6 +121,7 @@ module Mutineer
127
121
  @subjects << Subject.new(
128
122
  file: @file,
129
123
  namespace: @namespace_stack.dup,
124
+ lexical: @lexical_stack.dup,
130
125
  name: node.name,
131
126
  singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
132
127
  def_node: node
@@ -136,6 +131,40 @@ module Mutineer
136
131
 
137
132
  private
138
133
 
134
+ # Runs the block with `path` pushed as the current namespace. A
135
+ # root-anchored path (`module ::X` / `class ::X`) names the top-level X,
136
+ # not X nested in the enclosing scope, so the namespace restarts there.
137
+ # Bareword `module_function` state does not cross a class or module
138
+ # boundary: each body starts without it, and the outer state returns after.
139
+ #
140
+ # @param path [Prism::Node] the class/module constant path.
141
+ # @yield the class or module body visit.
142
+ # @return [void]
143
+ def with_namespace(path)
144
+ saved_stack = @namespace_stack
145
+ saved_lexical = @lexical_stack
146
+ saved_active = @module_function_active
147
+ name = extract_constant_name(path)
148
+ root = root_anchored?(path)
149
+ @namespace_stack = root ? [name] : saved_stack + [name]
150
+ @lexical_stack = saved_lexical + [root ? "::#{name}" : name]
151
+ @module_function_active = false
152
+ yield
153
+ ensure
154
+ @namespace_stack = saved_stack
155
+ @lexical_stack = saved_lexical
156
+ @module_function_active = saved_active
157
+ end
158
+
159
+ # True when a constant path starts with `::` (e.g. `::X` or `::A::B`).
160
+ #
161
+ # @param node [Prism::Node] constant path node.
162
+ # @return [Boolean]
163
+ def root_anchored?(node)
164
+ node = node.parent while node.is_a?(Prism::ConstantPathNode) && node.parent
165
+ node.is_a?(Prism::ConstantPathNode)
166
+ end
167
+
139
168
  # Extracts a constant name from a Prism constant node.
140
169
  #
141
170
  # @api private
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Realpath-based path normalization against a project root. Shared by the
5
+ # coverage cache (map keys) and mutant ids (the path hashed into each id), so
6
+ # `lib/x.rb`, `./lib/x.rb`, an absolute path and a path through a symlinked
7
+ # root all resolve to the same key. Realpaths on both sides also absorb the
8
+ # macOS `/var` vs `/private/var` alias.
9
+ module ProjectPath
10
+ module_function
11
+
12
+ # Path of `path` relative to the real project root. A path outside the root
13
+ # comes back as its absolute real path.
14
+ #
15
+ # @param path [String] relative (to `root`) or absolute path.
16
+ # @param root [String] project root.
17
+ # @return [String] root-relative path, or an absolute path when outside `root`.
18
+ def relative(path, root)
19
+ abs = absolute(path, root)
20
+ real_root = root_real(root)
21
+ prefix = real_root.end_with?("/") ? real_root : "#{real_root}/"
22
+ return abs unless abs.start_with?(prefix)
23
+
24
+ abs.delete_prefix(prefix)
25
+ end
26
+
27
+ # Expands `path` against `root`, resolved to its real path when it exists.
28
+ #
29
+ # @param path [String] relative (to `root`) or absolute path.
30
+ # @param root [String] project root.
31
+ # @return [String] absolute path.
32
+ def absolute(path, root)
33
+ # Join, don't expand: File.expand_path collapses `..` textually, before any
34
+ # symlink is followed, so `link/../x.rb` would name the wrong file. The file
35
+ # system resolves `..` physically in File.realpath.
36
+ # A leading `~` names the home directory (as File.expand_path reads it), so
37
+ # expand it rather than joining it under the root.
38
+ raw = if File.absolute_path?(path) then path
39
+ elsif path.start_with?("~") then File.expand_path(path)
40
+ else File.join(File.expand_path(root), path)
41
+ end
42
+ File.exist?(raw) ? File.realpath(raw) : File.expand_path(raw)
43
+ end
44
+
45
+ # Canonical project root (`/var` vs `/private/var`). Any file system error
46
+ # (missing, unreadable, a symlink loop) falls back to the expanded path, so
47
+ # a caller such as the CLI's run-root warning never crashes on it.
48
+ #
49
+ # @param root [String] project root.
50
+ # @return [String] realpath of the root when it exists, else its expanded path.
51
+ def root_real(root)
52
+ File.realpath(File.expand_path(root))
53
+ rescue SystemCallError
54
+ File.expand_path(root)
55
+ end
56
+ end
57
+ end
@@ -35,11 +35,13 @@ module Mutineer
35
35
  # or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
36
36
  # (`--since`) run; the JSON report records it so a consumer (or a later
37
37
  # `--baseline` load) knows the score covers only the changed-line mutants.
38
+ # `legacy_id_matches` (`{ignore:, baseline:}`) counts stored ids still in the
39
+ # old format (#126); only the JSON report records it (`summary.legacy_id_matches`).
38
40
  def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
39
- baseline: nil, scoped: false)
41
+ baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
40
42
  rendered =
41
43
  if format == "json"
42
- json_report(baseline, scoped: scoped)
44
+ json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches)
43
45
  elsif format == "html"
44
46
  html_report
45
47
  else
@@ -131,8 +133,11 @@ module Mutineer
131
133
  # @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
132
134
  # @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
133
135
  # covers only the changed-line mutants (additive `summary.scoped` key).
136
+ # @param legacy_id_matches [Hash{Symbol => Integer}] `{ignore:, baseline:}`:
137
+ # old-format ignore entries that matched, and survivors matched in an
138
+ # old-format baseline only through their old id (#126).
134
139
  # @return [String] JSON text.
135
- def json_report(baseline = nil, scoped: false)
140
+ def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
136
141
  killed = @agg.killed_count
137
142
  survived = @agg.survived_count
138
143
  # null (not 0.0) on an empty denominator, matching the nil-vs-0.0
@@ -141,7 +146,7 @@ module Mutineer
141
146
  score = @agg.mutation_score
142
147
 
143
148
  doc = {
144
- schema_version: "1.3",
149
+ schema_version: "1.4",
145
150
  summary: {
146
151
  total: @agg.total, killed: killed, survived: survived,
147
152
  no_coverage: @agg.no_coverage_count,
@@ -155,7 +160,15 @@ module Mutineer
155
160
  # Additive: true when the run was diff-scoped (--since). The score then
156
161
  # covers only the changed-line mutants, so it is not comparable to a
157
162
  # full-run score; Baseline#diff reads this to skip the score-drop gate.
158
- scoped: scoped
163
+ scoped: scoped,
164
+ # Additive (1.4, #126): ids hash the project-relative file path. A
165
+ # baseline without this key stores old-format ids; Baseline#diff then
166
+ # also matches on old ids.
167
+ id_format: 2,
168
+ # Additive (1.4, #126): stored ids still in the old format. `ignore` is
169
+ # the number of old-format ignore entries that matched; `baseline` the
170
+ # survivors matched in the baseline only through their old id.
171
+ legacy_id_matches: legacy_id_matches
159
172
  },
160
173
  survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
161
174
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
@@ -28,8 +28,8 @@ module Mutineer
28
28
  # `subject`, `mutation`, and `id` are nil when the Result is built by
29
29
  # Isolation/Runner (which only know the outcome); the orchestrator attaches
30
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.
31
+ # can render survivor diffs and emit the id. `id` is the content-based
32
+ # MutantId (it includes the project-relative file path).
33
33
  Result = Data.define(:status, :details, :subject, :mutation, :id) do
34
34
  # Builds a killed result.
35
35
  #