active_mutator 0.1.0 → 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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -63
  3. data/lib/active_mutator/accepted_ledger.rb +23 -8
  4. data/lib/active_mutator/atomic_file.rb +1 -1
  5. data/lib/active_mutator/baseline.rb +13 -4
  6. data/lib/active_mutator/baseline_delta.rb +67 -1
  7. data/lib/active_mutator/baseline_hooks.rb +2 -2
  8. data/lib/active_mutator/cli.rb +16 -4
  9. data/lib/active_mutator/config.rb +2 -1
  10. data/lib/active_mutator/config_file.rb +89 -0
  11. data/lib/active_mutator/coverage_map.rb +1 -1
  12. data/lib/active_mutator/defined_constants.rb +48 -0
  13. data/lib/active_mutator/edit.rb +8 -2
  14. data/lib/active_mutator/engine.rb +15 -2
  15. data/lib/active_mutator/fingerprint.rb +1 -1
  16. data/lib/active_mutator/inserter.rb +6 -3
  17. data/lib/active_mutator/operators/base.rb +2 -1
  18. data/lib/active_mutator/operators/call_swap.rb +16 -0
  19. data/lib/active_mutator/operators/literal.rb +14 -2
  20. data/lib/active_mutator/reporter/github.rb +36 -0
  21. data/lib/active_mutator/reporter/json.rb +1 -0
  22. data/lib/active_mutator/reporter/operator_stats.rb +20 -0
  23. data/lib/active_mutator/reporter/stryker_json.rb +117 -0
  24. data/lib/active_mutator/reporter/terminal.rb +11 -0
  25. data/lib/active_mutator/runner.rb +121 -17
  26. data/lib/active_mutator/scheduler.rb +64 -7
  27. data/lib/active_mutator/source_location.rb +21 -0
  28. data/lib/active_mutator/subject.rb +7 -1
  29. data/lib/active_mutator/subject_finder.rb +46 -7
  30. data/lib/active_mutator/subject_matcher.rb +23 -0
  31. data/lib/active_mutator/timeout_calibrator.rb +75 -0
  32. data/lib/active_mutator/version.rb +1 -1
  33. data/lib/active_mutator/work_item.rb +8 -1
  34. data/lib/active_mutator/worker.rb +5 -2
  35. data/lib/active_mutator.rb +8 -0
  36. metadata +13 -5
@@ -0,0 +1,89 @@
1
+ require "yaml"
2
+
3
+ module ActiveMutator
4
+ # Project config file, layered UNDER CLI flags: CLI.parse seeds its option
5
+ # defaults from this before OptionParser runs, so any flag given on the
6
+ # command line wins. Strict on unknown keys and types — a typo silently
7
+ # ignored would be a config that silently doesn't apply.
8
+ class ConfigFile
9
+ FILENAME = ".active_mutator.yml"
10
+
11
+ FORMATS = %w[terminal json stryker-json github].freeze
12
+
13
+ KEYS = {
14
+ "jobs" => :integer,
15
+ "format" => :format,
16
+ "timeout_factor" => :number,
17
+ "timeout_floor" => :number,
18
+ "browser_boot_seconds" => :number,
19
+ "fail_at" => :score,
20
+ "exclude" => :string_list,
21
+ "serial_patterns" => :string_list,
22
+ "requires" => :string_list,
23
+ "operators" => :string_list,
24
+ "preload_helper" => :preload_helper,
25
+ "adaptive_timeout" => :boolean
26
+ }.freeze
27
+
28
+ # YAML keys that don't match their Config member name.
29
+ RENAMES = { "operators" => :operator_paths }.freeze
30
+
31
+ def self.load(root)
32
+ path = File.join(root, FILENAME)
33
+ return {} unless File.exist?(path)
34
+
35
+ data = parse(path)
36
+ return {} if data.nil?
37
+ raise Error, "#{FILENAME}: top level must be a mapping" unless data.is_a?(Hash)
38
+
39
+ data.to_h do |key, value|
40
+ validator = KEYS[key]
41
+ raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
42
+
43
+ [RENAMES.fetch(key, key.to_sym), coerce(key, validator, value)]
44
+ end
45
+ end
46
+
47
+ def self.parse(path)
48
+ YAML.safe_load_file(path, aliases: true)
49
+ rescue Psych::Exception => e
50
+ raise Error, "#{FILENAME}: #{e.message}"
51
+ end
52
+
53
+ def self.coerce(key, validator, value)
54
+ case validator
55
+ when :integer
56
+ raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
57
+ value
58
+ when :number
59
+ raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
60
+ value.to_f
61
+ when :score
62
+ raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
63
+ raise Error, "#{FILENAME}: #{key} must be within 0..100" unless (0..100).cover?(value)
64
+ value.to_f
65
+ when :format
66
+ unless FORMATS.include?(value)
67
+ raise Error, "#{FILENAME}: format must be one of #{FORMATS.join(", ")}"
68
+ end
69
+ value.tr("-", "_").to_sym
70
+ when :string_list
71
+ unless value.is_a?(Array) && value.all?(String)
72
+ raise Error, "#{FILENAME}: #{key} must be a list of strings"
73
+ end
74
+ value
75
+ when :boolean
76
+ unless [true, false].include?(value)
77
+ raise Error, "#{FILENAME}: #{key} must be true or false"
78
+ end
79
+ value
80
+ when :preload_helper
81
+ return :none if value == false
82
+ raise Error, "#{FILENAME}: preload_helper must be a path or false" unless value.is_a?(String)
83
+ value
84
+ else
85
+ raise Error, "unhandled validator #{validator}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -3,7 +3,7 @@ require "json"
3
3
  module ActiveMutator
