active_mutator 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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|
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  module ActiveMutator
2
4
  class Runner
3
5
  def initialize(config, reporter: nil)
@@ -7,24 +9,34 @@ module ActiveMutator
7
9
 
8
10
  def call
9
11
  ENV["ACTIVE_MUTATOR"] = "1"
12
+ load_operators
10
13
  preload!
11
14
  preload_spec_helper!
12
15
  map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
16
+ @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
13
17
  subjects = discover_subjects
14
18
  analyses = subjects.map { |s| Engine.new.analyze(s) }
15
19
  mutations = analyses.flat_map(&:mutations)
20
+ mutations = mutations.first(@config.max_mutants) if @config.max_mutants
16
21
  invalid_count = analyses.sum(&:invalid_count)
17
22
 
18
23
  fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
19
24
  ledger = AcceptedLedger.load(@config.root)
20
- warn_stale(ledger, fingerprints.values)
25
+ scanned_files = prune_scope(subjects)
26
+ warn_stale(ledger, fingerprints.values, scanned_files)
21
27
 
22
28
  items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
29
+ return debug_plan(items, pre_results) if @config.debug_plan
30
+
23
31
  pre_results.each { |r| @reporter.on_result(r) }
24
- scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result))
32
+ calibrators = if @config.adaptive_timeout
33
+ { parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
34
+ end
35
+ scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
36
+ calibrators: calibrators)
25
37
  results = scheduler.run(items) + pre_results
26
38
 
27
- accept_survivors!(ledger, results, fingerprints) if @config.accept_survivors
39
+ accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
28
40
 
29
41
  @reporter.summary(results, invalid_count: invalid_count)
30
42
  exit_code(results)
@@ -39,27 +51,60 @@ module ActiveMutator
39
51
  pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
40
52
  next
41
53
  end
42
- example_ids = map.examples_for(mutation.subject.file, mutation.lines)
54
+ example_ids = map.examples_for(mutation.subject.file, coverage_lines(mutation))
43
55
  if example_ids.empty?
44
56
  pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
45
57
  else
46
58
  lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
47
- timeout = map.time_for(example_ids) * @config.timeout_factor + @config.timeout_floor
48
- timeout += @config.browser_boot_seconds if lane == :serial
49
- items << WorkItem.new(mutation: mutation, example_ids: example_ids, timeout: timeout, lane: lane)
59
+ variable = map.time_for(example_ids) * @config.timeout_factor
60
+ boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
61
+ timeout = variable + @config.timeout_floor + boot_extra
62
+ items << WorkItem.new(mutation: mutation, example_ids: example_ids,
63
+ timeout: timeout, lane: lane, variable: variable)
50
64
  end
51
65
  end
52
66
  [items, pre_results]
53
67
  end
54
68
 
55
69
  def exit_code(results)
56
- results.any? { |r| r.status == :survived } ? 1 : 0
70
+ survived = results.count { |r| r.status == :survived }
71
+ return 0 if survived.zero?
72
+ return 1 unless @config.fail_at
73
+
74
+ detected = results.count { |r| %i[killed timeout].include?(r.status) }
75
+ score = detected * 100.0 / (detected + survived)
76
+ score >= @config.fail_at ? 0 : 1
57
77
  end
58
78
 
59
79
  private
60
80
 
81
+ # Custom operators must exist in the PARENT before Engine analysis:
82
+ # subclassing Operators::Base self-registers, and forks inherit the
83
+ # loaded class. `requires` can't serve — those load inside the fork's
84
+ # setup, after mutations are already planned.
85
+ def load_operators
86
+ @config.operator_paths.each do |f|
87
+ require File.expand_path(f, @config.root)
88
+ rescue LoadError, SyntaxError => e
89
+ raise Error, "operator file not loadable: #{f}: #{e.message}"
90
+ end
91
+ end
92
+
93
+ # Line coverage attributes multi-line expressions to their statement anchor
94
+ # line (version-dependently), so a sub-expression mutant's own lines may
95
+ # carry no coverage at all. Look up the whole subject instead: a mutant must
96
+ # run against every example covering any line of its method.
97
+ def coverage_lines(mutation)
98
+ mutation.lines.to_a | mutation.subject.line_range.to_a
99
+ end
100
+
61
101
  def build_reporter
