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.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "parser"
4
5
  require_relative "project"
5
6
  require_relative "result"
@@ -12,6 +13,7 @@ require_relative "mutator_registry"
12
13
  require_relative "worker_pool"
13
14
  require_relative "progress"
14
15
  require_relative "mutant_id"
16
+ require_relative "project_path"
15
17
  require_relative "file_swap"
16
18
  require_relative "external_backend"
17
19
  require_relative "daemon_backend"
@@ -30,15 +32,18 @@ module Mutineer
30
32
  class Runner
31
33
  # Full orchestration: resolve operators, discover subjects, build the
32
34
  # coverage map, run every mutation, and aggregate. Returns
33
- # [AggregateResult, source_map]. The CLI then reports + applies the exit code;
34
- # the integration test asserts directly on the AggregateResult.
35
+ # [AggregateResult, source_map, extras], where extras is the hash
36
+ # {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
37
+ # The CLI then reports + applies the exit code; the integration test asserts
38
+ # directly on the AggregateResult.
35
39
  #
36
40
  # The parent process `require`s each source file so its classes exist; forked
37
41
  # children inherit them, so a covering test file's own require_relative of the
38
42
  # source is a no-op and does not clobber the mutated `load` (spec §7).
39
43
  #
40
44
  # @param config [Mutineer::Config] run configuration.
41
- # @return [Array(Mutineer::AggregateResult, Hash<String, String>)] aggregate and source map.
45
+ # @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
46
+ # source map, and run extras.
42
47
  def self.execute(config)
43
48
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
44
49
 
@@ -93,16 +98,22 @@ module Mutineer
93
98
  verbose: config.verbose
94
99
  ).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
95
100
  else
101
+ # As in boot mode, and with lib first as `rake test` does.
102
+ test_roots = test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
103
+ libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
104
+ $LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
105
+ # Relative, so the cache digest does not depend on the checkout path.
106
+ rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
96
107
  coverage_map = CoverageMap.new(
97
108
  source_paths: config.sources, test_paths: config.tests,
98
109
  cache_dir: config.cache_dir, project_root: config.project_root,
99
- load_paths: config.load_paths, framework: config.framework
110
+ load_paths: config.load_paths + rel_roots, framework: config.framework
100
111
  ).build_or_load
101
112
  end
102
113
  abort_if_unclean!(coverage_map)
103
114
 
104
115
  # Collect every (subject, mutation) up front so the pool can fan them out.
105
- jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
116
+ jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
106
117
 
107
118
  jobs = filter_since(jobs, source_map, config) if config.since
108
119
 
@@ -137,40 +148,75 @@ module Mutineer
137
148
  sweep_orphans(dirs)
138
149
  end
139
150
 
140
- [AggregateResult.new(results + ignored_results), source_map]
151
+ [AggregateResult.new(results + ignored_results), source_map, extras]
141
152
  end
142
153
 
143
154
  # Collect every (subject, mutation, id) up front so a backend can run them.
144
155
  # A mutant the user marked known-equivalent (inline disable-line comment or
145
156
  # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
146
157
  # removed from the killed+survived denominator so a strong file reaches 100%.
147
- # The stable id is computed per subject (occurrence needs the full list) and
148
- # carried on every job so the parent can reattach it after the run. Shared by
149
- # the in-process, external, and daemon backends so job selection can never drift.
158
+ # The id is computed per subject (occurrence needs the full list), keyed on
159
+ # the file path relative to config.project_root, and carried on every job so
160
+ # the parent can reattach it after the run. Shared by the in-process,
161
+ # external, and daemon backends so job selection can never drift.
150
162
  #
151
- # @return [Array(Array, Array<Result>, Hash<String,String>)] jobs, ignored, source_map.
163
+ # Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
164
+ # entry stored before ids carried the path still suppresses it. Prints nothing:
165
+ # the extras hash returns, as data, `legacy_ignore_matches` (each old-format
166
+ # ignore entry that matched a mutant through its old-format id => one
167
+ # `{id:, file:, subject:}` hash per matched mutant, in collection order: its
168
+ # new id, its project-relative file and its subject's qualified name;
169
+ # recorded even when a new id is also listed, since the old entry still
170
+ # over-matches other files) and `id_map` (every new id => its old-format id).
171
+ #
172
+ # Subjects sharing a qualified name in one file (two owner-less `def index`
173
+ # in two DSL blocks) get a per-file ordinal in discovery order, so their ids
174
+ # differ; the first one's ordinal is 0 and leaves its id unchanged.
175
+ #
176
+ # @param config [Mutineer::Config] run configuration.
177
+ # @param operator_classes [Array<Class>] resolved operators.
178
+ # @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
179
+ # jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
152
180
  def self.collect_jobs(config, operator_classes)
