saboteur 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +44 -6
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +116 -0
- data/lib/saboteur/baseline/run_evidence.rb +2 -1
- data/lib/saboteur/cli/agent_provenance.rb +59 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
- data/lib/saboteur/cli/doctor_command.rb +41 -0
- data/lib/saboteur/cli/error_cause.rb +40 -0
- data/lib/saboteur/cli/execution_options.rb +46 -0
- data/lib/saboteur/cli/exit_status.rb +12 -14
- data/lib/saboteur/cli/explicit_file_options.rb +21 -0
- data/lib/saboteur/cli/explicit_sources.rb +46 -0
- data/lib/saboteur/cli/failure_output.rb +35 -0
- data/lib/saboteur/cli/help_command.rb +23 -0
- data/lib/saboteur/cli/matrix_option.rb +17 -0
- data/lib/saboteur/cli/output_options.rb +27 -0
- data/lib/saboteur/cli/presets.rb +41 -1
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +6 -1
- data/lib/saboteur/cli/run_command.rb +33 -0
- data/lib/saboteur/cli/run_output.rb +38 -0
- data/lib/saboteur/cli/run_reporting.rb +6 -5
- data/lib/saboteur/cli/run_validation.rb +54 -0
- data/lib/saboteur/cli/source_set.rb +0 -18
- data/lib/saboteur/cli.rb +29 -305
- data/lib/saboteur/config.rb +76 -7
- data/lib/saboteur/config_file.rb +12 -8
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +24 -5
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +6 -4
- data/lib/saboteur/doctor.rb +10 -0
- data/lib/saboteur/engine/baseline_guard.rb +118 -15
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +16 -5
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
- data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
- data/lib/saboteur/engine/coverage_partial.rb +48 -0
- data/lib/saboteur/engine/coverage_parts.rb +50 -0
- data/lib/saboteur/engine/coverage_shadow.rb +92 -0
- data/lib/saboteur/engine/coverage_store.rb +296 -21
- data/lib/saboteur/engine/diagnostics.rb +24 -7
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +4 -1
- data/lib/saboteur/engine/load_coverage.rb +72 -8
- data/lib/saboteur/engine/partial_invalidation.rb +115 -0
- data/lib/saboteur/engine/partial_merge.rb +40 -0
- data/lib/saboteur/engine/pipelines.rb +11 -3
- data/lib/saboteur/engine/reference_specs.rb +71 -0
- data/lib/saboteur/engine/result_cache.rb +68 -5
- data/lib/saboteur/engine/resume_manifest.rb +110 -0
- data/lib/saboteur/engine/run_budget.rb +10 -7
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +26 -5
- data/lib/saboteur/engine/scheduler.rb +33 -25
- data/lib/saboteur/engine/selection_announcement.rb +9 -3
- data/lib/saboteur/engine/shadow_comparison.rb +121 -0
- data/lib/saboteur/engine/shadow_run.rb +38 -0
- data/lib/saboteur/engine/subject_selection.rb +144 -64
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +186 -32
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +44 -11
- data/lib/saboteur/environment_hints.rb +55 -0
- data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
- data/lib/saboteur/execution/external_runner.rb +123 -1
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/marker_sweep.rb +2 -1
- data/lib/saboteur/execution/process_table.rb +7 -3
- data/lib/saboteur/execution/reevaluator.rb +7 -0
- data/lib/saboteur/execution/sandbox.rb +39 -20
- data/lib/saboteur/execution/seatbelt.rb +10 -3
- data/lib/saboteur/execution/supervisor.rb +42 -8
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
- data/lib/saboteur/gates.rb +121 -28
- data/lib/saboteur/install.rb +6 -1
- data/lib/saboteur/integration/base.rb +19 -2
- data/lib/saboteur/integration/rspec.rb +145 -18
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +33 -21
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
- data/lib/saboteur/mutation_source/jump_value.rb +59 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
- data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
- data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
- data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
- data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
- data/lib/saboteur/mutation_source/when_removal.rb +51 -0
- data/lib/saboteur/preview.rb +13 -0
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- data/lib/saboteur/reporter/agent_summary.rb +16 -10
- data/lib/saboteur/reporter/review_comments.rb +151 -0
- data/lib/saboteur/reporter/safe_out.rb +32 -0
- data/lib/saboteur/reporter/stryker.rb +8 -6
- data/lib/saboteur/reporter/survivor_classification.rb +41 -3
- data/lib/saboteur/reporter/survivor_hints.rb +5 -0
- data/lib/saboteur/reporter/terminal.rb +45 -5
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +150 -9
- data/lib/saboteur/selection/external_test_map.rb +4 -0
- data/lib/saboteur/selection/git_diff.rb +24 -2
- data/lib/saboteur/selection/layout_only_lines.rb +45 -1
- data/lib/saboteur/selection/recorder.rb +52 -22
- data/lib/saboteur/selection/trace_recorder.rb +83 -0
- data/lib/saboteur/selection/untracked_changes.rb +1 -2
- data/lib/saboteur/subject.rb +16 -0
- data/lib/saboteur/subject_finder.rb +91 -6
- data/lib/saboteur/text.rb +31 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +7 -2
- data/lib/saboteur.rb +9 -0
- metadata +48 -16
data/lib/saboteur/cli.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "doctor"
|
|
|
8
8
|
require_relative "help"
|
|
9
9
|
require_relative "reporter/replay"
|
|
10
10
|
require_relative "baseline/command"
|
|
11
|
+
require_relative "cli/error_cause"
|
|
11
12
|
require_relative "cli/exit_status"
|
|
12
13
|
require_relative "preview"
|
|
13
14
|
require_relative "cli/run_reporting"
|
|
@@ -19,6 +20,20 @@ require_relative "cli/selection_options"
|
|
|
19
20
|
require_relative "cli/presets"
|
|
20
21
|
require_relative "cli/help_options"
|
|
21
22
|
require_relative "cli/history_command"
|
|
23
|
+
require_relative "cli/execution_options"
|
|
24
|
+
require_relative "cli/matrix_option"
|
|
25
|
+
require_relative "cli/output_options"
|
|
26
|
+
require_relative "cli/explicit_file_options"
|
|
27
|
+
require_relative "cli/doctor_command"
|
|
28
|
+
require_relative "cli/agent_provenance"
|
|
29
|
+
require_relative "cli/preview_command"
|
|
30
|
+
require_relative "cli/help_command"
|
|
31
|
+
require_relative "cli/run_validation"
|
|
32
|
+
require_relative "cli/explicit_sources"
|
|
33
|
+
require_relative "cli/run_output"
|
|
34
|
+
require_relative "cli/dirty_tree_notice"
|
|
35
|
+
require_relative "cli/run_command"
|
|
36
|
+
require_relative "cli/failure_output"
|
|
22
37
|
|
|
23
38
|
module Saboteur
|
|
24
39
|
class CLI
|
|
@@ -32,7 +47,21 @@ module Saboteur
|
|
|
32
47
|
include SelectionOptions
|
|
33
48
|
include Presets
|
|
34
49
|
include HistoryCommand
|
|
50
|
+
include ExecutionOptions
|
|
51
|
+
include MatrixOption
|
|
52
|
+
include OutputOptions
|
|
53
|
+
include ExplicitFileOptions
|
|
54
|
+
include DoctorCommand
|
|
55
|
+
include AgentProvenance
|
|
35
56
|
include HelpOptions
|
|
57
|
+
include PreviewCommand
|
|
58
|
+
include HelpCommand
|
|
59
|
+
include RunValidation
|
|
60
|
+
include ExplicitSources
|
|
61
|
+
include RunOutput
|
|
62
|
+
include DirtyTreeNotice
|
|
63
|
+
include RunCommand
|
|
64
|
+
include FailureOutput
|
|
36
65
|
|
|
37
66
|
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--all] [--threshold PERCENT] [--since REF] " \
|
|
38
67
|
"[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
|
|
@@ -96,319 +125,14 @@ module Saboteur
|
|
|
96
125
|
end
|
|
97
126
|
end
|
|
98
127
|
|
|
99
|
-
def doctor_parser
|
|
100
|
-
OptionParser.new do |opts|
|
|
101
|
-
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
|
|
102
|
-
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
103
|
-
opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
|
|
104
|
-
opts.on("--suite", "Also run the unchanged suite twice (slow on a large suite)") { set(:suite, true) }
|
|
105
|
-
opts.on("--test PATH", "Spec file or directory for the suite runs; repeatable. Implies --suite") do |value|
|
|
106
|
-
(options[:tests] ||= []) << value
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
128
|
private
|
|
112
129
|
|
|
113
|
-
def run_command = help_requested? ? show_help("run") : run
|
|
114
|
-
|
|
115
|
-
def doctor_command = help_requested? ? show_help("doctor") : doctor
|
|
116
|
-
|
|
117
130
|
def history_entry(command) = help_requested? ? show_help(command) : history_command(command)
|
|
118
131
|
|
|
119
|
-
def help_requested? = @argv.intersect?(%w[--help -h])
|
|
120
|
-
|
|
121
|
-
def show_help(command_name)
|
|
122
|
-
help = Help.new(option_lines: option_lines)
|
|
123
|
-
text = command_name ? help.command(command_name) : help.overview
|
|
124
|
-
return @out.puts(text) || EXIT_OK if text
|
|
125
|
-
|
|
126
|
-
@err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
|
|
127
|
-
EXIT_USAGE
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def usage_error(message = nil)
|
|
131
|
-
@err.puts(message) if message
|
|
132
|
-
@err.puts(USAGE)
|
|
133
|
-
agent_failure(message || USAGE, EXIT_USAGE)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def agent? = @format == "agent"
|
|
137
|
-
|
|
138
132
|
def agent_requested? = self.class.agent_requested?(@argv)
|
|
139
133
|
|
|
140
|
-
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
141
|
-
def agent_failure(message, exit_code)
|
|
142
|
-
@out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
|
|
143
|
-
exit_code
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def run
|
|
147
|
-
parser.parse!(@argv)
|
|
148
|
-
reject_test_selection_conflicts
|
|
149
|
-
use_explicit_sources
|
|
150
|
-
validate_run_options
|
|
151
|
-
warn_dirty_tree
|
|
152
|
-
|
|
153
|
-
config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
|
|
154
|
-
|
|
155
|
-
return preview if options[:list_operators] || options[:dry_run]
|
|
156
|
-
|
|
157
|
-
refuse_empty_source_set
|
|
158
|
-
refuse_empty_since_range
|
|
159
|
-
execute_run
|
|
160
|
-
rescue ConfigError => e
|
|
161
|
-
@err.puts("saboteur: #{e.message}")
|
|
162
|
-
agent_failure(e.message, EXIT_USAGE)
|
|
163
|
-
rescue Error => e
|
|
164
|
-
@err.puts("saboteur: #{e.message}")
|
|
165
|
-
agent_failure(e.message, EXIT_TOOL)
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# Mutants run your code, and a mutant of code that writes or deletes files
|
|
169
|
-
# can change the tree. Say so up front when uncommitted work is at stake.
|
|
170
|
-
# The scope the warning names is the configured one: positional source
|
|
171
|
-
# operands narrow the run after the warning, so the include/exclude they
|
|
172
|
-
# imply stay out of its config (#309, #503 review).
|
|
173
|
-
def warn_dirty_tree
|
|
174
|
-
warning = @sources ? Config.load(path: options[:config], overrides: overrides.except(:include, :exclude)) : config
|
|
175
|
-
DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Contradictory selection flags are a usage error before any file or
|
|
179
|
-
# repository check: --test wins over inference by precedence, but only
|
|
180
|
-
# one of them may be named. Config-file combinations are not errors:
|
|
181
|
-
# --test wins there, so generated commands stay valid over any config.
|
|
182
|
-
def reject_test_selection_conflicts
|
|
183
|
-
raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
|
|
184
|
-
|
|
185
|
-
reject_test_command_conflicts
|
|
186
|
-
raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
|
|
187
|
-
return unless options[:guess_tests] && options[:tests]&.any?
|
|
188
|
-
|
|
189
|
-
raise UsageError, "--guess-tests cannot be combined with --test"
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# --test-command replaces the whole in-process test machinery: test
|
|
193
|
-
# selection, coverage, parallelism and the sandbox do not apply.
|
|
194
|
-
def reject_test_command_conflicts
|
|
195
|
-
return unless options[:test_command]
|
|
196
|
-
|
|
197
|
-
# options[:tests] is nil or a non-empty list, so truthiness is the check.
|
|
198
|
-
raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
|
|
199
|
-
raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# Usage errors found before the target boots.
|
|
203
|
-
def validate_run_options
|
|
204
|
-
reject_format_conflicts
|
|
205
|
-
validate_shard_options
|
|
206
|
-
Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
|
|
210
|
-
def reject_format_conflicts
|
|
211
|
-
if agent? && (config["threshold"] || gates.any?)
|
|
212
|
-
raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
|
|
213
|
-
"cannot be combined with --agent"
|
|
214
|
-
end
|
|
215
|
-
return unless @format == "json" && !options[:dry_run]
|
|
216
|
-
|
|
217
|
-
raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# One line naming the reports written, paths relative to the root when
|
|
221
|
-
# they sit under it (a blank line before it, as the old JSON line had).
|
|
222
|
-
def announce_reports(reports)
|
|
223
|
-
return if (written = reports.select { |path, _label| path }).empty?
|
|
224
|
-
|
|
225
|
-
root = File.join(File.realpath(config.root), "")
|
|
226
|
-
names = written.map do |path, label|
|
|
227
|
-
shown = File.expand_path(path).delete_prefix(root)
|
|
228
|
-
"#{shown} (#{label})"
|
|
229
|
-
end
|
|
230
|
-
@out.puts "\nReports: #{names.join(', ')}"
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def doctor
|
|
234
|
-
doctor_parser.parse!(@argv)
|
|
235
|
-
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
236
|
-
|
|
237
|
-
options[:tests]&.each { |test| require_existing_test(test) }
|
|
238
|
-
Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
|
|
239
|
-
EXIT_OK
|
|
240
|
-
rescue ConfigError => e
|
|
241
|
-
@err.puts("saboteur: #{e.message}")
|
|
242
|
-
EXIT_USAGE
|
|
243
|
-
rescue Error => e
|
|
244
|
-
@err.puts("saboteur: #{e.message}")
|
|
245
|
-
EXIT_TOOL
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
249
|
-
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
250
|
-
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
251
|
-
# children cannot write into the JSON on stdout.
|
|
252
|
-
def stdout_on_stderr
|
|
253
|
-
saved = $stdout.dup
|
|
254
|
-
$stdout.reopen($stderr)
|
|
255
|
-
yield
|
|
256
|
-
ensure
|
|
257
|
-
$stdout.reopen(saved) if saved
|
|
258
|
-
saved&.close
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def agent_provenance(report, tree, engine)
|
|
262
|
-
Reporter::Agent::Provenance.new(
|
|
263
|
-
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
264
|
-
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
265
|
-
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
|
|
266
|
-
sources: @sources || [],
|
|
267
|
-
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
268
|
-
incremental: config["incremental"], settings: agent_settings, typed_settings: typed_settings
|
|
269
|
-
)
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# The settings keys typed on the command line: a typed empty operator
|
|
273
|
-
# list may clear a config value, so the generated commands keep it.
|
|
274
|
-
def typed_settings = options.keys & Reporter::Agent::Commands::SETTING_FLAGS.keys
|
|
275
|
-
|
|
276
|
-
# A whole-suite run records its scope as --all, so the generated rerun
|
|
277
|
-
# and replay commands do not default back to inference (which can exit
|
|
278
|
-
# 2 in a project with a spec-less source file).
|
|
279
|
-
def agent_settings
|
|
280
|
-
settings = options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
281
|
-
.merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
|
|
282
|
-
# The effective mode: named tests win over a configured whole-suite
|
|
283
|
-
# selection by precedence, so all is recorded only when no tests
|
|
284
|
-
# resolved - otherwise the follow-up commands would carry both --all
|
|
285
|
-
# and --test, which the CLI rejects.
|
|
286
|
-
settings[:all] = true if (config["all"] || config["test_selection"] == "all") && config["tests"].empty?
|
|
287
|
-
# CLI-only switches that change candidate availability or boot, so a
|
|
288
|
-
# printed rerun/replay must keep them (config-file settings like
|
|
289
|
-
# arid/hazard patterns, whole-file, require, rspec_args and rails ride
|
|
290
|
-
# along through --config or the default config path).
|
|
291
|
-
settings[:allow_hazardous] = true if options[:allow_hazardous]
|
|
292
|
-
settings[:allow_focus] = true if options[:allow_focus]
|
|
293
|
-
# An explicit false (--no-max-duration-ok over a config true) is a
|
|
294
|
-
# setting too: dropping it would silently re-enable the config value.
|
|
295
|
-
settings[:max_duration_ok] = options[:max_duration_ok] unless options[:max_duration_ok].nil?
|
|
296
|
-
settings
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def agent_mode
|
|
300
|
-
return "replay" if options[:id]
|
|
301
|
-
return "diff" if config["since"]
|
|
302
|
-
|
|
303
|
-
@sources&.any? ? "files" : "full"
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Positional arguments name the files (or directories) to mutate. They
|
|
307
|
-
# replace the configured include and exclude patterns. Each must exist,
|
|
308
|
-
# so a typo is an error and not a run over zero subjects.
|
|
309
|
-
def use_explicit_sources
|
|
310
|
-
Selection::SourceScope.verify_no_specs!(@argv)
|
|
311
|
-
options[:tests]&.each { |test| require_existing_test(test) }
|
|
312
|
-
# --test may stand alone: the configured include (or the --since
|
|
313
|
-
# diff) names the sources. The files must still exist.
|
|
314
|
-
return if @argv.empty?
|
|
315
|
-
|
|
316
|
-
@sources = @argv.dup
|
|
317
|
-
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
318
|
-
options[:exclude] = []
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def source_pattern(source)
|
|
322
|
-
return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
|
|
323
|
-
raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
|
|
324
|
-
raise UsageError, "source file not found: #{source}" unless File.file?(source)
|
|
325
|
-
|
|
326
|
-
glob_escape(source)
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
# include is a list of glob patterns; a named file must match only itself.
|
|
330
|
-
def glob_escape(path)
|
|
331
|
-
path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
# A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
|
|
335
|
-
def require_existing_test(test)
|
|
336
|
-
path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
|
|
337
|
-
raise UsageError, "test file not found: #{test}" unless File.exist?(path)
|
|
338
|
-
end
|
|
339
|
-
|
|
340
134
|
def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
|
|
341
135
|
|
|
342
|
-
def preview
|
|
343
|
-
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
|
|
344
|
-
raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
|
|
345
|
-
|
|
346
|
-
if options[:list_operators]
|
|
347
|
-
MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
|
|
348
|
-
return EXIT_OK
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
Preview.new(config, out: @out, err: @err, format: @format).call
|
|
352
|
-
EXIT_OK
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
def define_execution_options(opts)
|
|
356
|
-
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
357
|
-
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
358
|
-
add_run_budget_options(opts)
|
|
359
|
-
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
360
|
-
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
361
|
-
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
362
|
-
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
363
|
-
opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
|
|
364
|
-
set(:allow_hazardous, true)
|
|
365
|
-
end
|
|
366
|
-
opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
|
|
367
|
-
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
368
|
-
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
369
|
-
set(:allow_focus, true)
|
|
370
|
-
end
|
|
371
|
-
opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
|
|
372
|
-
"mutated files") { set(:all, true) }
|
|
373
|
-
opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
|
|
374
|
-
"For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
|
|
375
|
-
set(:test_command, v)
|
|
376
|
-
end
|
|
377
|
-
opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
|
|
378
|
-
"--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
|
|
379
|
-
set(:guess_tests, v)
|
|
380
|
-
end
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
def define_matrix_option(opts)
|
|
384
|
-
opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
|
|
385
|
-
set(:matrix, true)
|
|
386
|
-
end
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
def define_output_options(opts)
|
|
390
|
-
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
391
|
-
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
392
|
-
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
393
|
-
opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
|
|
394
|
-
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
395
|
-
opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
|
|
396
|
-
"a crash prints its backtrace") { @verbose = true }
|
|
397
|
-
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
398
|
-
@color = v ? :always : :never
|
|
399
|
-
end
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
def define_explicit_file_options(opts)
|
|
403
|
-
opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
|
|
404
|
-
(options[:tests] ||= []) << v
|
|
405
|
-
end
|
|
406
|
-
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
407
|
-
set(:threshold, v)
|
|
408
|
-
end
|
|
409
|
-
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
410
|
-
end
|
|
411
|
-
|
|
412
136
|
def add_subject(expr) = (options[:subjects] ||= []) << expr
|
|
413
137
|
|
|
414
138
|
def set(key, value) = (options[key] = value)
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Saboteur
|
|
|
18
18
|
LEGACY_IGNORE_FILE = ".saboteur-ignore.yml"
|
|
19
19
|
IGNORE_FILE = "config/saboteur_ignore.rb"
|
|
20
20
|
|
|
21
|
+
# Settings that say where saboteur writes, never what it mutates: left
|
|
22
|
+
# out of the run fingerprint, baseline evidence and score-history scope.
|
|
23
|
+
OUTPUT_KEYS = %w[cache_dir json_report html_report score_history resume].freeze
|
|
24
|
+
|
|
21
25
|
DEFAULTS = {
|
|
22
26
|
"integration" => "rspec",
|
|
23
27
|
"require" => [],
|
|
@@ -69,7 +73,15 @@ module Saboteur
|
|
|
69
73
|
"subjects" => [],
|
|
70
74
|
"since" => nil,
|
|
71
75
|
"cache" => true,
|
|
76
|
+
# Experimental (#46): "shadow" also builds a partial coverage map after a
|
|
77
|
+
# cache miss and compares it with the full map, which the run still uses.
|
|
78
|
+
# "use" uses the partial map when it is safe and builds the full map otherwise.
|
|
79
|
+
"partial_coverage" => "off",
|
|
80
|
+
# coverage (default) reads Coverage around every test; trace gets lines and
|
|
81
|
+
# methods from a TracePoint and reads branches once per spec file (#725).
|
|
82
|
+
"coverage_recorder" => "coverage",
|
|
72
83
|
"incremental" => false,
|
|
84
|
+
"resume" => nil,
|
|
73
85
|
"shard" => nil,
|
|
74
86
|
"allow_focus" => false,
|
|
75
87
|
"fail_fast" => false,
|
|
@@ -79,6 +91,8 @@ module Saboteur
|
|
|
79
91
|
"matrix" => false,
|
|
80
92
|
"seed" => 1,
|
|
81
93
|
"baseline_passes" => 2,
|
|
94
|
+
# Parallel children for the coverage-map build (#741); 1 keeps the single child.
|
|
95
|
+
"coverage_parts" => 1,
|
|
82
96
|
"max_duration" => nil,
|
|
83
97
|
"max_duration_ok" => false,
|
|
84
98
|
"tests" => [],
|
|
@@ -86,11 +100,16 @@ module Saboteur
|
|
|
86
100
|
"fingerprint_extra" => [],
|
|
87
101
|
"threshold" => nil,
|
|
88
102
|
"max_timeouts" => nil,
|
|
89
|
-
"max_errors" => nil
|
|
103
|
+
"max_errors" => nil,
|
|
104
|
+
# --verbose also lands here, so boot-time output filters (the rspec
|
|
105
|
+
# load-error report, #608) can keep their raw text behind the flag.
|
|
106
|
+
"verbose" => false
|
|
90
107
|
}.freeze
|
|
91
108
|
|
|
92
|
-
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes
|
|
109
|
+
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes
|
|
110
|
+
coverage_parts].freeze
|
|
93
111
|
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
112
|
+
STRING_KEYS = %w[test_selection partial_coverage coverage_recorder].freeze
|
|
94
113
|
NILABLE_FLOAT_KEYS = %w[threshold max_duration].freeze
|
|
95
114
|
THRESHOLD_RANGE = (0.0..100.0)
|
|
96
115
|
|
|
@@ -212,10 +231,29 @@ module Saboteur
|
|
|
212
231
|
# Paths of saboteur's own output, relative to the root, that do not count as source changes.
|
|
213
232
|
def output_paths
|
|
214
233
|
prefix = File.join(File.realpath(root), "")
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
234
|
+
OUTPUT_KEYS.filter_map { |key| path(key) }
|
|
235
|
+
.select { |file| file.start_with?(prefix) }
|
|
236
|
+
.map { |file| file.delete_prefix(prefix) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Tool-owned paths, relative to the root, that never count as
|
|
240
|
+
# working-tree changes (#638): the run's output, plus an in-repo
|
|
241
|
+
# BUNDLE_GEMFILE sidecar and its lock - the documented trial setup
|
|
242
|
+
# should not read as a dirty tree. Path-exact, never a glob, so a
|
|
243
|
+
# real untracked Gemfile.lock still counts.
|
|
244
|
+
def ignored_tree_paths(gemfile: ENV.fetch("BUNDLE_GEMFILE", nil))
|
|
245
|
+
prefix = File.join(File.realpath(root), "")
|
|
246
|
+
sidecar = gemfile && File.expand_path(gemfile, prefix)
|
|
247
|
+
begin
|
|
248
|
+
sidecar &&= File.join(File.realpath(File.dirname(sidecar)), File.basename(sidecar))
|
|
249
|
+
rescue SystemCallError
|
|
250
|
+
# The sidecar's directory does not exist; keep the expanded spelling.
|
|
251
|
+
end
|
|
252
|
+
paths = output_paths
|
|
253
|
+
if sidecar&.start_with?(prefix) && sidecar != File.join(prefix, "Gemfile")
|
|
254
|
+
paths.concat([sidecar, DirtyTreeWarning.lockfile_for(sidecar)].map { |file| file.delete_prefix(prefix) })
|
|
255
|
+
end
|
|
256
|
+
paths
|
|
219
257
|
end
|
|
220
258
|
|
|
221
259
|
private
|
|
@@ -236,11 +274,28 @@ module Saboteur
|
|
|
236
274
|
FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
|
|
237
275
|
LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
|
|
238
276
|
NILABLE_FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) unless @values[key].nil? }
|
|
239
|
-
@values[
|
|
277
|
+
STRING_KEYS.each { |key| @values[key] = @values[key].to_s }
|
|
240
278
|
rescue ArgumentError, TypeError => e
|
|
241
279
|
raise ConfigError, "invalid config value: #{e.message}"
|
|
242
280
|
end
|
|
243
281
|
|
|
282
|
+
def validate_partial_coverage!
|
|
283
|
+
return if %w[off shadow use].include?(self["partial_coverage"])
|
|
284
|
+
|
|
285
|
+
raise ConfigError, "partial_coverage must be one of: off, shadow, use"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def validate_coverage_settings!
|
|
289
|
+
validate_partial_coverage!
|
|
290
|
+
validate_coverage_recorder!
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def validate_coverage_recorder!
|
|
294
|
+
return if %w[coverage trace].include?(self["coverage_recorder"])
|
|
295
|
+
|
|
296
|
+
raise ConfigError, "coverage_recorder must be one of: coverage, trace"
|
|
297
|
+
end
|
|
298
|
+
|
|
244
299
|
def validate_mutant_limit!
|
|
245
300
|
raise ConfigError, "max_mutants_per_method must be > 0" unless self["max_mutants_per_method"].positive?
|
|
246
301
|
end
|
|
@@ -249,6 +304,7 @@ module Saboteur
|
|
|
249
304
|
def validate_baseline!
|
|
250
305
|
raise ConfigError, "seed must be >= 0" if self["seed"].negative?
|
|
251
306
|
raise ConfigError, "baseline_passes must be >= 1" if self["baseline_passes"] < 1
|
|
307
|
+
raise ConfigError, "coverage_parts must be >= 1" if self["coverage_parts"] < 1
|
|
252
308
|
raise ConfigError, "max_duration must be > 0" if self["max_duration"] && !self["max_duration"].positive?
|
|
253
309
|
end
|
|
254
310
|
|
|
@@ -267,6 +323,7 @@ module Saboteur
|
|
|
267
323
|
def validate_modes!
|
|
268
324
|
validate_fail_fast!
|
|
269
325
|
validate_matrix!
|
|
326
|
+
validate_resume!
|
|
270
327
|
validate_test_selection!
|
|
271
328
|
validate_gates!
|
|
272
329
|
validate_score_gates!
|
|
@@ -324,6 +381,17 @@ module Saboteur
|
|
|
324
381
|
raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
|
|
325
382
|
end
|
|
326
383
|
|
|
384
|
+
# --resume DIR keeps verdicts in DIR for a later run (#471); it reuses
|
|
385
|
+
# them the way --incremental does, so the same limits apply.
|
|
386
|
+
def validate_resume!
|
|
387
|
+
resume = self["resume"]
|
|
388
|
+
return if resume.nil?
|
|
389
|
+
raise ConfigError, "resume must be a non-blank directory path" unless resume.is_a?(String) && !resume.strip.empty?
|
|
390
|
+
return unless self["matrix"] || !self["test_command"].to_s.empty?
|
|
391
|
+
|
|
392
|
+
raise ConfigError, "resume cannot be combined with matrix or test_command: reused verdicts are not valid there"
|
|
393
|
+
end
|
|
394
|
+
|
|
327
395
|
def validate_threshold!
|
|
328
396
|
threshold = self["threshold"]
|
|
329
397
|
return if threshold.nil? || THRESHOLD_RANGE.cover?(threshold)
|
|
@@ -373,6 +441,7 @@ module Saboteur
|
|
|
373
441
|
raise ConfigError, "flaky_check must be one of: off, survivors, decisive"
|
|
374
442
|
end
|
|
375
443
|
|
|
444
|
+
validate_coverage_settings!
|
|
376
445
|
validate_mutant_limit!
|
|
377
446
|
validate_threshold!
|
|
378
447
|
validate_modes!
|
data/lib/saboteur/config_file.rb
CHANGED
|
@@ -9,29 +9,34 @@ module Saboteur
|
|
|
9
9
|
# boots, so it cannot reference app constants - plain Ruby and ENV only.
|
|
10
10
|
module ConfigFile
|
|
11
11
|
# The assignment target. One setter per Config key; anything else is an
|
|
12
|
-
# unknown setting.
|
|
13
|
-
# errors can point at it.
|
|
12
|
+
# unknown setting.
|
|
14
13
|
class Settings
|
|
15
14
|
def initialize
|
|
16
15
|
@values = {}
|
|
17
|
-
@lines = {}
|
|
18
16
|
end
|
|
19
17
|
|
|
20
|
-
attr_reader :values
|
|
18
|
+
attr_reader :values
|
|
21
19
|
|
|
22
|
-
Config::DEFAULTS.
|
|
20
|
+
(Config::DEFAULTS.keys - ["integration"]).each do |key|
|
|
23
21
|
define_method("#{key}=") do |value|
|
|
24
22
|
@values[key] = value
|
|
25
|
-
@lines[key] = caller_locations(1, 1).first.lineno
|
|
26
23
|
end
|
|
27
24
|
end
|
|
28
25
|
|
|
26
|
+
def integration=(value)
|
|
27
|
+
line = caller_locations(1, 1).fetch(0).lineno
|
|
28
|
+
unless value.is_a?(String)
|
|
29
|
+
raise ConfigError, "#{line}: integration must be a String, got #{value.inspect}; use \"rspec\""
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@values["integration"] = value
|
|
33
|
+
end
|
|
34
|
+
|
|
29
35
|
# Repeatable per-path score gates (#473), the one setting that takes
|
|
30
36
|
# arguments: c.score_gate "app/payments/**/*", min_score: 95. Values
|
|
31
37
|
# are validated later, on the effective Config.
|
|
32
38
|
def score_gate(pattern, min_score:)
|
|
33
39
|
(@values["score_gates"] ||= []) << { "pattern" => pattern, "min_score" => min_score }
|
|
34
|
-
@lines["score_gates"] = caller_locations(1, 1).first.lineno
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
Config::INTEGER_KEYS.each do |key|
|
|
@@ -41,7 +46,6 @@ module Saboteur
|
|
|
41
46
|
raise ConfigError, "#{line}: #{key} must be an Integer, got #{value.inspect}"
|
|
42
47
|
end
|
|
43
48
|
@values[key] = value
|
|
44
|
-
@lines[key] = caller_locations(1, 1).first.lineno
|
|
45
49
|
end
|
|
46
50
|
end
|
|
47
51
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "prism"
|
|
5
5
|
require_relative "subject"
|
|
6
|
+
require_relative "deep_source"
|
|
6
7
|
require_relative "constant_subject"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
@@ -47,6 +48,8 @@ module Saboteur
|
|
|
47
48
|
result = Prism.parse(source)
|
|
48
49
|
return Parsed.new(subjects: [], unsupported: [], errors: result.errors.map(&:message)) if result.failure?
|
|
49
50
|
|
|
51
|
+
DeepSource.check!(result.value, file)
|
|
52
|
+
|
|
50
53
|
ctx = { file: file, source: source, frozen: frozen_literals?(result) }
|
|
51
54
|
out = { subjects: [], unsupported: [], ambiguous: [] }
|
|
52
55
|
walk(result.value, [], out, **ctx)
|
data/lib/saboteur/custom_map.rb
CHANGED
|
@@ -64,15 +64,27 @@ module Saboteur
|
|
|
64
64
|
unless spec.is_a?(String)
|
|
65
65
|
raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; spec paths must be Strings"
|
|
66
66
|
end
|
|
67
|
-
return if key.is_a?(Regexp) && spec.match?(/\\\d/) # backreference filled in at match time
|
|
68
67
|
|
|
68
|
+
numbered = key.is_a?(Regexp) && spec.match?(/\\\d/)
|
|
69
|
+
named = key.is_a?(Regexp) && spec.match?(/%[-0-9]*[<{][^>}]+[>}]|\\k</)
|
|
70
|
+
# Existence is deliberately not checked here: a stale or mistyped
|
|
71
|
+
# entry warns at guess time and the naming convention takes over,
|
|
72
|
+
# instead of the whole run dying at boot on one bad line (#640).
|
|
73
|
+
# The root check comes first: no template, backreference or named,
|
|
74
|
+
# may skip it (#712).
|
|
69
75
|
expanded = File.expand_path(spec, root)
|
|
70
76
|
unless expanded.start_with?("#{root}/")
|
|
71
77
|
raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which is outside the project root"
|
|
72
78
|
end
|
|
73
|
-
|
|
79
|
+
if numbered && named
|
|
80
|
+
raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; " \
|
|
81
|
+
'regex map specs support \1 backreferences only'
|
|
82
|
+
end
|
|
83
|
+
return if numbered # backreference filled in at match time
|
|
84
|
+
return unless named
|
|
74
85
|
|
|
75
|
-
raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}
|
|
86
|
+
raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; " \
|
|
87
|
+
'regex map specs support \1 backreferences only'
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
private_class_method :validate_entry, :validate_spec
|
|
@@ -149,14 +161,14 @@ module Saboteur
|
|
|
149
161
|
return :excluded if matches.all? { |entry, _| entry.specs.empty? }
|
|
150
162
|
|
|
151
163
|
matches.reject { |entry, _| entry.specs.empty? }
|
|
152
|
-
.flat_map { |entry, match| resolve(entry, match) }
|
|
164
|
+
.flat_map { |entry, match| resolve(entry, match, file) }
|
|
153
165
|
end
|
|
154
166
|
|
|
155
167
|
def specificity(key)
|
|
156
168
|
key.count("^*?[]{}\\")
|
|
157
169
|
end
|
|
158
170
|
|
|
159
|
-
def resolve(entry, match)
|
|
171
|
+
def resolve(entry, match, file = nil)
|
|
160
172
|
return :excluded if entry.specs.empty?
|
|
161
173
|
|
|
162
174
|
specs = entry.specs
|
|
@@ -165,6 +177,13 @@ module Saboteur
|
|
|
165
177
|
# that is never a real spec path, so drop it here rather than letting
|
|
166
178
|
# a junk entry reach the run.
|
|
167
179
|
specs = specs.map { |spec| spec.gsub(/\\(\d+)/) { match[::Regexp.last_match(1).to_i].to_s } }.reject(&:empty?)
|
|
180
|
+
# A capture can carry "../" segments the template check never saw:
|
|
181
|
+
# refuse a filled spec that escapes the root (#712).
|
|
182
|
+
escaped = specs.reject { |spec| File.expand_path(spec, @root).start_with?("#{@root}/") }
|
|
183
|
+
if escaped.any?
|
|
184
|
+
raise ConfigError,
|
|
185
|
+
"#{entry.key.inspect} maps #{file} to #{escaped.first.inspect}, which is outside the project root"
|
|
186
|
+
end
|
|
168
187
|
end
|
|
169
188
|
specs
|
|
170
189
|
end
|