62
- @config.format == :json ? Reporter::Json.new : Reporter::Terminal.new
102
+ case @config.format
103
+ when :json then Reporter::Json.new
104
+ when :stryker_json then Reporter::StrykerJson.new(root: @config.root)
105
+ when :github then Reporter::Github.new(root: @config.root)
106
+ else Reporter::Terminal.new
107
+ end
63
108
  end
64
109
 
65
110
  def preload!
@@ -77,9 +122,14 @@ module ActiveMutator
77
122
  def discover_subjects
78
123
  paths = @config.paths.empty? ? default_paths : @config.paths
79
124
  subjects = paths
80
- .flat_map { |p| Dir[File.join(@config.root, p, "**", "*.rb")] }
125
+ .flat_map { |p| expand_path_arg(p) }
126
+ .uniq
127
+ .reject { |file| excluded?(file) }
81
128
  .sort.flat_map { |file| SubjectFinder.call(file) }
82
- subjects = subjects.select { |s| s.name == @config.subject_filter } if @config.subject_filter
129
+ if @config.subject_filter
130
+ matcher = SubjectMatcher.new(@config.subject_filter)
131
+ subjects = subjects.select { |s| matcher.match?(s.name) }
132
+ end
83
133
  if @config.since
84
134
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
85
135
  subjects = subjects.select { |s| filter.cover?(s) }
@@ -87,6 +137,34 @@ module ActiveMutator
87
137
  subjects
88
138
  end
89
139
 
140
+ # Positional args may be files or directories. Anything else is an error:
141
+ # a mistyped path that silently matched nothing produced a false green
142
+ # (0 subjects, exit 0) — see #23.
143
+ def expand_path_arg(path)
144
+ full = File.expand_path(path, @config.root)
145
+ if File.file?(full)
146
+ raise Error, "not a Ruby file: #{path}" unless full.end_with?(".rb")
147
+
148
+ [full]
149
+ elsif Dir.exist?(full)
150
+ Dir[File.join(full, "**", "*.rb")]
151
+ else
152
+ raise Error, "no such file or directory: #{path}"
153
+ end
154
+ end
155
+
156
+ def excluded?(file)
157
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
158
+ relative = file.delete_prefix(@config.root.chomp("/") + "/")
159
+ @config.exclude.any? do |pattern|
160
+ # Gitignore-like ergonomics: "lib/gen", "lib/gen/" and "lib/gen/**"
161
+ # all exclude the whole subtree, not just direct children.
162
+ dir = pattern.sub(%r{(/\*\*)?/?\z}, "")
163
+ File.fnmatch?(pattern, relative, flags) ||
164
+ File.fnmatch?("#{dir}/**/*", relative, flags)
165
+ end
166
+ end
167
+
90
168
  def default_paths
91
169
  %w[app lib].select { |p| Dir.exist?(File.join(@config.root, p)) }
92
170
  end
@@ -127,17 +205,43 @@ module ActiveMutator
127
205
  SimpleCov.at_exit {} if defined?(SimpleCov)
128
206
  end
129
207
 
130
- def accept_survivors!(ledger, results, fingerprints)
208
+ # Only a run with no subject-level narrowing has fully scanned a file;
209
+ # anything narrower must not prune (or warn about) out-of-scope entries.
210
+ # MAINTENANCE: any future flag that narrows the mutant set below "every
211
+ # subject in the scanned files" MUST be added to this nil-trigger list,
212
+ # or scoped accept runs will clobber out-of-scope ledger entries (#24).
213
+ def prune_scope(subjects)
214
+ return nil if @config.subject_filter || @config.since || @config.max_mutants
215
+
216
+ subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
217
+ end
218
+
219
+ def accept_survivors!(ledger, results, fingerprints, scanned_files)
131
220
  survivors = results.select { |r| r.status == :survived }.map { |r| fingerprints[r.mutation] }
