Class: Mutineer::CLI

Inherits:
Object
  • Object
show all
Defined in:
lib/mutineer/cli.rb

Overview

Command-line entry point. start is the single public method called by bin/mutineer; it parses argv, acts, and exits with a pinned code.

Exit codes (taxonomy consistent across M1–M5):

0  success / requested output (--version, --help, score >= threshold)
1  survivors below threshold, or a runtime error
2  usage / flag error (unknown subcommand, invalid flag, unknown operator,
 out-of-range threshold)

Constant Summary collapse

<<~USAGE
  Usage: mutineer [options] <command> [args]

  Commands:
    run [options] <source...> --test <test...>   Mutate, run, and report
    run --dry-run [options] <source...>          Print candidate mutations only

  Run options:
    --test FILE          Test file covering the sources (repeatable)
    --operators LIST     Comma-separated operator names (default: Tier 1 set)
    --threshold FLOAT    Fail (exit 1) when score < FLOAT (default: 0 = off)
    --only NAME          Restrict to one fully-qualified subject
    --since REF          Only mutate lines changed since git REF (e.g. origin/main)
    --jobs N             Parallel worker count (default: processor count)
    --strategy NAME      reload (whole-file) or redefine (surgical); default: reload
    --framework NAME     minitest or rspec (default: auto-detect from --test names)
    --boot FILE          Require FILE once in the parent to boot the app env, then
                         fork per mutant (Rails apps; requires --test)
    --rails              Sugar for --boot config/environment --strategy redefine
    --format human|json  Report format (default: human)
    --output FILE        Write the report to FILE instead of stdout
    --dry-run            List mutations without executing

  Options:
    --list-operators  List available operators (default vs optional) and exit
    --version         Print version and exit
    --help            Print this help and exit
USAGE
PRECEDENCE_FLAGS =

Field symbols whose config-file value is suppressed when the flag is typed.

%i[operators jobs threshold only].freeze
STRATEGY_ALIASES =

Deprecated internal strategy names, mapped to their canonical equivalents.

{ "7a" => "reload", "7b" => "redefine" }.freeze

Class Method Summary collapse

Class Method Details

.dry_run(config) ⇒ Object



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'lib/mutineer/cli.rb', line 281

def self.dry_run(config)
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
  sources = {}
  per_operator = Hash.new(0)
  skipped = 0

  # --since narrows the preview to changed lines too, so `--dry-run --since`
  # shows exactly what a real `--since` run would mutate.
  changed = if config.since
              ChangedLines.for(ref: config.since, files: config.sources,
                               project_root: config.project_root)
            end

  Project.discover(config.sources, only: config.only).each do |subject|
    source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
    operator_classes.each do |klass|
      klass.new.mutations_for(subject, source).each do |mutation|
        unless mutation.valid?(source)
          skipped += 1
          next
        end
        if changed
          line = source.byteslice(0, mutation.start_offset).count("\n") + 1
          next unless changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
        end
        per_operator[mutation.operator] += 1
        original = source.byteslice(mutation.start_offset...mutation.end_offset)
        line = source.byteslice(0, mutation.start_offset).count("\n") + 1
        puts "[#{mutation.operator}] #{subject.qualified_name}  " \
             "#{subject.file}:#{line}  `#{original}` -> `#{mutation.replacement}`"
      end
    end
  end

  total = per_operator.values.sum
  breakdown = per_operator.map { |op, n| "#{op}: #{n}" }.join(", ")
  summary = breakdown.empty? ? "" : "#{breakdown}"
  puts "#{summary}#{total} mutations (dry run, not executed); #{skipped} skipped (invalid)"
  exit 0
end

.execute(config) ⇒ Object



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/mutineer/cli.rb', line 250