4
4
  # Cache format v2: primary data is per-example `records`
5
5
  # ({example_id => [[abs_path, line], ...]}); the inverted index is derived
6
- # in memory at load. A missing/old version is simply stale the cache is
6
+ # in memory at load. A missing/old version is simply stale: the cache is
7
7
  # disposable, so there is no migration path, only regeneration.
8
8
  class CoverageMap
9
9
  def self.load(path) = new(JSON.parse(File.read(path)))
@@ -0,0 +1,48 @@
1
+ require "prism"
2
+
3
+ module ActiveMutator
4
+ # Deepest fully qualified names of classes/modules a source file defines
5
+ # ("Billing::Invoice"). Two shorthands are deliberately never emitted,
6
+ # because either would let a single common token match half of any real
7
+ # spec suite and trip BaselineDelta's full-run fallback on every edit:
8
+ # - bare leaves ("Config" for MyApp::Config)
9
+ # - pure namespace wrappers ("MyApp" for `module MyApp; class Config`):
10
+ # every file in a namespaced app reopens the top module, and every
11
+ # spec mentions it.
12
+ # A wrapper is a node whose non-empty direct body contains ONLY nested
13
+ # class/module definitions. A module with its own defs/macros/constants is
14
+ # a real edit target and IS emitted; so is an empty or def-less leaf class
15
+ # (macro-only ActiveRecord models).
16
+ #
17
+ # Guard is errors.any?, not warnings: Prism produces a complete AST for
18
+ # warnings-only input (`if a = 2`), and those definitions are real.
19
+ module DefinedConstants
20
+ def self.in_source(source)
21
+ result = Prism.parse(source)
22
+ return [] if result.errors.any?
23
+
24
+ names = []
25
+ walk(result.value, [], names)
26
+ names.uniq
27
+ end
28
+
29
+ # The walk intentionally descends into block bodies (unlike SubjectFinder,
30
+ # which skips them): over-inclusion is the safe direction for spec-file
31
+ # matching, so a constant defined inside a block is still emitted.
32
+ def self.walk(node, scope, names)
33
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
34
+ scope = scope + [node.constant_path.slice]
35
+ names << scope.join("::") unless namespace_wrapper?(node)
36
+ end
37
+ node.compact_child_nodes.each { |child| walk(child, scope, names) }
38
+ end
39
+ private_class_method :walk
40
+
41
+ def self.namespace_wrapper?(node)
42
+ statements = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
43
+ statements.any? &&
44
+ statements.all? { |s| s.is_a?(Prism::ClassNode) || s.is_a?(Prism::ModuleNode) }
45
+ end
46
+ private_class_method :namespace_wrapper?
47
+ end
48
+ end
@@ -1,5 +1,11 @@
1
1
  module ActiveMutator
