evilution 0.26.0 → 0.27.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/.beads/interactions.jsonl +10 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +22 -0
- data/README.md +57 -3
- data/lib/evilution/cache.rb +2 -0
- data/lib/evilution/child_output.rb +24 -0
- data/lib/evilution/cli/commands/run.rb +9 -0
- data/lib/evilution/cli/commands/version.rb +2 -0
- data/lib/evilution/cli/parser/options_builder.rb +16 -2
- data/lib/evilution/config/builders/spec_resolver.rb +15 -0
- data/lib/evilution/config/builders/spec_selector.rb +16 -0
- data/lib/evilution/config/builders.rb +4 -0
- data/lib/evilution/config/env_loader.rb +12 -0
- data/lib/evilution/config/file_loader.rb +22 -0
- data/lib/evilution/config/sources.rb +14 -0
- data/lib/evilution/config/validators/base.rb +37 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +37 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +22 -0
- data/lib/evilution/config/validators/fail_fast.rb +11 -0
- data/lib/evilution/config/validators/hooks.rb +12 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +16 -0
- data/lib/evilution/config/validators/integration.rb +11 -0
- data/lib/evilution/config/validators/isolation.rb +19 -0
- data/lib/evilution/config/validators/jobs.rb +9 -0
- data/lib/evilution/config/validators/preload.rb +13 -0
- data/lib/evilution/config/validators/spec_mappings.rb +56 -0
- data/lib/evilution/config/validators/spec_pattern.rb +12 -0
- data/lib/evilution/config/validators.rb +4 -0
- data/lib/evilution/config.rb +78 -268
- data/lib/evilution/feedback/detector.rb +15 -0
- data/lib/evilution/feedback/messages.rb +42 -0
- data/lib/evilution/feedback.rb +5 -0
- data/lib/evilution/integration/rspec/baseline_runner.rb +16 -0
- data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +17 -0
- data/lib/evilution/integration/rspec/example_filter_applier.rb +21 -0
- data/lib/evilution/integration/rspec/framework_loader.rb +28 -0
- data/lib/evilution/integration/rspec/result_builder.rb +40 -0
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +28 -0
- data/lib/evilution/integration/rspec/state_guard/internals.rb +19 -0
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +35 -0
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +32 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +20 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +20 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +20 -0
- data/lib/evilution/integration/rspec/state_guard.rb +40 -0
- data/lib/evilution/integration/rspec/test_file_resolver.rb +30 -0
- data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +18 -0
- data/lib/evilution/integration/rspec.rb +61 -232
- data/lib/evilution/isolation/fork.rb +7 -2
- data/lib/evilution/mcp/info_tool/actions/base.rb +22 -0
- data/lib/evilution/mcp/info_tool/actions/environment.rb +42 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +16 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +10 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +47 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +60 -0
- data/lib/evilution/mcp/info_tool/actions.rb +16 -0
- data/lib/evilution/mcp/info_tool/config_factory.rb +24 -0
- data/lib/evilution/mcp/info_tool/error_mapper.rb +15 -0
- data/lib/evilution/mcp/info_tool/request_parser.rb +34 -0
- data/lib/evilution/mcp/info_tool/response_formatter.rb +24 -0
- data/lib/evilution/mcp/info_tool/status_glossary.rb +75 -0
- data/lib/evilution/mcp/info_tool.rb +43 -261
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +8 -1
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +13 -1
- data/lib/evilution/mcp/mutate_tool.rb +5 -2
- data/lib/evilution/mutator/operator/block_removal.rb +1 -1
- data/lib/evilution/mutator/operator/method_body_replacement.rb +18 -2
- data/lib/evilution/parallel/work_queue/channel/frame.rb +21 -0
- data/lib/evilution/parallel/work_queue/channel.rb +23 -0
- data/lib/evilution/parallel/work_queue/collection_state.rb +14 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +133 -0
- data/lib/evilution/parallel/work_queue/validators/optional_positive_int.rb +11 -0
- data/lib/evilution/parallel/work_queue/validators/optional_positive_number.rb +11 -0
- data/lib/evilution/parallel/work_queue/validators/positive_int.rb +11 -0
- data/lib/evilution/parallel/work_queue/validators.rb +6 -0
- data/lib/evilution/parallel/work_queue/worker/loop.rb +45 -0
- data/lib/evilution/parallel/work_queue/worker.rb +114 -0
- data/lib/evilution/parallel/work_queue/worker_stat.rb +17 -0
- data/lib/evilution/parallel/work_queue.rb +42 -327
- data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +18 -0
- data/lib/evilution/reporter/cli/item_formatters/disabled.rb +9 -0
- data/lib/evilution/reporter/cli/item_formatters/error.rb +14 -0
- data/lib/evilution/reporter/cli/item_formatters/result_location.rb +10 -0
- data/lib/evilution/reporter/cli/item_formatters.rb +6 -0
- data/lib/evilution/reporter/cli/line_formatters/duration.rb +9 -0
- data/lib/evilution/reporter/cli/line_formatters/efficiency.rb +18 -0
- data/lib/evilution/reporter/cli/line_formatters/feedback_footer.rb +13 -0
- data/lib/evilution/reporter/cli/line_formatters/header.rb +10 -0
- data/lib/evilution/reporter/cli/line_formatters/mutations.rb +16 -0
- data/lib/evilution/reporter/cli/line_formatters/peak_memory.rb +12 -0
- data/lib/evilution/reporter/cli/line_formatters/result_line.rb +20 -0
- data/lib/evilution/reporter/cli/line_formatters/score.rb +14 -0
- data/lib/evilution/reporter/cli/line_formatters/truncation_notice.rb +11 -0
- data/lib/evilution/reporter/cli/line_formatters.rb +6 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +26 -0
- data/lib/evilution/reporter/cli/pct.rb +9 -0
- data/lib/evilution/reporter/cli/section.rb +13 -0
- data/lib/evilution/reporter/cli/section_renderer.rb +15 -0
- data/lib/evilution/reporter/cli/trailer.rb +22 -0
- data/lib/evilution/reporter/cli.rb +79 -162
- data/lib/evilution/runner/isolation_resolver.rb +9 -2
- data/lib/evilution/runner/mutation_executor/mutation_runner.rb +32 -0
- data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +16 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +46 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +75 -0
- data/lib/evilution/runner/mutation_executor/result_cache.rb +69 -0
- data/lib/evilution/runner/mutation_executor/result_notifier.rb +48 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +39 -0
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +80 -0
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +34 -0
- data/lib/evilution/runner/mutation_executor.rb +58 -289
- data/lib/evilution/runner.rb +21 -0
- data/lib/evilution/version.rb +1 -1
- metadata +113 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../config_factory"
|
|
5
|
+
require_relative "../../../runner"
|
|
6
|
+
require_relative "../../../spec_resolver"
|
|
7
|
+
|
|
8
|
+
class Evilution::MCP::InfoTool::Actions::Tests < Evilution::MCP::InfoTool::Actions::Base
|
|
9
|
+
def self.call(files: nil, spec: nil, integration: nil, skip_config: nil, **)
|
|
10
|
+
return config_error("files is required") if files.nil? || files.empty?
|
|
11
|
+
|
|
12
|
+
config = Evilution::MCP::InfoTool::ConfigFactory.tests(
|
|
13
|
+
files: files, spec: spec, integration: integration, skip_config: skip_config
|
|
14
|
+
)
|
|
15
|
+
return explicit_specs_response(files, config.spec_files) if config.spec_files.any?
|
|
16
|
+
|
|
17
|
+
resolver = resolver_for(config.integration)
|
|
18
|
+
resolved, unresolved = resolve_specs(files, resolver)
|
|
19
|
+
success(
|
|
20
|
+
"specs" => resolved,
|
|
21
|
+
"unresolved" => unresolved,
|
|
22
|
+
"total_sources" => files.length,
|
|
23
|
+
"total_specs" => resolved.map { |r| r["spec"] }.uniq.length
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def resolver_for(integration)
|
|
31
|
+
integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
|
|
32
|
+
return Evilution::SpecResolver.new unless integration_class
|
|
33
|
+
|
|
34
|
+
integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def explicit_specs_response(files, spec_files)
|
|
38
|
+
success(
|
|
39
|
+
"specs" => spec_files.map { |f| { "source" => nil, "spec" => f } },
|
|
40
|
+
"unresolved" => [],
|
|
41
|
+
"total_sources" => files.length,
|
|
42
|
+
"total_specs" => spec_files.uniq.length
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve_specs(files, resolver)
|
|
47
|
+
resolved = []
|
|
48
|
+
unresolved = []
|
|
49
|
+
files.each do |source|
|
|
50
|
+
found = resolver.call(source)
|
|
51
|
+
if found
|
|
52
|
+
resolved << { "source" => source, "spec" => found }
|
|
53
|
+
else
|
|
54
|
+
unresolved << source
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
[resolved, unresolved]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../../mcp"
|
|
5
|
+
|
|
6
|
+
class Evilution::MCP::InfoTool < MCP::Tool; end unless defined?(Evilution::MCP::InfoTool)
|
|
7
|
+
|
|
8
|
+
module Evilution::MCP::InfoTool::Actions
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
unless defined?(Evilution::MCP::InfoTool::Actions::Base)
|
|
12
|
+
class Evilution::MCP::InfoTool::Actions::Base # rubocop:disable Lint/EmptyClass
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require_relative "../info_tool"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../info_tool"
|
|
4
|
+
require_relative "../../config"
|
|
5
|
+
|
|
6
|
+
module Evilution::MCP::InfoTool::ConfigFactory
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def subjects(files:, line_ranges:, target:, integration:, skip_config:)
|
|
10
|
+
opts = { target_files: files, line_ranges: line_ranges || {} }
|
|
11
|
+
opts[:skip_config_file] = true if skip_config
|
|
12
|
+
opts[:target] = target if target
|
|
13
|
+
opts[:integration] = integration if integration
|
|
14
|
+
Evilution::Config.new(**opts)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def tests(files:, spec:, integration:, skip_config:)
|
|
18
|
+
opts = { target_files: files }
|
|
19
|
+
opts[:skip_config_file] = true if skip_config
|
|
20
|
+
opts[:spec_files] = spec if spec
|
|
21
|
+
opts[:integration] = integration if integration
|
|
22
|
+
Evilution::Config.new(**opts)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../info_tool"
|
|
4
|
+
|
|
5
|
+
module Evilution::MCP::InfoTool::ErrorMapper
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def type_for(error)
|
|
9
|
+
case error
|
|
10
|
+
when Evilution::ConfigError then "config_error"
|
|
11
|
+
when Evilution::ParseError then "parse_error"
|
|
12
|
+
else "runtime_error"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../info_tool"
|
|
4
|
+
|
|
5
|
+
module Evilution::MCP::InfoTool::RequestParser
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse_files(raw_files)
|
|
9
|
+
files = []
|
|
10
|
+
ranges = {}
|
|
11
|
+
|
|
12
|
+
raw_files.each do |arg|
|
|
13
|
+
file, range_str = arg.split(":", 2)
|
|
14
|
+
files << file
|
|
15
|
+
ranges[file] = parse_line_range(range_str) if range_str
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
[files, ranges]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse_line_range(str)
|
|
22
|
+
if str.include?("-")
|
|
23
|
+
start_str, end_str = str.split("-", 2)
|
|
24
|
+
start_line = Integer(start_str)
|
|
25
|
+
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
26
|
+
start_line..end_line
|
|
27
|
+
else
|
|
28
|
+
line = Integer(str)
|
|
29
|
+
line..line
|
|
30
|
+
end
|
|
31
|
+
rescue ArgumentError, TypeError
|
|
32
|
+
raise Evilution::ParseError, "invalid line range: #{str.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../info_tool"
|
|
5
|
+
require_relative "error_mapper"
|
|
6
|
+
|
|
7
|
+
module Evilution::MCP::InfoTool::ResponseFormatter
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def success(payload)
|
|
11
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def error(type, message)
|
|
15
|
+
::MCP::Tool::Response.new(
|
|
16
|
+
[{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
|
|
17
|
+
error: true
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def error_for(exception)
|
|
22
|
+
error(Evilution::MCP::InfoTool::ErrorMapper.type_for(exception), exception.message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../info_tool"
|
|
4
|
+
require_relative "../../result/mutation_result"
|
|
5
|
+
|
|
6
|
+
module Evilution::MCP::InfoTool::StatusGlossary
|
|
7
|
+
ENTRIES = [
|
|
8
|
+
{
|
|
9
|
+
"status" => "killed",
|
|
10
|
+
"meaning" => "A test failed when the mutation was applied — the test suite caught the mutation. " \
|
|
11
|
+
"This is the desired outcome.",
|
|
12
|
+
"counted_in_score" => true
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"status" => "survived",
|
|
16
|
+
"meaning" => "No test failed when the mutation was applied — gap in coverage. " \
|
|
17
|
+
"The test suite did not detect the behavioral change.",
|
|
18
|
+
"counted_in_score" => true
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"status" => "timeout",
|
|
22
|
+
"meaning" => "Test run exceeded the configured per-mutation timeout. " \
|
|
23
|
+
"Treated like survived for scoring (counted in the denominator); " \
|
|
24
|
+
"may indicate an infinite loop introduced by the mutation.",
|
|
25
|
+
"counted_in_score" => true
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"status" => "error",
|
|
29
|
+
"meaning" => "Mutation execution raised an unexpected error (syntax error at load time, " \
|
|
30
|
+
"boot failure, test-infrastructure crash). The mutation could not be evaluated.",
|
|
31
|
+
"counted_in_score" => false
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"status" => "neutral",
|
|
35
|
+
"meaning" => "Baseline tests already failed before the mutation was applied — pre-existing " \
|
|
36
|
+
"test-suite problem (flaky spec, infra collision, fixture setup failure). " \
|
|
37
|
+
"Not a meaningful mutation signal.",
|
|
38
|
+
"counted_in_score" => false
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"status" => "equivalent",
|
|
42
|
+
"meaning" => "Mutation is provably identical to the original source " \
|
|
43
|
+
"(e.g. a no-op replacement that the parser or evaluator treats as semantically equal).",
|
|
44
|
+
"counted_in_score" => false
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"status" => "unresolved",
|
|
48
|
+
"meaning" => "No spec/test file resolved for the mutated source — coverage gap, not a failure. " \
|
|
49
|
+
"The file has no corresponding test file the resolver could locate.",
|
|
50
|
+
"counted_in_score" => false
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"status" => "unparseable",
|
|
54
|
+
"meaning" => "Mutated source failed to parse (e.g. dangling heredoc after method_body_replacement). " \
|
|
55
|
+
"Short-circuited before execution; no test run was attempted.",
|
|
56
|
+
"counted_in_score" => false
|
|
57
|
+
}
|
|
58
|
+
].each(&:freeze).freeze
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
def entries
|
|
63
|
+
check_drift!
|
|
64
|
+
ENTRIES
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_drift!
|
|
68
|
+
defined_statuses = Evilution::Result::MutationResult::STATUSES.map(&:to_s).sort
|
|
69
|
+
documented = ENTRIES.map { |s| s["status"] }.sort
|
|
70
|
+
return if defined_statuses == documented
|
|
71
|
+
|
|
72
|
+
missing = (defined_statuses - documented) + (documented - defined_statuses)
|
|
73
|
+
raise Evilution::Error, "status glossary drift: #{missing.inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -13,25 +13,30 @@ require_relative "../version"
|
|
|
13
13
|
require_relative "../mcp"
|
|
14
14
|
|
|
15
15
|
class Evilution::MCP::InfoTool < MCP::Tool
|
|
16
|
+
VALID_ACTIONS = %w[subjects tests environment statuses feedback].freeze
|
|
17
|
+
|
|
16
18
|
tool_name "evilution-info"
|
|
17
19
|
description "Discover what evilution sees before running any mutations. " \
|
|
18
|
-
"One tool,
|
|
20
|
+
"One tool, five actions: " \
|
|
19
21
|
"'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
|
|
20
22
|
"'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
|
|
21
23
|
"'environment' dumps the effective config (version, ruby, config file, timeout, " \
|
|
22
24
|
"integration, isolation, and every other setting); " \
|
|
23
25
|
"'statuses' returns the mutation-result status glossary (killed/survived/neutral/error/etc.) with " \
|
|
24
|
-
"per-status meaning and scoring semantics so agents can triage results without guessing
|
|
26
|
+
"per-status meaning and scoring semantics so agents can triage results without guessing; " \
|
|
27
|
+
"'feedback' returns the public discussion URL plus consent and privacy guidance for posting " \
|
|
28
|
+
"feedback on errors, usage problems, friction, or missing capabilities. " \
|
|
25
29
|
"Use this instead of shelling out to 'evilution subjects', 'evilution tests list', or 'evilution environment show' — " \
|
|
26
30
|
"the response is structured JSON so you can plan the next mutation run without parsing CLI text."
|
|
27
31
|
input_schema(
|
|
28
32
|
properties: {
|
|
29
33
|
action: {
|
|
30
34
|
type: "string",
|
|
31
|
-
enum:
|
|
35
|
+
enum: VALID_ACTIONS,
|
|
32
36
|
description: "Which discovery operation to perform. " \
|
|
33
37
|
"'subjects' lists mutatable methods; 'tests' resolves specs for sources; " \
|
|
34
|
-
"'environment' dumps effective config; 'statuses' returns the result-status glossary
|
|
38
|
+
"'environment' dumps effective config; 'statuses' returns the result-status glossary; " \
|
|
39
|
+
"'feedback' returns the discussion URL and consent/privacy guidance."
|
|
35
40
|
},
|
|
36
41
|
files: {
|
|
37
42
|
type: "array",
|
|
@@ -64,270 +69,47 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
64
69
|
required: ["action"]
|
|
65
70
|
)
|
|
66
71
|
|
|
67
|
-
VALID_ACTIONS = %w[subjects tests environment statuses].freeze
|
|
68
|
-
|
|
69
|
-
STATUS_GLOSSARY = [
|
|
70
|
-
{
|
|
71
|
-
"status" => "killed",
|
|
72
|
-
"meaning" => "A test failed when the mutation was applied — the test suite caught the mutation. " \
|
|
73
|
-
"This is the desired outcome.",
|
|
74
|
-
"counted_in_score" => true
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"status" => "survived",
|
|
78
|
-
"meaning" => "No test failed when the mutation was applied — gap in coverage. " \
|
|
79
|
-
"The test suite did not detect the behavioral change.",
|
|
80
|
-
"counted_in_score" => true
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
"status" => "timeout",
|
|
84
|
-
"meaning" => "Test run exceeded the configured per-mutation timeout. " \
|
|
85
|
-
"Treated like survived for scoring (counted in the denominator); " \
|
|
86
|
-
"may indicate an infinite loop introduced by the mutation.",
|
|
87
|
-
"counted_in_score" => true
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
"status" => "error",
|
|
91
|
-
"meaning" => "Mutation execution raised an unexpected error (syntax error at load time, " \
|
|
92
|
-
"boot failure, test-infrastructure crash). The mutation could not be evaluated.",
|
|
93
|
-
"counted_in_score" => false
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
"status" => "neutral",
|
|
97
|
-
"meaning" => "Baseline tests already failed before the mutation was applied — pre-existing " \
|
|
98
|
-
"test-suite problem (flaky spec, infra collision, fixture setup failure). " \
|
|
99
|
-
"Not a meaningful mutation signal.",
|
|
100
|
-
"counted_in_score" => false
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
"status" => "equivalent",
|
|
104
|
-
"meaning" => "Mutation is provably identical to the original source " \
|
|
105
|
-
"(e.g. a no-op replacement that the parser or evaluator treats as semantically equal).",
|
|
106
|
-
"counted_in_score" => false
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
"status" => "unresolved",
|
|
110
|
-
"meaning" => "No spec/test file resolved for the mutated source — coverage gap, not a failure. " \
|
|
111
|
-
"The file has no corresponding test file the resolver could locate.",
|
|
112
|
-
"counted_in_score" => false
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
"status" => "unparseable",
|
|
116
|
-
"meaning" => "Mutated source failed to parse (e.g. dangling heredoc after method_body_replacement). " \
|
|
117
|
-
"Short-circuited before execution; no test run was attempted.",
|
|
118
|
-
"counted_in_score" => false
|
|
119
|
-
}
|
|
120
|
-
].freeze
|
|
121
|
-
private_constant :STATUS_GLOSSARY
|
|
122
|
-
|
|
123
72
|
class << self
|
|
124
73
|
# rubocop:disable Lint/UnusedMethodArgument
|
|
125
74
|
def call(server_context:, action: nil, files: nil, target: nil, spec: nil, integration: nil, skip_config: nil)
|
|
126
|
-
return
|
|
127
|
-
return
|
|
75
|
+
return ResponseFormatter.error("config_error", "action is required") unless action
|
|
76
|
+
return ResponseFormatter.error("config_error", "unknown action: #{action}") unless ACTIONS.key?(action)
|
|
128
77
|
|
|
129
|
-
parsed_files, line_ranges = parse_files(Array(files)) if files
|
|
78
|
+
parsed_files, line_ranges = RequestParser.parse_files(Array(files)) if files
|
|
130
79
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
when "tests"
|
|
136
|
-
tests_action(files: parsed_files, spec: spec, integration: integration, skip_config: skip_config)
|
|
137
|
-
when "environment"
|
|
138
|
-
environment_action
|
|
139
|
-
when "statuses"
|
|
140
|
-
statuses_action
|
|
141
|
-
end
|
|
80
|
+
ACTIONS[action].call(
|
|
81
|
+
files: parsed_files, line_ranges: line_ranges, target: target, spec: spec,
|
|
82
|
+
integration: integration, skip_config: skip_config
|
|
83
|
+
)
|
|
142
84
|
rescue Evilution::Error => e
|
|
143
|
-
|
|
85
|
+
ResponseFormatter.error_for(e)
|
|
144
86
|
end
|
|
145
87
|
# rubocop:enable Lint/UnusedMethodArgument
|
|
146
|
-
|
|
147
|
-
private
|
|
148
|
-
|
|
149
|
-
def parse_files(raw_files)
|
|
150
|
-
files = []
|
|
151
|
-
ranges = {}
|
|
152
|
-
|
|
153
|
-
raw_files.each do |arg|
|
|
154
|
-
file, range_str = arg.split(":", 2)
|
|
155
|
-
files << file
|
|
156
|
-
ranges[file] = parse_line_range(range_str) if range_str
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
[files, ranges]
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def parse_line_range(str)
|
|
163
|
-
if str.include?("-")
|
|
164
|
-
start_str, end_str = str.split("-", 2)
|
|
165
|
-
start_line = Integer(start_str)
|
|
166
|
-
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
167
|
-
start_line..end_line
|
|
168
|
-
else
|
|
169
|
-
line = Integer(str)
|
|
170
|
-
line..line
|
|
171
|
-
end
|
|
172
|
-
rescue ArgumentError, TypeError
|
|
173
|
-
raise Evilution::ParseError, "invalid line range: #{str.inspect}"
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def subjects_action(files:, line_ranges:, target:, integration:, skip_config:)
|
|
177
|
-
return error_response("config_error", "files is required") if files.nil? || files.empty?
|
|
178
|
-
|
|
179
|
-
config = build_subjects_config(files: files, line_ranges: line_ranges,
|
|
180
|
-
target: target, integration: integration, skip_config: skip_config)
|
|
181
|
-
runner = Evilution::Runner.new(config: config)
|
|
182
|
-
subjects = runner.parse_and_filter_subjects
|
|
183
|
-
|
|
184
|
-
registry = Evilution::Mutator::Registry.default
|
|
185
|
-
filter = build_subject_filter(config)
|
|
186
|
-
operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
|
|
187
|
-
|
|
188
|
-
entries = subjects.map do |subj|
|
|
189
|
-
count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
|
|
190
|
-
{ "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
|
|
191
|
-
ensure
|
|
192
|
-
subj.release_node!
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
success_response(
|
|
196
|
-
"subjects" => entries,
|
|
197
|
-
"total_subjects" => entries.length,
|
|
198
|
-
"total_mutations" => entries.sum { |e| e["mutations"] }
|
|
199
|
-
)
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def tests_action(files:, spec:, integration:, skip_config:)
|
|
203
|
-
return error_response("config_error", "files is required") if files.nil? || files.empty?
|
|
204
|
-
|
|
205
|
-
config = build_tests_config(files: files, spec: spec, integration: integration, skip_config: skip_config)
|
|
206
|
-
return explicit_specs_response(files, config.spec_files) if config.spec_files.any?
|
|
207
|
-
|
|
208
|
-
resolver = resolver_for_integration(config.integration)
|
|
209
|
-
resolved, unresolved = resolve_specs(files, resolver)
|
|
210
|
-
success_response(
|
|
211
|
-
"specs" => resolved,
|
|
212
|
-
"unresolved" => unresolved,
|
|
213
|
-
"total_sources" => files.length,
|
|
214
|
-
"total_specs" => resolved.map { |r| r["spec"] }.uniq.length
|
|
215
|
-
)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def build_subjects_config(files:, line_ranges:, target:, integration:, skip_config:)
|
|
219
|
-
opts = { target_files: files, line_ranges: line_ranges || {} }
|
|
220
|
-
opts[:skip_config_file] = true if skip_config
|
|
221
|
-
opts[:target] = target if target
|
|
222
|
-
opts[:integration] = integration if integration
|
|
223
|
-
Evilution::Config.new(**opts)
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def build_tests_config(files:, spec:, integration:, skip_config:)
|
|
227
|
-
opts = { target_files: files }
|
|
228
|
-
opts[:skip_config_file] = true if skip_config
|
|
229
|
-
opts[:spec_files] = spec if spec
|
|
230
|
-
opts[:integration] = integration if integration
|
|
231
|
-
Evilution::Config.new(**opts)
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def resolver_for_integration(integration)
|
|
235
|
-
integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
|
|
236
|
-
return Evilution::SpecResolver.new unless integration_class
|
|
237
|
-
|
|
238
|
-
integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def explicit_specs_response(files, spec_files)
|
|
242
|
-
success_response(
|
|
243
|
-
"specs" => spec_files.map { |f| { "source" => nil, "spec" => f } },
|
|
244
|
-
"unresolved" => [],
|
|
245
|
-
"total_sources" => files.length,
|
|
246
|
-
"total_specs" => spec_files.length
|
|
247
|
-
)
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def resolve_specs(files, resolver)
|
|
251
|
-
resolved = []
|
|
252
|
-
unresolved = []
|
|
253
|
-
files.each do |source|
|
|
254
|
-
found = resolver.call(source)
|
|
255
|
-
if found
|
|
256
|
-
resolved << { "source" => source, "spec" => found }
|
|
257
|
-
else
|
|
258
|
-
unresolved << source
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
[resolved, unresolved]
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def environment_action
|
|
265
|
-
config = Evilution::Config.new(skip_config_file: false)
|
|
266
|
-
config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
|
|
267
|
-
|
|
268
|
-
success_response(
|
|
269
|
-
"version" => Evilution::VERSION,
|
|
270
|
-
"ruby" => RUBY_VERSION,
|
|
271
|
-
"config_file" => config_file,
|
|
272
|
-
"settings" => environment_settings(config)
|
|
273
|
-
)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
def statuses_action
|
|
277
|
-
# Guard against drift: every STATUSES symbol must have a glossary entry.
|
|
278
|
-
defined = Evilution::Result::MutationResult::STATUSES.map(&:to_s).sort
|
|
279
|
-
documented = STATUS_GLOSSARY.map { |s| s["status"] }.sort
|
|
280
|
-
if defined != documented
|
|
281
|
-
missing = (defined - documented) + (documented - defined)
|
|
282
|
-
raise Evilution::Error, "status glossary drift: #{missing.inspect}"
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
success_response("statuses" => STATUS_GLOSSARY)
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def error_response_for(error)
|
|
289
|
-
type = case error
|
|
290
|
-
when Evilution::ConfigError then "config_error"
|
|
291
|
-
when Evilution::ParseError then "parse_error"
|
|
292
|
-
else "runtime_error"
|
|
293
|
-
end
|
|
294
|
-
error_response(type, error.message)
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
def environment_settings(config)
|
|
298
|
-
{
|
|
299
|
-
"timeout" => config.timeout,
|
|
300
|
-
"format" => config.format,
|
|
301
|
-
"integration" => config.integration,
|
|
302
|
-
"jobs" => config.jobs,
|
|
303
|
-
"isolation" => config.isolation,
|
|
304
|
-
"baseline" => config.baseline,
|
|
305
|
-
"incremental" => config.incremental,
|
|
306
|
-
"fail_fast" => config.fail_fast,
|
|
307
|
-
"min_score" => config.min_score,
|
|
308
|
-
"suggest_tests" => config.suggest_tests,
|
|
309
|
-
"save_session" => config.save_session,
|
|
310
|
-
"target" => config.target,
|
|
311
|
-
"skip_heredoc_literals" => config.skip_heredoc_literals,
|
|
312
|
-
"ignore_patterns" => config.ignore_patterns
|
|
313
|
-
}
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def build_subject_filter(config)
|
|
317
|
-
return nil if config.ignore_patterns.empty?
|
|
318
|
-
|
|
319
|
-
Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
def success_response(payload)
|
|
323
|
-
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
def error_response(type, message)
|
|
327
|
-
::MCP::Tool::Response.new(
|
|
328
|
-
[{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
|
|
329
|
-
error: true
|
|
330
|
-
)
|
|
331
|
-
end
|
|
332
88
|
end
|
|
333
89
|
end
|
|
90
|
+
|
|
91
|
+
require_relative "info_tool/request_parser"
|
|
92
|
+
require_relative "info_tool/error_mapper"
|
|
93
|
+
require_relative "info_tool/response_formatter"
|
|
94
|
+
require_relative "info_tool/status_glossary"
|
|
95
|
+
require_relative "info_tool/config_factory"
|
|
96
|
+
require_relative "info_tool/actions"
|
|
97
|
+
require_relative "info_tool/actions/base"
|
|
98
|
+
require_relative "info_tool/actions/subjects"
|
|
99
|
+
require_relative "info_tool/actions/tests"
|
|
100
|
+
require_relative "info_tool/actions/environment"
|
|
101
|
+
require_relative "info_tool/actions/statuses"
|
|
102
|
+
require_relative "info_tool/actions/feedback"
|
|
103
|
+
|
|
104
|
+
Evilution::MCP::InfoTool.const_set(:ACTIONS, {
|
|
105
|
+
"subjects" => Evilution::MCP::InfoTool::Actions::Subjects,
|
|
106
|
+
"tests" => Evilution::MCP::InfoTool::Actions::Tests,
|
|
107
|
+
"environment" => Evilution::MCP::InfoTool::Actions::Environment,
|
|
108
|
+
"statuses" => Evilution::MCP::InfoTool::Actions::Statuses,
|
|
109
|
+
"feedback" => Evilution::MCP::InfoTool::Actions::Feedback
|
|
110
|
+
}.freeze)
|
|
111
|
+
Evilution::MCP::InfoTool.send(:private_constant, :ACTIONS)
|
|
112
|
+
|
|
113
|
+
unless Evilution::MCP::InfoTool.send(:const_get, :ACTIONS).keys == Evilution::MCP::InfoTool::VALID_ACTIONS
|
|
114
|
+
raise "InfoTool action drift: ACTIONS keys do not match VALID_ACTIONS"
|
|
115
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../mutate_tool"
|
|
4
|
+
require_relative "../../feedback"
|
|
5
|
+
require_relative "../../feedback/messages"
|
|
4
6
|
|
|
5
7
|
module Evilution::MCP::MutateTool::ErrorPayload
|
|
6
8
|
def self.build(error)
|
|
@@ -12,6 +14,11 @@ module Evilution::MCP::MutateTool::ErrorPayload
|
|
|
12
14
|
|
|
13
15
|
payload = { type: type, message: error.message }
|
|
14
16
|
payload[:file] = error.file if error.file
|
|
15
|
-
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
error: payload,
|
|
20
|
+
feedback_url: Evilution::Feedback::DISCUSSION_URL,
|
|
21
|
+
feedback_hint: Evilution::Feedback::Messages.mcp_hint
|
|
22
|
+
}
|
|
16
23
|
end
|
|
17
24
|
end
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "../mutate_tool"
|
|
5
|
+
require_relative "../../feedback"
|
|
6
|
+
require_relative "../../feedback/detector"
|
|
7
|
+
require_relative "../../feedback/messages"
|
|
5
8
|
|
|
6
9
|
module Evilution::MCP::MutateTool::ReportTrimmer
|
|
7
10
|
MINIMAL_KEYS = %w[summary survived].freeze
|
|
8
11
|
FULL_DIFF_STRIP_KEYS = %w[killed neutral equivalent unresolved unparseable].freeze
|
|
9
12
|
SUMMARY_DROP_KEYS = %w[killed neutral equivalent unparseable].freeze
|
|
10
13
|
|
|
11
|
-
def self.call(json_string, verbosity:, survived_results:, config:, enricher:)
|
|
14
|
+
def self.call(json_string, verbosity:, survived_results:, config:, enricher:, summary: nil)
|
|
12
15
|
data = ::JSON.parse(json_string)
|
|
13
16
|
case verbosity
|
|
14
17
|
when "full"
|
|
@@ -19,6 +22,7 @@ module Evilution::MCP::MutateTool::ReportTrimmer
|
|
|
19
22
|
data.keep_if { |key, _| MINIMAL_KEYS.include?(key) }
|
|
20
23
|
end
|
|
21
24
|
enricher.call(data, survived_results, config)
|
|
25
|
+
embed_feedback(data, summary) unless verbosity == "minimal"
|
|
22
26
|
::JSON.generate(data)
|
|
23
27
|
end
|
|
24
28
|
|
|
@@ -28,4 +32,12 @@ module Evilution::MCP::MutateTool::ReportTrimmer
|
|
|
28
32
|
data[key].each { |entry| entry.delete("diff") }
|
|
29
33
|
end
|
|
30
34
|
private_class_method :strip_diffs
|
|
35
|
+
|
|
36
|
+
def self.embed_feedback(data, summary)
|
|
37
|
+
return unless Evilution::Feedback::Detector.friction?(summary)
|
|
38
|
+
|
|
39
|
+
data["feedback_url"] = Evilution::Feedback::DISCUSSION_URL
|
|
40
|
+
data["feedback_hint"] = Evilution::Feedback::Messages.mcp_hint
|
|
41
|
+
end
|
|
42
|
+
private_class_method :embed_feedback
|
|
31
43
|
end
|
|
@@ -26,7 +26,9 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
26
26
|
"'subject' (Class#method), resolved 'spec_file', and a concrete 'next_step' hint — " \
|
|
27
27
|
"so the agent can jump straight to writing the missing test. " \
|
|
28
28
|
"Prefer this over shelling out to 'evilution' — the response is machine-readable " \
|
|
29
|
-
"and already trimmed for survived-mutant triage."
|
|
29
|
+
"and already trimmed for survived-mutant triage. " \
|
|
30
|
+
"Hitting errors, friction, or missing capabilities? See evilution-info action=feedback for the " \
|
|
31
|
+
"public feedback channel — ask the user before posting anything."
|
|
30
32
|
input_schema(
|
|
31
33
|
properties: {
|
|
32
34
|
files: {
|
|
@@ -126,7 +128,8 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
126
128
|
verbosity: normalized_verbosity,
|
|
127
129
|
survived_results: summary.survived_results,
|
|
128
130
|
config: config,
|
|
129
|
-
enricher: Evilution::MCP::MutateTool::SurvivedEnricher
|
|
131
|
+
enricher: Evilution::MCP::MutateTool::SurvivedEnricher,
|
|
132
|
+
summary: summary
|
|
130
133
|
)
|
|
131
134
|
|
|
132
135
|
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|