mutineer 1.2.0 → 1.4.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.
@@ -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
  #
@@ -13,6 +13,7 @@ require_relative "mutator_registry"
13
13
  require_relative "worker_pool"
14
14
  require_relative "progress"
15
15
  require_relative "mutant_id"
16
+ require_relative "project_path"
16
17
  require_relative "file_swap"
17
18
  require_relative "external_backend"
18
19
  require_relative "daemon_backend"
@@ -31,15 +32,18 @@ module Mutineer
31
32
  class Runner
32
33
  # Full orchestration: resolve operators, discover subjects, build the
33
34
  # coverage map, run every mutation, and aggregate. Returns
34
- # [AggregateResult, source_map]. The CLI then reports + applies the exit code;
35
- # 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.
36
39
  #
37
40
  # The parent process `require`s each source file so its classes exist; forked
38
41
  # children inherit them, so a covering test file's own require_relative of the
39
42
  # source is a no-op and does not clobber the mutated `load` (spec §7).
40
43
  #
41
44
  # @param config [Mutineer::Config] run configuration.
42
- # @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.
43
47
  def self.execute(config)
44
48
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
45
49
 
@@ -109,7 +113,7 @@ module Mutineer
109
113
  abort_if_unclean!(coverage_map)
110
114
 
111
115
  # Collect every (subject, mutation) up front so the pool can fan them out.
112
- jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
116
+ jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
113
117
 
114
118
  jobs = filter_since(jobs, source_map, config) if config.since
115
119
 
@@ -144,40 +148,75 @@ module Mutineer
144
148
  sweep_orphans(dirs)
145
149
  end
146
150
 
147
- [AggregateResult.new(results + ignored_results), source_map]
151
+ [AggregateResult.new(results + ignored_results), source_map, extras]
148
152
  end
149
153
 
150
154
  # Collect every (subject, mutation, id) up front so a backend can run them.
151
155
  # A mutant the user marked known-equivalent (inline disable-line comment or
152
156
  # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
153
157
  # removed from the killed+survived denominator so a strong file reaches 100%.
154
- # The stable id is computed per subject (occurrence needs the full list) and
155
- # carried on every job so the parent can reattach it after the run. Shared by
156
- # 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.
157
162
  #
158
- # @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`).
159
180
  def self.collect_jobs(config, operator_classes)
160
181
  source_map = {}
161
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] = {} }
162
188
  ignore_set = config.ignore.to_set
163
189
  jobs = []
164
190
  ignored_results = []
191
+ legacy_ignore_matches = {}
192
+ id_map = {}
165
193
  Project.discover(config.sources, only: config.only).each do |subject|
166
194
  source = (source_map[subject.file] ||= File.read(subject.file))
167
195
  disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
168
196
  mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
169
- 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)
170
202
  mutations.each_with_index do |mutation, i|
171
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
172
211
  line = source.byteslice(0, mutation.start_offset).count("\n") + 1
173
- if suppressed?(mutation.operator, line, id, disabled, ignore_set)
212
+ if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
174
213
  ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
175
214
  else
176
215
  jobs << [subject, mutation, id]
177
216
  end
178
217
  end
179
218
  end
180
- [jobs, ignored_results, source_map]
219
+ [jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
181
220
  end
182
221
 
183
222
  # External backend orchestration. Runs each mutant's whole-file mutation on
@@ -189,7 +228,8 @@ module Mutineer
189
228
  #
190
229
  # @param config [Mutineer::Config] run configuration (test_command set).
191
230
  # @param operator_classes [Array<Class>] resolved operators.
192
- # @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.
193
233
  def self.execute_external(config, operator_classes)
194
234
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
195
235
  sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
@@ -205,12 +245,12 @@ module Mutineer
205
245
  # source. Heal first, then discover jobs from the clean tree.
206
246
  FileSwap.restore_orphans(dirs)
207
247
 
208
- jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
248
+ jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
209
249
  jobs = filter_since(jobs, source_map, config) if config.since
210
250
 
211
251
  # Nothing to mutate: return before the smoke check, which runs the whole
212
252
  # --test set to calibrate a timeout no mutant would use (#76).
213
- next [AggregateResult.new(ignored_results), source_map] if jobs.empty?
253
+ next [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
214
254
 
215
255
  # Calibrate the per-mutant timeout from the clean run (a real suite far
216
256
  # outlasts the 10s in-process fork budget), and abort if it is not green.
@@ -234,7 +274,7 @@ module Mutineer
234
274
  FileSwap.restore_orphans(dirs)
235
275
  end
236
276
 
237
- [AggregateResult.new(results + ignored_results), source_map]
277
+ [AggregateResult.new(results + ignored_results), source_map, extras]
238
278
  end
239
279
  end
240
280
 
@@ -331,10 +371,14 @@ module Mutineer
331
371
  end
332
372
 
333
373
  # True when this mutant is suppressed: its line bears a disable-line marker
334
- # (bare, or scoped to its operator), OR its stable id is in the config ignore
335
- # list. Checked at job-build time so a suppressed mutant is never forked.
336
- def self.suppressed?(operator, line, id, disabled, ignore_set)
337
- 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) }
338
382
 
339
383
  case (entry = disabled[line])
340
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.2.0"
5
+ VERSION = "1.4.0"
6
6
  end
data/lib/mutineer.rb CHANGED
@@ -31,6 +31,8 @@ require_relative "mutineer/mutators/negation_removal"
31
31
  require_relative "mutineer/mutators/chain_link"
32
32
  require_relative "mutineer/mutators/operand_removal"
33
33
  require_relative "mutineer/mutators/array_literal"
34
+ require_relative "mutineer/mutators/condition_forcing"
35
+ require_relative "mutineer/mutators/operator_assignment"
34
36
  require_relative "mutineer/mutator_registry"
35
37
  require_relative "mutineer/worker_pool"
36
38
  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.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -77,10 +77,12 @@ files:
77
77
  - lib/mutineer/mutators/chain_link.rb
78
78
  - lib/mutineer/mutators/collection_method.rb
79
79
  - lib/mutineer/mutators/comparison.rb
80
+ - lib/mutineer/mutators/condition_forcing.rb
80
81
  - lib/mutineer/mutators/condition_negation.rb
81
82
  - lib/mutineer/mutators/literal_mutation.rb
82
83
  - lib/mutineer/mutators/negation_removal.rb
83
84
  - lib/mutineer/mutators/operand_removal.rb
85
+ - lib/mutineer/mutators/operator_assignment.rb
84
86
  - lib/mutineer/mutators/range_literal.rb
85
87
  - lib/mutineer/mutators/regex_literal.rb
86
88
  - lib/mutineer/mutators/return_nil.rb
@@ -91,6 +93,7 @@ files:
91
93
  - lib/mutineer/parser.rb
92
94
  - lib/mutineer/progress.rb
93
95
  - lib/mutineer/project.rb
96
+ - lib/mutineer/project_path.rb
94
97
  - lib/mutineer/rails_worker_db.rb
95
98
  - lib/mutineer/reporter.rb
96
99
  - lib/mutineer/result.rb