132
221
  return if survivors.empty?
133
222
 
134
- ledger.accept!(survivors, fingerprints.values)
223
+ ledger.accept!(survivors, fingerprints.values, scanned_files: scanned_files)
135
224
  end
136
225
 
137
- def warn_stale(ledger, all_fingerprints)
138
- ledger.stale_entries(all_fingerprints).each do |entry|
226
+ def debug_plan(items, pre_results)
227
+ plan = items.map do |i|
228
+ { "subject" => i.mutation.subject.name, "description" => i.mutation.description,
229
+ "file" => i.mutation.subject.file, "line" => i.mutation.line,
230
+ "lane" => i.lane.to_s, "timeout" => i.timeout.round(2),
231
+ "examples" => i.example_ids.size }
232
+ end
233
+ skipped = pre_results.group_by { |r| r.status.to_s }.transform_values(&:size)
234
+ puts JSON.pretty_generate("planned" => plan, "pre_resolved" => skipped)
235
+ 0
236
+ end
237
+
238
+ def warn_stale(ledger, all_fingerprints, scanned_files)
239
+ ledger.stale_entries(all_fingerprints, scanned_files: scanned_files).each do |entry|
139
240
  warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}, #{entry.description}"
140
241
  end
242
+ ledger.missing_file_entries(@config.root).each do |entry|
243
+ warn "active_mutator: accepted fingerprint references missing file: #{entry.file} (#{entry.subject})"
244
+ end
141
245
  end
142
246
  end
143
247
  end
@@ -8,11 +8,13 @@ module ActiveMutator
8
8
  OrphanedError = Class.new(Error)
9
9
 
10
10
  def initialize(jobs:, worker: Worker.method(:run), on_result: nil,
11
- orphaned: -> { Process.ppid == 1 })
11
+ calibrators: nil, orphaned: -> { Process.ppid == 1 })
12
12
  @jobs = jobs
13
13
  @worker = worker
14
14
  @on_result = on_result
15
+ @calibrators = calibrators
15
16
  @orphaned = orphaned
17
+ @last_logged_scale = {} # lane => last scale logged for that lane
16
18
  end
17
19
 
18
20
  def run(items)
@@ -72,7 +74,12 @@ module ActiveMutator
72
74
  Process.exit!(0)
73
75
  end
74
76
  writer.close
75
- running[pid] = { reader: reader, item: item, deadline: now + item.timeout }
77
+ calibrator = calibrator_for(item)
78
+ budget = calibrator ? calibrator.budget_for(item) : item.timeout
79
+ log_scale(calibrator, item.lane)
80
+ started = now
81
+ running[pid] = { reader: reader, item: item, started: started,
82
+ budget: budget, deadline: started + budget }
76
83
  end
77
84
 
78
85
  def reap(running, results)
@@ -80,7 +87,11 @@ module ActiveMutator
80
87
  done, _status = Process.waitpid2(pid, Process::WNOHANG)
81
88
  if done
82
89
  running.delete(pid)
83
- results << finish(entry)
90
+ result = finish(entry)
91
+ if result.status == :killed
92
+ calibrator_for(entry[:item])&.record(now - entry[:started], entry[:budget])
93
+ end
94
+ results << result
84
95
  elsif now > entry[:deadline]
85
96
  kill(pid)
86
97
  running.delete(pid)
@@ -94,8 +105,16 @@ module ActiveMutator
94
105
  payload = entry[:reader].read.to_s
95
106
  entry[:reader].close
96
107
  data = payload.empty? ? nil : JSON.parse(payload)