def self.execute(config)
  if config.tests.empty?
    warn "mutineer: run requires at least one --test file (or use --dry-run)"
    exit 2
  end

  aggregate, source_map = Runner.execute(config)
  reporter = Reporter.new(aggregate, source_map)
  reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
                  format: config.format, output: config.output)

  # #14: nudge toward the opt-in tier-2 operators (human report only — never
  # pollute JSON output).
  if config.format != "json" && (hint = tier2_hint(config.operators))
    puts hint
  end

  exit reporter.exit_code(threshold: config.threshold)
end

.list_operatorsObject



129
130
131
132
133
134
135
# File 'lib/mutineer/cli.rb', line 129

def self.list_operators
  MutatorRegistry::ALL.each_key do |name|
    state = MutatorRegistry.default?(name) ? "default" : "disabled"
    puts format("%-20s tier %d  %-9s %s",
                name, MutatorRegistry.tier(name), state, MutatorRegistry::DESCRIPTIONS[name])
  end
end

.preflight_output!(path) ⇒ Object



241
242
243
244
245
246
247
248
# File 'lib/mutineer/cli.rb', line 241

def self.preflight_output!(path)
  dir = File.dirname(File.expand_path(path))
  return if File.directory?(dir) && File.writable?(dir)

  reason = File.directory?(dir) ? "directory is not writable" : "no such directory"
  warn "mutineer: cannot write to #{path}: #{reason}"
  exit 2
end

.run(config) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/mutineer/cli.rb', line 137

def self.run(config)
  if config.sources.empty?
    warn "mutineer: run requires at least one source file"
    exit 2
  end
  validate!(config)

  config.dry_run ? dry_run(config) : execute(config)
rescue ArgumentError => e
  # Unknown --operators value surfaces here; no backtrace reaches the user.
  warn "mutineer: #{e.message}"
  exit 2
rescue SystemCallError => e
  # R5: a missing/unreadable path reaches here as Errno::ENOENT etc. — a plain
  # message and usage exit, never a raw backtrace.
  warn "mutineer: #{e.message}"
  exit 2
rescue SyntaxError => e
  # A syntactically invalid source file surfaces when `require`d; report it
  # cleanly rather than dumping a backtrace.
  warn "mutineer: cannot load source: #{e.message}"
  exit 1
rescue Mutineer::ParseError => e
  warn "mutineer: error reading: #{e.message}"
  exit 1
end

.start(argv) ⇒ Object



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/mutineer/cli.rb', line 60

def self.start(argv)
  opts = {}            # symbol => value, the CLI-provided Config fields
  explicit = Set.new   # precedence keys the user typed (KTD3)
  show_operators = false

  parser = OptionParser.new do |o|
    o.banner = BANNER
    o.on("--version") do
      puts Mutineer::VERSION
      exit 0
    end
    o.on("--help") do
      puts BANNER
      exit 0
    end
    o.on("--list-operators") { show_operators = true }
    o.on("--dry-run") { opts[:dry_run] = true }
    o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
    o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
    o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
    o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
    o.on("--threshold FLOAT") { |v| opts[:threshold] = v.to_f; explicit << :threshold }
    o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
    o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
    o.on("--framework NAME") { |v| opts[:framework] = v; explicit << :framework }
    o.on("--boot FILE") { |v| opts[:boot] = v; explicit << :boot }
    o.on("--rails") { opts[:rails] = true }
    o.on("--format FORMAT") { |v| opts[:format] = v }
    o.on("--output FILE") { |v| opts[:output] = v }
  end

  begin
    parser.parse!(argv)
  rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
    warn "mutineer: #{e.message}"
    exit 2
  end

  if show_operators
    list_operators
    exit 0
  end

  if argv.empty?
    puts BANNER
    exit 0
  end

  begin
    file_path = Config.find_file
    file_hash = file_path ? Config.from_file(file_path) : {}
    config = Config.resolve(opts, file_hash, explicit)
  rescue Mutineer::ConfigError => e
    # R8: the lib layer raises instead of killing the host; the CLI maps a
    # config (usage) error to exit 2.
    warn "mutineer: #{e.message}"
    exit 2
  end

  case argv.first
  when "run"
    config.sources = argv[1..]
    run(config)
  else
    warn "mutineer: unknown command '#{argv.first}'"
    exit 2
  end