2
2
  # A single mutation as a text edit: replace `range` (exclusive byte Range)
3
- # in the original source with `replacement`.
4
- Edit = Data.define(:range, :replacement, :description)
3
+ # in the original source with `replacement`. `operator` is the producing
4
+ # operator's demodulized class name ("CallSwap"), "Unknown" outside the
5
+ # operator pipeline.
6
+ Edit = Data.define(:range, :replacement, :description, :operator) do
7
+ def initialize(range:, replacement:, description:, operator: "Unknown")
8
+ super
9
+ end
10
+ end
5
11
  end
@@ -35,14 +35,27 @@ module ActiveMutator
35
35
  def collect_edits(def_node)
36
36
  edits = []
37
37
  walk(def_node.body) do |node|
38
- @operators.each { |op| edits.concat(op.edits(node)) }
38
+ @operators.each do |op|
39
+ edits.concat(op.edits(node))
40
+ rescue StandardError => e
41
+ # Fail loud but attributed: a buggy (likely third-party) operator
42
+ # should point at itself, not surface as a bare crash mid-analysis.
43
+ raise Error, "operator #{op.class.name} failed on #{node.class.name}: #{e.message}"
44
+ end
39
45
  end
40
46
  edits
41
47
  end
42
48
 
43
49
  def walk(node, &blk)
44
50
  return if node.nil?
45
- return if node.is_a?(Prism::DefNode) # nested defs are separate subjects
51
+ # Descend into nested DefNodes rather than treating them as separate
52
+ # subjects. Giving a nested def its own subject identity is a trap:
53
+ # every call of the outer method re-executes the nested `def`, which
54
+ # would silently revert a directly-inserted mutant mid-run (phantom
55
+ # survivors). Instead we mutate the nested body as part of the outer
56
+ # def's re-evaled source. (SubjectFinder still emits no subject for
57
+ # nested defs.) walk is called as walk(def_node.body), so the outer
58
+ # DefNode itself never passes through here.
46
59
 
47
60
  yield node
48
61
  node.compact_child_nodes.each { |child| walk(child, &blk) }
@@ -1,7 +1,7 @@
1
1
  module ActiveMutator
2
2
  # Line-number-independent identity for a mutant, used by the acceptance
3
3
  # ledger. `ordinal` disambiguates byte-identical mutants within one subject
4
- # (e.g. the two `>` in `a > 0 && b > 0`) by source order without it,
4
+ # (e.g. the two `>` in `a > 0 && b > 0`) by source order. Without it,
5
5
  # accepting one would silently accept both.
6
6
  Fingerprint = Data.define(:file, :subject, :description, :original_snippet, :ordinal) do
7
7
  def self.for_mutations(mutations, root:)
@@ -1,13 +1,16 @@
1
1
  module ActiveMutator
2
2
  # Redefines the subject's method with its mutated source. `class_eval` of a
3
3
  # `def` handles instance methods; a `def self.x` source string defines the
4
- # singleton method the same way. Top-level subjects eval at main scope.
4
+ # singleton method the same way. An sclass subject's source is a plain
5
+ # `def foo` that must land on the constant's singleton class, so we route it
6
+ # through `.singleton_class.class_eval`. Top-level subjects eval at main scope.
5
7
  class Inserter
6
8
  def insert(mutation)
7
9
  subject = mutation.subject
8
10
  if subject.constant_scope
9
- Object.const_get(subject.constant_scope)
10
- .class_eval(mutation.mutated_def_source, subject.file, mutation.mutated_def_line)
11
+ target = Object.const_get(subject.constant_scope)
12
+ target = target.singleton_class if subject.sclass
13
+ target.class_eval(mutation.mutated_def_source, subject.file, mutation.mutated_def_line)
11
14
  else