97
- status = data ? data.fetch("status").to_sym : :error
98
- details = data ? data["details"] : "worker exited without reporting"
108
+ # A self-mutation of Worker#emit can produce well-formed JSON without a
109
+ # "status" key (or with a non-Hash root); treat any unusable payload as
110
+ # a worker error instead of crashing the whole run.
111
+ reported = data.is_a?(Hash) && data.key?("status")
112
+ status = reported ? data["status"].to_sym : :error
113
+ details = reported ? data["details"] : "worker exited without reporting"
114
+ rescue JSON::ParserError
115
+ report(Result.new(mutation: entry[:item].mutation, status: :error,
116
+ details: "worker emitted unparseable payload"))
117
+ else
99
118
  report(Result.new(mutation: entry[:item].mutation, status: status, details: details))
100
119
  end
101
120
 
@@ -141,6 +160,23 @@ module ActiveMutator
141
160
  previous.each { |sig, handler| trap(sig, handler || "DEFAULT") }
142
161
  end
143
162
 
163
+ def calibrator_for(item)
164
+ @calibrators && @calibrators[item.lane]
165
+ end
166
+
167
+ # Effective budgets are otherwise invisible (--debug-plan shows static
168
+ # ones by design). One stderr line per scale CHANGE per lane, not per
169
+ # spawn — the two lanes calibrate independently, so the lane is named.
170
+ def log_scale(calibrator, lane)
171
+ return unless calibrator&.warmed?
172
+
173
+ scale = calibrator.scale.round(2)
174
+ return if scale == @last_logged_scale[lane]
175
+
176
+ @last_logged_scale[lane] = scale
177
+ warn "active_mutator: adaptive timeout scale (#{lane}): #{scale}"
178
+ end
179
+
144
180
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
181
  end
146
182
  end
@@ -0,0 +1,21 @@
1
+ module ActiveMutator
2
+ # 1-based line/column (never 0 — the Stryker schema rejects 0) for an
3
+ # exclusive byte range within a source string.
4
+ module SourceLocation
5
+ def self.locate(source, byte_range)
6
+ {
7
+ start: position(source, byte_range.begin),
8
+ end: position(source, byte_range.end)
9
+ }
10
+ end
11
+
12
+ def self.position(source, offset)
13
+ prefix = source.byteslice(0, offset)
14
+ last_newline = prefix.rindex("\n")
15
+ {
16
+ line: prefix.count("\n") + 1,
17
+ column: offset - (last_newline ? last_newline + 1 : 0) + 1
18
+ }
19
+ end
20
+ end
21
+ end
@@ -1,7 +1,13 @@
1
1
  module ActiveMutator
2
2
  # A mutable unit: one method definition.
3
3
  # byte_range/line_range cover the whole `def ... end`.
4
- Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind) do
4
+ # sclass: def lives inside `class << self` — its source slice is `def foo`,
5
+ # so Inserter must target the singleton class, not the constant itself.
6
+ Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
7
+ def initialize(name:, file:, byte_range:, line_range:, constant_scope:, kind:, sclass: false)
8
+ super
9
+ end
10
+
5
11
  def singleton? = kind == :singleton
6
12
  end
7
13
  end
@@ -1,36 +1,73 @@
1
+ require "set"
2
+
1
3
  module ActiveMutator
2
4
  class SubjectFinder < Prism::Visitor
5
+ SKIP_MARKER = /#\s*active_mutator:\s*skip\b/
6
+
3
7
  def self.call(file)
4
8
  result = Prism.parse(File.read(file))
5
9
  return [] unless result.success?
6
10
 
7
- finder = new(file)
11
+ skip_lines = result.comments
12
+ .select { |c| c.slice.match?(SKIP_MARKER) }
13
+ .to_set { |c| c.location.start_line }
14
+ finder = new(file, skip_lines: skip_lines)
8
15
  finder.visit(result.value)
9
16
  finder.subjects
10
17
  end
11
18
 
12
19
  attr_reader :subjects
13
20
 
14
- def initialize(file)
21
+ def initialize(file, skip_lines: Set.new)
15
22
  @file = file
23
+ @skip_lines = skip_lines
16
24
  @stack = []
17
25
  @subjects = []