end

.tier2_hint(active) ⇒ Object

The tier-2 operators not in the active set, as a one-line hint (or nil when they're all already enabled). active nil means the default (Tier-1) set.



272
273
274
275
276
277
278
279
# File 'lib/mutineer/cli.rb', line 272

def self.tier2_hint(active)
  active ||= MutatorRegistry::DEFAULT_NAMES
  unused = MutatorRegistry::TIER2_NAMES - active
  return if unused.empty?

  "#{unused.size} tier-2 operators available (#{unused.join(', ')}) — " \
    "enable with --operators <list>."
end

.validate!(config) ⇒ Object

Flag validation: every flag/usage failure exits 2 (C7), consistent with the taxonomy above — CI can tell "mistyped flag" from "tests too weak."



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/mutineer/cli.rb', line 166

def self.validate!(config)
  unless (0.0..100.0).cover?(config.threshold)
    warn "mutineer: --threshold must be between 0 and 100"
    exit 2
  end

  jobs = Integer(config.jobs.to_s, exception: false)
  if jobs.nil? || jobs < 1
    warn "mutineer: --jobs requires a positive integer (got: #{config.jobs})"
    exit 2
  end
  config.jobs = jobs

  unless %w[human json].include?(config.format)
    warn %(mutineer: unknown format "#{config.format}". Expected: human, json)
    exit 2
  end

  # Canonical strategies are reload|redefine; 7a/7b are accepted as deprecated
  # aliases. Normalize to canonical so the rest of the pipeline sees one name.
  config.strategy = STRATEGY_ALIASES.fetch(config.strategy, config.strategy)
  unless %w[reload redefine].include?(config.strategy)
    warn %(mutineer: unknown strategy "#{config.strategy}". Expected: reload, redefine)
    exit 2
  end

  unless %w[minitest rspec].include?(config.framework)
    warn %(mutineer: unknown framework "#{config.framework}". Expected: minitest, rspec)
    exit 2
  end

  # Boot mode does no coverage selection — every mutant runs the given tests —
  # so at least one --test file is mandatory (there is nothing to select from).
  if config.boot && config.tests.empty?
    warn "mutineer: --boot/--rails requires at least one --test file"
    exit 2
  end

  validate_since!(config) if config.since
  preflight_output!(config.output) if config.output
  validate_paths!(config)
end

.validate_paths!(config) ⇒ Object

R5: validate path existence up front so a typo is a clean usage error (exit 2), not an Errno::ENOENT backtrace from deep in the run. Flag checks run first so a bad flag still reports the flag, not the missing file.



232
233
234
235
236
237
238
239
# File 'lib/mutineer/cli.rb', line 232

def self.validate_paths!(config)
  missing = (config.sources + config.tests)
            .reject { |p| File.exist?(File.expand_path(p, config.project_root)) }
  return if missing.empty?

  warn "mutineer: no such file: #{missing.join(', ')}"
  exit 2
end

.validate_since!(config) ⇒ Object

--since needs a real git repo and a resolvable ref; either failure is a usage error (exit 2) so CI sees "bad invocation," not "tests too weak."



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/mutineer/cli.rb', line 211

def self.validate_since!(config)
  _out, _err, status = Open3.capture3(
    "git", "-C", config.project_root, "rev-parse", "--verify", "--quiet",
    "#{config.since}^{commit}"
  )
  return if status.success?

  inside, = Open3.capture3(
    "git", "-C", config.project_root, "rev-parse", "--is-inside-work-tree"
  )
  msg = inside.strip == "true" ? "unknown git ref: #{config.since}" : "--since requires a git repository"
  warn "mutineer: #{msg}"
  exit 2
rescue Errno::ENOENT
  warn "mutineer: --since requires git on PATH"
  exit 2
end