12
15
  eval(mutation.mutated_def_source, TOPLEVEL_BINDING, # rubocop:disable Security/Eval
13
16
  subject.file, mutation.mutated_def_line)
@@ -18,7 +18,8 @@ module ActiveMutator
18
18
  def loc_range(loc) = loc.start_offset...loc.end_offset
19
19
 
20
20
  def edit(range, replacement, description)
21
- Edit.new(range: range, replacement: replacement, description: description)
21
+ Edit.new(range: range, replacement: replacement, description: description,
22
+ operator: self.class.name.split("::").last)
22
23
  end
23
24
  end
24
25
  end
@@ -9,6 +9,22 @@ module ActiveMutator
9
9
  min: "max", max: "min",
10
10
  first: "last", last: "first",
11
11
  any?: "none?", none?: "any?",
12
+ # all? is one-way: any? already pairs with none?, so all?→any? adds a
13
+ # distinct mutant without a redundant reverse edge.
14
+ all?: "any?",
15
+ take: "drop", drop: "take",
16
+ min_by: "max_by", max_by: "min_by",
17
+ # sort→reverse is one-way by design: reverse already has a strong
18
+ # forward mutant here, and reverse→sort would double-map `reverse`
19
+ # against nothing useful (reverse has no MAP entry to preserve).
20
+ sort: "reverse",
21
+ # detect/find→first is one-way: first ignores the retained block, so
22
+ # the mutant usually differs (equivalent only when element 0 already
23
+ # satisfies the predicate). No reverse edge: `first` is taken by
24
+ # first→last above.
25
+ detect: "first", find: "first",
26
+ # Evaluated and rejected: sum (initial-arg arity mismatch),
27
+ # find_index (no safe partner — rindex is Array-only).
12
28
  # Rails-aware pack:
13
29
  present?: "blank?", blank?: "present?",
14
30
  save: "save!", save!: "save"
@@ -23,8 +23,8 @@ module ActiveMutator
23
23
 
24
24
  def string_edits(node)
25
25
  opening = node.opening_loc&.slice
26
- return [] unless opening # quote-less parts (interpolation)
27
- return [] if opening.start_with?("<<") # heredocs: v1 limit
26
+ return [] unless opening # quote-less parts (interpolation)
27
+ return heredoc_edits(node) if opening.start_with?("<<")
28
28
 
29
29
  if node.unescaped.empty?
30
30
  [edit(loc_range(node.location), %("active_mutator"), %(replace "" with "active_mutator"))]
@@ -32,6 +32,18 @@ module ActiveMutator
32
32
  [edit(loc_range(node.location), %(""), %(replace string with ""))]
33
33
  end
34
34
  end
35
+
36
+ # The node span covers the `<<~X` opening token; splicing there breaks
37
+ # the source. Mutate the body content range instead: nonempty body →
38
+ # empty heredoc (opening line directly followed by the terminator).
39
+ # The guard is on the DEDENTED VALUE (unescaped), not content_loc: a
40
+ # squiggly body that dedents to "" would only lose whitespace bytes —
41
+ # an equivalent mutant — so it is skipped even though content is nonempty.
42
+ def heredoc_edits(node)
43
+ return [] if node.unescaped.empty?
44
+
45
+ [edit(loc_range(node.content_loc), "", "empty heredoc body")]
46
+ end
35
47
  end
36
48
  end
37
49
  end