26
+ @sclass_depth = 0
18
27
  super()
19
28
  end
20
29
 
30
+ # Classes/modules declared inside `class << self` hang their constant on
31
+ # the SINGLETON class, so a lexically-joined scope like "Foo::Bar" is not
32
+ # reachable via Object.const_get — Inserter would crash. Skipped entirely.
21
33
  def visit_class_node(node)
34
+ return if @sclass_depth.positive?
35
+
22
36
  with_scope(node.constant_path.slice) { super }
23
37
  end
24
38
 
25
39
  def visit_module_node(node)
40
+ return if @sclass_depth.positive?
41
+
26
42
  with_scope(node.constant_path.slice) { super }
27
43
  end
28
44
 
29
- # `class << self` bodies are a documented v1 limit: not visited.
30
- def visit_singleton_class_node(node); end
45
+ # `class << self` inside a constant scope: defs there are singleton
46
+ # methods of the enclosing constant. `class << obj` and a top-level
47
+ # `class << self` (no constant to hang the method on) stay skipped.
48
+ def visit_singleton_class_node(node)
49
+ return unless node.expression.is_a?(Prism::SelfNode) && !@stack.empty?
50
+
51
+ @sclass_depth += 1
52
+ begin
53
+ super
54
+ ensure
55
+ @sclass_depth -= 1
56
+ end
57
+ end
58
+
59
+ # Defs inside blocks (`Data.define do ... end`, `class_eval do ... end`)
60
+ # do not live on the enclosing constant scope, so Inserter would redefine
61
+ # them on the wrong constant and every mutant would falsely survive.
62
+ # Same v1 limit as `class << self`: not visited. Note this also hides
63
+ # classes/modules defined inside blocks (accepted v1 limit).
64
+ def visit_block_node(node); end
31
65
 
32
66
  def visit_def_node(node)
33
- singleton = node.receiver.is_a?(Prism::SelfNode)
67
+ return if @skip_lines.include?(node.location.start_line - 1)
68
+
69
+ sclass = @sclass_depth.positive?
70
+ singleton = sclass || node.receiver.is_a?(Prism::SelfNode)
34
71
  scope = @stack.empty? ? nil : @stack.join("::")
35
72
  loc = node.location
36
73
  @subjects << Subject.new(
@@ -39,9 +76,11 @@ module ActiveMutator
39
76
  byte_range: loc.start_offset...loc.end_offset,
40
77
  line_range: loc.start_line..loc.end_line,
41
78
  constant_scope: scope,
42
- kind: singleton ? :singleton : :instance
79
+ kind: singleton ? :singleton : :instance,
80
+ sclass: sclass
43
81
  )
44
- # No `super`: nested defs are out of scope for v1.
82
+ # No `super`: nested defs get no subject of their own -- their bodies
83
+ # are mutated via the OUTER def (Engine#walk descends into them).
45
84
  end
46
85
 
47
86
  private
@@ -0,0 +1,23 @@
1
+ module ActiveMutator
2
+ # Tiny subject-expression grammar for --subject:
3
+ # Foo::Bar#baz exact Foo::Bar all methods of the constant
4
+ # Foo::Bar* namespace Foo::Bar#* instance-only Foo::Bar.* singleton-only
5
+ class SubjectMatcher
6
+ def initialize(expression)
7
+ @regexp = compile(expression)
8
+ end
9
+
10
+ def match?(name) = @regexp.match?(name)
11
+
12
+ private
13
+
14
+ def compile(expr)
15
+ case expr
16
+ when /\A(.+)([#.])\*\z/ then /\A#{Regexp.escape($1)}#{Regexp.escape($2)}[^#.]+\z/
17
+ when /\A(.+)\*\z/ then /\A#{Regexp.escape($1)}/
18
+ when /[#.]/ then /\A#{Regexp.escape(expr)}\z/
19
+ else /\A#{Regexp.escape(expr)}[#.][^#.]+\z/
20
+ end
21
+ end
22
+ end
23
+ end