153
181
  source_map = {}
154
182
  disabled_map = {}
183
+ id_paths = {}
184
+ # [file, qualified_name] => { declaration offset => ordinal }: keyed by the
185
+ # declaration, so the same file discovered twice (two path spellings) reuses
186
+ # its ordinal instead of minting a second id for the same mutant.
187
+ name_decls = Hash.new { |h, k| h[k] = {} }
155
188
  ignore_set = config.ignore.to_set
156
189
  jobs = []
157
190
  ignored_results = []
191
+ legacy_ignore_matches = {}
192
+ id_map = {}
158
193
  Project.discover(config.sources, only: config.only).each do |subject|
159
194
  source = (source_map[subject.file] ||= File.read(subject.file))
160
- disabled = (disabled_map[subject.file] ||= suppress_map(source))
195
+ disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
161
196
  mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
162
- ids = MutantId.for_subject(subject, source, mutations)
197
+ id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
198
+ decls = name_decls[[id_path, subject.qualified_name]]
199
+ ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
200
+ ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
201
+ legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
163
202
  mutations.each_with_index do |mutation, i|
164
203
  id = ids[i]
204
+ legacy = legacy_ids[i]
205
+ id_map[id] = legacy
206
+ # An old entry still over-matches other files even when the new id is
207
+ # listed too, so every old-entry match is reported for migration.
208
+ if ignore_set.include?(legacy)
209
+ (legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
210
+ end
165
211
  line = source.byteslice(0, mutation.start_offset).count("\n") + 1
166
- if suppressed?(mutation.operator, line, id, disabled, ignore_set)
212
+ if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
167
213
  ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
168
214
  else
169
215
  jobs << [subject, mutation, id]
170
216
  end
171
217
  end
172
218
  end
173
- [jobs, ignored_results, source_map]
219
+ [jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
174
220
  end
175
221
 
176
222
  # External backend orchestration. Runs each mutant's whole-file mutation on
@@ -182,7 +228,8 @@ module Mutineer
182
228
  #
183
229
  # @param config [Mutineer::Config] run configuration (test_command set).
184
230
  # @param operator_classes [Array<Class>] resolved operators.
185
- # @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
231
+ # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
232
+ # source map, and the {.collect_jobs} extras.
186
233
  def self.execute_external(config, operator_classes)
187
234
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
188
235
  sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
@@ -198,12 +245,12 @@ module Mutineer
198
245
  # source. Heal first, then discover jobs from the clean tree.
199
246
  FileSwap.restore_orphans(dirs)
200
247
 
201
- jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
248
+ jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
202
249
  jobs = filter_since(jobs, source_map, config) if config.since
203
250
 
204
251
  # Nothing to mutate: return before the smoke check, which runs the whole
205
252
  # --test set to calibrate a timeout no mutant would use (#76).
206
- next [AggregateResult.new(ignored_results), source_map] if jobs.empty?
253
+ next [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
207
254
 
208
255
  # Calibrate the per-mutant timeout from the clean run (a real suite far
209
256
  # outlasts the 10s in-process fork budget), and abort if it is not green.
@@ -227,7 +274,7 @@ module Mutineer
227
274
  FileSwap.restore_orphans(dirs)
228
275
  end
229
276
 
230
- [AggregateResult.new(results + ignored_results), source_map]
277
+ [AggregateResult.new(results + ignored_results), source_map, extras]
231
278
  end
232
279
  end
233
280
 
@@ -253,8 +300,13 @@ module Mutineer
253
300
  #
254
301
  # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
255
302
  # @return [void]
256
- # @raise [Mutineer::SmokeCheckError] when any captured test failed clean.
303
+ # @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
304
+ # or when no test recorded coverage and a capture failed.
257
305
  def self.abort_if_unclean!(coverage_map)
306
+ if coverage_map.map.empty? && coverage_map.failed_test_files.any?
307
+ raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
308
+ end
309
+
258
310
  files = coverage_map.failed_clean_tests
259
311
  return if files.empty?
260
312
 
@@ -299,22 +351,34 @@ module Mutineer
299
351
  # sits on the same physical line as the code it silences). A bare marker
300
352
  # disables every operator on that line; `disable-line a, b` only the listed
301
353
  # operators. Block-form disable/enable ranges are intentionally not supported.
302
- def self.suppress_map(source)
354
+ def self.suppress_map(source, file)
303
355
  map = {}
304
356
  source.each_line.with_index(1) do |text, line|
305
357
  next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
306
358
 
307
- ops = m[1]
308
- map[line] = ops ? ops.split(",").map { |o| o.strip.to_sym }.reject(&:empty?).to_set : :all
359
+ ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
360
+ # Only spaces or commas after the marker (e.g. `disable-line -- why`)
361
+ # is a bare marker, not an empty list that silences nothing.
362
+ ops = nil if ops&.empty?
363
+ unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
364
+ unknown.each do |o|
365
+ warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
366
+ "(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
367
+ end
368
+ map[line] = ops ? ops.map(&:to_sym).to_set : :all
309
369
  end
310
370
  map
311
371
  end
312
372
 
313
373
  # True when this mutant is suppressed: its line bears a disable-line marker
314
- # (bare, or scoped to its operator), OR its stable id is in the config ignore
315
- # list. Checked at job-build time so a suppressed mutant is never forked.
316
- def self.suppressed?(operator, line, id, disabled, ignore_set)
317
- return true if ignore_set.include?(id)
374
+ # (bare, or scoped to its operator), OR its new or old-format id is in the
375
+ # config ignore list. Checked at job-build time so a suppressed mutant is
376
+ # never forked.
377
+ #
378
+ # @param ids [Array<String>, String] the mutant's new id and its old-format
379
+ # id, or a single id (the pre-#126 call shape).
380
+ def self.suppressed?(operator, line, ids, disabled, ignore_set)
381
+ return true if Array(ids).any? { |id| ignore_set.include?(id) }
318
382
 
319
383
  case (entry = disabled[line])
320
384
  when :all then true
@@ -4,8 +4,11 @@ module Mutineer
4
4
  # One discoverable method and its AST node.
5
5
  #
6
6
  # Location, namespace context, and the live Prism::DefNode are kept together
7
- # because mutators walk the def node directly.
8
- Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, keyword_init: true) do
7
+ # because mutators walk the def node directly. `namespace` names the owner
8
+ # (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
9
+ # chain as written (`["Outer", "::X"]`), which the redefine strategy needs to
10
+ # rebuild the same Module.nesting as a whole-file reload (#145).
11
+ Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, keyword_init: true) do
9
12
  # Returns the fully-qualified subject name.
10
13
  #
11
14
  # @return [String] namespaced method name like `Billing::Invoice#total`.
@@ -13,6 +16,14 @@ module Mutineer
13
16
  namespace.join("::") + (singleton ? "." : "#") + name.to_s
14
17
  end
15
18
 
19
+ # Class/module chain as written in the source, for textual wrappers. Falls
20
+ # back to `namespace` for subjects built without one.
21
+ #
22
+ # @return [Array<String>] names; a root-anchored element keeps its `::`.
23
+ def lexical_namespace
24
+ lexical || namespace
25
+ end
26
+
16
27
  # Returns the body location for the subject, if any.
17
28
  #
18
29
  # @return [Prism::Location, nil] body location or nil for empty methods.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.1.0"
5
+ VERSION = "1.3.0"
6
6
  end
data/lib/mutineer.rb CHANGED
@@ -29,6 +29,8 @@ require_relative "mutineer/mutators/safe_navigation"
29
29
  require_relative "mutineer/mutators/range_literal"
30
30
  require_relative "mutineer/mutators/negation_removal"
31
31
  require_relative "mutineer/mutators/chain_link"
32
+ require_relative "mutineer/mutators/operand_removal"
33
+ require_relative "mutineer/mutators/array_literal"
32
34
  require_relative "mutineer/mutator_registry"
33
35
  require_relative "mutineer/worker_pool"
34
36
  require_relative "mutineer/progress"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutineer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -70,6 +70,7 @@ files:
70
70
  - lib/mutineer/mutation.rb
71
71
  - lib/mutineer/mutator_registry.rb
72
72
  - lib/mutineer/mutators/arithmetic.rb
73
+ - lib/mutineer/mutators/array_literal.rb
73
74
  - lib/mutineer/mutators/base.rb
74
75
  - lib/mutineer/mutators/boolean_connector.rb
75
76
  - lib/mutineer/mutators/boolean_literal.rb
@@ -79,6 +80,7 @@ files:
79
80
  - lib/mutineer/mutators/condition_negation.rb
80
81
  - lib/mutineer/mutators/literal_mutation.rb
81
82
  - lib/mutineer/mutators/negation_removal.rb
83
+ - lib/mutineer/mutators/operand_removal.rb
82
84
  - lib/mutineer/mutators/range_literal.rb
83
85
  - lib/mutineer/mutators/regex_literal.rb
84
86
  - lib/mutineer/mutators/return_nil.rb
@@ -89,6 +91,7 @@ files:
89
91
  - lib/mutineer/parser.rb
90
92
  - lib/mutineer/progress.rb
91
93
  - lib/mutineer/project.rb
94
+ - lib/mutineer/project_path.rb
92
95
  - lib/mutineer/rails_worker_db.rb
93
96
  - lib/mutineer/reporter.rb
94
97
  - lib/mutineer/result.rb