@@ -0,0 +1,36 @@
1
+ module ActiveMutator
2
+ module Reporter
3
+ # GitHub Actions workflow-command projection (issue #19): one ::warning
4
+ # annotation per surviving mutant, inlined on the PR diff. Everything
5
+ # else mirrors the terminal reporter so CI logs stay readable.
6
+ class Github
7
+ def initialize(root:, out: $stdout)
8
+ @root = root
9
+ @terminal = Terminal.new(out: out)
10
+ @out = out
11
+ end
12
+
13
+ def on_result(result) = @terminal.on_result(result)
14
+
15
+ def summary(results, invalid_count:)
16
+ @terminal.summary(results, invalid_count: invalid_count)
17
+ results.select { |r| r.status == :survived }.each { |r| annotate(r) }
18
+ end
19
+
20
+ private
21
+
22
+ def annotate(result)
23
+ m = result.mutation
24
+ file = m.subject.file.delete_prefix(@root.chomp("/") + "/")
25
+ message = "#{m.subject.name}: #{m.description} | - #{m.original_snippet} | + #{m.edit.replacement}"
26
+ @out.puts "::warning file=#{file},line=#{m.line},title=Surviving mutant::#{encode(message)}"
27
+ end
28
+
29
+ # GitHub workflow commands terminate at a raw newline; percent-encode
30
+ # per https://github.com/actions/toolkit runner rules.
31
+ def encode(message)
32
+ message.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -15,6 +15,7 @@ module ActiveMutator
15
15
  "score" => Terminal.score(counts),
16
16
  "counts" => counts.transform_keys(&:to_s),
17
17
  "invalid" => invalid_count,
18
+ "operators" => OperatorStats.call(results),
18
19
  "results" => results.map { |r| serialize(r) },
19
20
  "exit_reason" => counts.fetch(:survived, 0).positive? ? "unaccepted_survivors" : "clean"
20
21
  )
@@ -0,0 +1,20 @@
1
+ module ActiveMutator
2
+ module Reporter
3
+ # Per-operator noise signal (issue #20): equivalent_rate =
4
+ # survived / (killed + survived). Every :survived result is covered by
5
+ # construction (uncovered mutants never reach the scheduler), so this is
6
+ # the covered-survivor rate. It deliberately conflates true equivalents
7
+ # with weak assertions — it is an aggregate signal, not a score.
8
+ module OperatorStats
9
+ def self.call(results)
10
+ results.group_by { |r| r.mutation.edit.operator }.to_h do |operator, group|
11
+ killed = group.count { |r| r.status == :killed }
12
+ survived = group.count { |r| r.status == :survived }
13
+ denominator = killed + survived
14
+ rate = denominator.zero? ? 0.0 : (survived.to_f / denominator).round(3)
15
+ [operator, { "killed" => killed, "survived" => survived, "equivalent_rate" => rate }]
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,117 @@
1
+ require "json"
2
+
3
+ module ActiveMutator
4
+ module Reporter
5
+ # mutation-testing-report-schema v2 (the Stryker ecosystem format).
6
+ # Load the written file in https://microsoft.github.io/mutation-testing-elements/
7
+ # for the interactive per-file mutant viewer.
8
+ #
9
+ # Schema constraints honored here: 1-based positive line/column, integer
10
+ # thresholds, tool-specific data only under config.active_mutator.
11
+ # Invalid mutants are discarded before results exist, so they appear as a
12
+ # count in the extras, never as CompileError mutants.
13
+ class StrykerJson
14
+ SCHEMA_URL = "https://git.io/mutation-testing-schema"
15
+ STATUS = { killed: "Killed", survived: "Survived", timeout: "Timeout",
16
+ error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored" }.freeze
17
+ ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
18
+ REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
19
+
20
+ # Injected by Runner once the baseline map exists; nil in unit tests.
21
+ attr_writer :coverage_map
22
+
23
+ def initialize(root:, out: $stdout)
24
+ @root = root
25
+ @out = out
26
+ @coverage_map = nil
27
+ end
28
+
29
+ def on_result(result)
30
+ @out.print(Terminal::CHARS.fetch(result.status))
31
+ end
32
+
33
+ def summary(results, invalid_count:)
34
+ report = build_report(results, invalid_count)
35
+ path = File.join(@root, REPORT_PATH)
36
+ AtomicFile.write(path, JSON.pretty_generate(report))
37
+ @out.puts "", "", "Stryker report written to #{REPORT_PATH}"
38
+ end
39
+
40
+ private
41
+
42
+ def build_report(results, invalid_count)
43
+ mutants_by_file = results.group_by { |r| r.mutation.subject.file }
44
+ report = {
45
+ "$schema" => SCHEMA_URL,
46
+ "schemaVersion" => "2",
47
+ "thresholds" => { "high" => 80, "low" => 60 },
48
+ "projectRoot" => @root,
49
+ "config" => { "active_mutator" => { "invalid_discarded" => invalid_count,
50
+ "version" => VERSION } },
51
+ "files" => mutants_by_file.to_h { |file, rs| [relative(file), file_entry(file, rs)] }
52
+ }
53
+ tests = referenced_examples(results)
54
+ report["testFiles"] = test_files(tests) unless tests.empty?
55
+ report
56
+ end
57
+
58
+ def file_entry(file, results)
59
+ source = File.read(file)
60
+ { "language" => "ruby", "source" => source,
61
+ "mutants" => results.map { |r| mutant(r, source) } }
62
+ end
63
+
64
+ def mutant(result, source)
65
+ loc = SourceLocation.locate(source, result.mutation.edit.range)
66
+ entry = {
67
+ "id" => next_id,
68
+ "mutatorName" => result.mutation.edit.operator,
69
+ "location" => { "start" => stringify(loc[:start]), "end" => stringify(loc[:end]) },
70
+ "status" => STATUS.fetch(result.status),
71
+ "replacement" => result.mutation.edit.replacement,
72
+ "description" => result.mutation.description
73
+ }
74
+ reason = status_reason(result)
75
+ entry["statusReason"] = reason if reason
76
+ covered = covered_by(result)
77
+ entry["coveredBy"] = covered if covered
78
+ entry
79
+ end
80
+
81
+ def status_reason(result)
82
+ return ACCEPTED_REASON if result.status == :accepted
83
+
84
+ result.details&.to_s
85
+ end
86
+
87
+ def covered_by(result)
88
+ return nil unless @coverage_map
89
+
90
+ @coverage_map.examples_for(result.mutation.subject.file, result.mutation.lines)
91
+ end
92
+
93
+ def referenced_examples(results)
94
+ results.flat_map { |r| covered_by(r) || [] }.uniq.sort
95
+ end
96
+
97
+ # Group example ids by spec path (the id up to the trailing "[...]")
98
+ # so the viewer's test panel resolves coveredBy references.
99
+ def test_files(example_ids)
100
+ example_ids
101
+ .group_by { |id| id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "") }
102
+ .transform_values do |ids|
103
+ { "tests" => ids.map { |id| { "id" => id, "name" => id } } }
104
+ end
105
+ end
106
+
107
+ def next_id
108
+ @next_id = (@next_id || -1) + 1
109
+ @next_id.to_s
110
+ end
111
+
112
+ def stringify(position) = { "line" => position[:line], "column" => position[:column] }
113
+
114
+ def relative(file) = file.delete_prefix(@root.chomp("/") + "/")
115
+ end
116
+ end
117
+ end
@@ -21,6 +21,9 @@ module ActiveMutator
21
21
  @out.puts format("Mutation score: %.1f%%", score(counts) * 100)
22
22
  survivors = results.select { |r| r.status == :survived }
23
23
  print_survivors(survivors) unless survivors.empty?
24
+ stats = OperatorStats.call(results)
25
+ noisy = stats.select { |_, s| s["survived"].positive? }
26
+ print_operator_stats(noisy) unless noisy.empty?
24
27
  end
25
28
 
26
29
  def self.score(counts)
@@ -35,6 +38,14 @@ module ActiveMutator
35
38
 
36
39
  def score(counts) = self.class.score(counts)
37
40
 
41
+ def print_operator_stats(stats)
42
+ @out.puts "", "Equivalent-rate by operator (survived / (killed + survived)):"
43
+ stats.sort_by { |_, s| -s["equivalent_rate"] }.each do |operator, s|
44
+ @out.puts format(" %-24s %5.1f%% (%d survived / %d killed)",
45
+ operator, s["equivalent_rate"] * 100, s["survived"], s["killed"])
46
+ end
47
+ end
48
+
38
49
  def print_survivors(survivors)
39
50
  @out.puts "", "Surviving mutants:"
40
51
  survivors.each do |result|