evilution 0.22.7 → 0.24.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 +8 -0
- data/CHANGELOG.md +28 -0
- data/README.md +37 -7
- data/lib/evilution/cli/command.rb +37 -0
- data/lib/evilution/cli/commands/environment_show.rb +20 -0
- data/lib/evilution/cli/commands/init.rb +24 -0
- data/lib/evilution/cli/commands/mcp.rb +19 -0
- data/lib/evilution/cli/commands/run.rb +68 -0
- data/lib/evilution/cli/commands/session_diff.rb +30 -0
- data/lib/evilution/cli/commands/session_gc.rb +46 -0
- data/lib/evilution/cli/commands/session_list.rb +51 -0
- data/lib/evilution/cli/commands/session_show.rb +27 -0
- data/lib/evilution/cli/commands/subjects.rb +50 -0
- data/lib/evilution/cli/commands/tests_list.rb +43 -0
- data/lib/evilution/cli/commands/util_mutation.rb +66 -0
- data/lib/evilution/cli/commands/version.rb +17 -0
- data/lib/evilution/cli/commands.rb +4 -0
- data/lib/evilution/cli/dispatcher.rb +23 -0
- data/lib/evilution/cli/parsed_args.rb +12 -0
- data/lib/evilution/cli/parser/command_extractor.rb +77 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +103 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +88 -0
- data/lib/evilution/cli/printers/environment.rb +53 -0
- data/lib/evilution/cli/printers/session_detail.rb +76 -0
- data/lib/evilution/cli/printers/session_diff.rb +57 -0
- data/lib/evilution/cli/printers/session_list.rb +48 -0
- data/lib/evilution/cli/printers/subjects.rb +35 -0
- data/lib/evilution/cli/printers/tests_list.rb +45 -0
- data/lib/evilution/cli/printers/util_mutation.rb +35 -0
- data/lib/evilution/cli/printers.rb +4 -0
- data/lib/evilution/cli/result.rb +9 -0
- data/lib/evilution/cli.rb +30 -850
- data/lib/evilution/config.rb +31 -3
- data/lib/evilution/integration/base.rb +23 -55
- data/lib/evilution/integration/minitest.rb +22 -4
- data/lib/evilution/integration/rspec.rb +28 -8
- data/lib/evilution/isolation/fork.rb +11 -9
- data/lib/evilution/isolation/in_process.rb +11 -9
- data/lib/evilution/mcp/info_tool.rb +261 -0
- data/lib/evilution/mcp/mutate_tool.rb +112 -19
- data/lib/evilution/mcp/server.rb +3 -4
- data/lib/evilution/mcp/session_diff_tool.rb +5 -1
- data/lib/evilution/mcp/session_list_tool.rb +5 -1
- data/lib/evilution/mcp/session_show_tool.rb +5 -1
- data/lib/evilution/mcp/session_tool.rb +157 -0
- data/lib/evilution/reporter/cli.rb +2 -1
- data/lib/evilution/reporter/html/assets/style.css +68 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
- data/lib/evilution/reporter/html/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
- data/lib/evilution/reporter/html.rb +11 -349
- data/lib/evilution/reporter/json.rb +12 -8
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +9 -1
- data/lib/evilution/runner/baseline_runner.rb +71 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +255 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +57 -692
- data/lib/evilution/version.rb +1 -1
- metadata +71 -2
|
@@ -6,13 +6,28 @@ require_relative "../config"
|
|
|
6
6
|
require_relative "../runner"
|
|
7
7
|
require_relative "../reporter/json"
|
|
8
8
|
require_relative "../reporter/suggestion"
|
|
9
|
+
require_relative "../spec_resolver"
|
|
9
10
|
|
|
10
11
|
require_relative "../mcp"
|
|
11
12
|
|
|
12
13
|
class Evilution::MCP::MutateTool < MCP::Tool
|
|
13
14
|
tool_name "evilution-mutate"
|
|
14
|
-
description "Run mutation testing on Ruby source files. " \
|
|
15
|
-
"
|
|
15
|
+
description "Run mutation testing on Ruby source files and return structured JSON — not parsed CLI text. " \
|
|
16
|
+
"Built for iterative TDD: " \
|
|
17
|
+
"'incremental: true' caches killed/timeout results so rerunning on unchanged files is fast; " \
|
|
18
|
+
"'save_session: true' persists results for later diffing via evilution-session; " \
|
|
19
|
+
"'suggest_tests: true' streams concrete RSpec/Minitest code for each survivor as progress " \
|
|
20
|
+
"events so you can drop fixes straight into a test file. " \
|
|
21
|
+
"Respects .evilution.yml (timeout, jobs, integration, target, ignore_patterns, isolation) by default — " \
|
|
22
|
+
"pair with evilution-info to discover subjects and specs before you call this tool. " \
|
|
23
|
+
"Supports line-range file targeting (lib/foo.rb:15-30), 'target' method filter, explicit 'spec' overrides, " \
|
|
24
|
+
"'fail_fast' for early exit on N survivors, 'baseline: false' to skip the green-suite precheck, " \
|
|
25
|
+
"and 'verbosity' (full/summary/minimal) to match the agent's context budget. " \
|
|
26
|
+
"Survived mutants are enriched beyond `evilution --format json`: each entry includes " \
|
|
27
|
+
"'subject' (Class#method), resolved 'spec_file', and a concrete 'next_step' hint — " \
|
|
28
|
+
"so the agent can jump straight to writing the missing test. " \
|
|
29
|
+
"Prefer this over shelling out to 'evilution' — the response is machine-readable " \
|
|
30
|
+
"and already trimmed for survived-mutant triage."
|
|
16
31
|
input_schema(
|
|
17
32
|
properties: {
|
|
18
33
|
files: {
|
|
@@ -46,6 +61,37 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
46
61
|
description: "When true, suggestions for survived mutants include concrete test code " \
|
|
47
62
|
"instead of static description text (default: false)"
|
|
48
63
|
},
|
|
64
|
+
incremental: {
|
|
65
|
+
type: "boolean",
|
|
66
|
+
description: "Cache killed/timeout results and skip re-running them on unchanged files. " \
|
|
67
|
+
"Set true when iterating on the same files to speed up repeat runs."
|
|
68
|
+
},
|
|
69
|
+
integration: {
|
|
70
|
+
type: "string",
|
|
71
|
+
enum: %w[rspec minitest],
|
|
72
|
+
description: "Test integration to use (default: rspec)"
|
|
73
|
+
},
|
|
74
|
+
isolation: {
|
|
75
|
+
type: "string",
|
|
76
|
+
enum: %w[auto fork in_process],
|
|
77
|
+
description: "Isolation strategy for mutation execution (default: auto)"
|
|
78
|
+
},
|
|
79
|
+
baseline: {
|
|
80
|
+
type: "boolean",
|
|
81
|
+
description: "Run a baseline test suite check before mutations (default: true). " \
|
|
82
|
+
"Set false to skip when you already know the suite is green."
|
|
83
|
+
},
|
|
84
|
+
save_session: {
|
|
85
|
+
type: "boolean",
|
|
86
|
+
description: "Save session results to .evilution/results/ for later inspection via evilution-session"
|
|
87
|
+
},
|
|
88
|
+
skip_config: {
|
|
89
|
+
type: "boolean",
|
|
90
|
+
description: "When true, ignore .evilution.yml / config/evilution.yml. " \
|
|
91
|
+
"MCP-specific overrides (JSON output, quiet mode, preload disabled) and explicit tool " \
|
|
92
|
+
"parameters still apply. Default: false — project config is loaded so the MCP run " \
|
|
93
|
+
"matches `evilution` CLI behavior."
|
|
94
|
+
},
|
|
49
95
|
verbosity: {
|
|
50
96
|
type: "string",
|
|
51
97
|
enum: %w[full summary minimal],
|
|
@@ -57,27 +103,28 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
57
103
|
)
|
|
58
104
|
|
|
59
105
|
class << self
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
fail_fast: nil, spec: nil, suggest_tests: nil, verbosity: nil)
|
|
106
|
+
def call(server_context:, files: [], verbosity: nil, **opts)
|
|
107
|
+
validate_opts!(opts)
|
|
63
108
|
parsed_files, line_ranges = parse_files(Array(files))
|
|
64
|
-
config_opts = build_config_opts(parsed_files, line_ranges,
|
|
65
|
-
suggest_tests)
|
|
109
|
+
config_opts = build_config_opts(parsed_files, line_ranges, opts)
|
|
66
110
|
config = Evilution::Config.new(**config_opts)
|
|
111
|
+
suggest_tests = opts[:suggest_tests]
|
|
67
112
|
on_result = build_streaming_callback(server_context, suggest_tests, config.integration)
|
|
68
113
|
runner = Evilution::Runner.new(config: config, on_result: on_result)
|
|
69
114
|
summary = runner.call
|
|
70
115
|
report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true, integration: config.integration).call(summary)
|
|
71
|
-
compact = trim_report(report, normalize_verbosity(verbosity))
|
|
116
|
+
compact = trim_report(report, normalize_verbosity(verbosity), summary.survived_results, config)
|
|
72
117
|
|
|
73
118
|
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|
|
74
119
|
rescue Evilution::Error => e
|
|
75
120
|
error_payload = build_error_payload(e)
|
|
76
121
|
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
|
|
77
122
|
end
|
|
78
|
-
# rubocop:enable Metrics/ParameterLists
|
|
79
123
|
|
|
80
124
|
VALID_VERBOSITIES = %w[full summary minimal].freeze
|
|
125
|
+
PASSTHROUGH_KEYS = %i[target timeout jobs fail_fast suggest_tests incremental integration
|
|
126
|
+
isolation baseline save_session].freeze
|
|
127
|
+
ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
|
|
81
128
|
|
|
82
129
|
private
|
|
83
130
|
|
|
@@ -110,18 +157,21 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
110
157
|
raise Evilution::ParseError, "invalid line range: #{str.inspect}"
|
|
111
158
|
end
|
|
112
159
|
|
|
113
|
-
def
|
|
160
|
+
def validate_opts!(opts)
|
|
161
|
+
unknown = opts.keys - ALLOWED_OPT_KEYS
|
|
162
|
+
return if unknown.empty?
|
|
163
|
+
|
|
164
|
+
raise Evilution::ParseError, "unknown parameters: #{unknown.join(", ")}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_config_opts(files, line_ranges, params)
|
|
114
168
|
# Preload is disabled for MCP invocations: `require`-ing Rails into the
|
|
115
169
|
# long-lived MCP server would poison subsequent runs against other
|
|
116
170
|
# projects. MCP users who want the speedup should use the CLI.
|
|
117
|
-
opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true,
|
|
118
|
-
|
|
119
|
-
opts[:
|
|
120
|
-
opts[
|
|
121
|
-
opts[:jobs] = jobs if jobs
|
|
122
|
-
opts[:fail_fast] = fail_fast if fail_fast
|
|
123
|
-
opts[:spec_files] = spec if spec
|
|
124
|
-
opts[:suggest_tests] = true if suggest_tests
|
|
171
|
+
opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, preload: false }
|
|
172
|
+
opts[:skip_config_file] = true if params[:skip_config]
|
|
173
|
+
opts[:spec_files] = params[:spec] if params[:spec]
|
|
174
|
+
PASSTHROUGH_KEYS.each { |key| opts[key] = params[key] unless params[key].nil? }
|
|
125
175
|
opts
|
|
126
176
|
end
|
|
127
177
|
|
|
@@ -133,7 +183,7 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
133
183
|
raise Evilution::ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
|
|
134
184
|
end
|
|
135
185
|
|
|
136
|
-
def trim_report(json_string, verbosity)
|
|
186
|
+
def trim_report(json_string, verbosity, survived_results, config)
|
|
137
187
|
data = ::JSON.parse(json_string)
|
|
138
188
|
case verbosity
|
|
139
189
|
when "full"
|
|
@@ -151,9 +201,52 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
151
201
|
data.delete("timed_out")
|
|
152
202
|
data.delete("errors")
|
|
153
203
|
end
|
|
204
|
+
enrich_survived(data, survived_results, config)
|
|
154
205
|
::JSON.generate(data)
|
|
155
206
|
end
|
|
156
207
|
|
|
208
|
+
def enrich_survived(data, survived_results, config)
|
|
209
|
+
entries = data["survived"]
|
|
210
|
+
return unless entries.is_a?(Array)
|
|
211
|
+
|
|
212
|
+
explicit_spec = explicit_spec_override(config)
|
|
213
|
+
resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
|
|
214
|
+
cache = {}
|
|
215
|
+
|
|
216
|
+
entries.each_with_index do |entry, index|
|
|
217
|
+
result = survived_results[index]
|
|
218
|
+
next unless result
|
|
219
|
+
|
|
220
|
+
mutation = result.mutation
|
|
221
|
+
entry["subject"] = mutation.subject.name
|
|
222
|
+
spec_file = explicit_spec || cache.fetch(mutation.file_path) do
|
|
223
|
+
cache[mutation.file_path] = resolver.call(mutation.file_path)
|
|
224
|
+
end
|
|
225
|
+
entry["spec_file"] = spec_file if spec_file
|
|
226
|
+
entry["next_step"] = build_next_step(mutation, spec_file)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def explicit_spec_override(config)
|
|
231
|
+
return nil unless config.respond_to?(:spec_files)
|
|
232
|
+
|
|
233
|
+
files = Array(config.spec_files).compact.map(&:to_s).reject(&:empty?)
|
|
234
|
+
files.first
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def resolver_for_integration(integration)
|
|
238
|
+
integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
|
|
239
|
+
return Evilution::SpecResolver.new unless integration_class
|
|
240
|
+
|
|
241
|
+
integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def build_next_step(mutation, spec_file)
|
|
245
|
+
target = spec_file || "the covering test file"
|
|
246
|
+
"Add a test in #{target} that fails against this mutation at #{mutation.file_path}:#{mutation.line} " \
|
|
247
|
+
"(#{mutation.subject.name}, #{mutation.operator_name})."
|
|
248
|
+
end
|
|
249
|
+
|
|
157
250
|
def strip_diffs(data, key)
|
|
158
251
|
return unless data[key]
|
|
159
252
|
|
data/lib/evilution/mcp/server.rb
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
require_relative "../version"
|
|
5
5
|
require_relative "mutate_tool"
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "session_diff_tool"
|
|
6
|
+
require_relative "session_tool"
|
|
7
|
+
require_relative "info_tool"
|
|
9
8
|
|
|
10
9
|
require_relative "../mcp"
|
|
11
10
|
|
|
@@ -14,7 +13,7 @@ class Evilution::MCP::Server
|
|
|
14
13
|
::MCP::Server.new(
|
|
15
14
|
name: "evilution",
|
|
16
15
|
version: Evilution::VERSION,
|
|
17
|
-
tools: [Evilution::MCP::MutateTool, Evilution::MCP::
|
|
16
|
+
tools: [Evilution::MCP::MutateTool, Evilution::MCP::SessionTool, Evilution::MCP::InfoTool]
|
|
18
17
|
)
|
|
19
18
|
end
|
|
20
19
|
end
|
|
@@ -7,9 +7,13 @@ require_relative "../session/diff"
|
|
|
7
7
|
|
|
8
8
|
require_relative "../mcp"
|
|
9
9
|
|
|
10
|
+
# @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "diff") as of 0.22.8.
|
|
11
|
+
# No longer registered with the MCP server; retained only for direct Ruby callers.
|
|
12
|
+
# Will be removed entirely — tracked by EV-h8pw / GH #686.
|
|
10
13
|
class Evilution::MCP::SessionDiffTool < MCP::Tool
|
|
11
14
|
tool_name "evilution-session-diff"
|
|
12
|
-
description "
|
|
15
|
+
description "DEPRECATED: use evilution-session with action: 'diff'. " \
|
|
16
|
+
"Compare two mutation testing sessions and return the diff. " \
|
|
13
17
|
"Shows new regressions, fixed mutations, and persistent survivors."
|
|
14
18
|
input_schema(
|
|
15
19
|
properties: {
|
|
@@ -6,9 +6,13 @@ require_relative "../session/store"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../mcp"
|
|
8
8
|
|
|
9
|
+
# @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "list") as of 0.22.8.
|
|
10
|
+
# No longer registered with the MCP server; retained only for direct Ruby callers.
|
|
11
|
+
# Will be removed entirely — tracked by EV-h8pw / GH #686.
|
|
9
12
|
class Evilution::MCP::SessionListTool < MCP::Tool
|
|
10
13
|
tool_name "evilution-session-list"
|
|
11
|
-
description "
|
|
14
|
+
description "DEPRECATED: use evilution-session with action: 'list'. " \
|
|
15
|
+
"List past mutation testing sessions with summary statistics. " \
|
|
12
16
|
"Returns sessions in reverse chronological order."
|
|
13
17
|
input_schema(
|
|
14
18
|
properties: {
|
|
@@ -6,9 +6,13 @@ require_relative "../session/store"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../mcp"
|
|
8
8
|
|
|
9
|
+
# @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "show") as of 0.22.8.
|
|
10
|
+
# No longer registered with the MCP server; retained only for direct Ruby callers.
|
|
11
|
+
# Will be removed entirely — tracked by EV-h8pw / GH #686.
|
|
9
12
|
class Evilution::MCP::SessionShowTool < MCP::Tool
|
|
10
13
|
tool_name "evilution-session-show"
|
|
11
|
-
description "
|
|
14
|
+
description "DEPRECATED: use evilution-session with action: 'show'. " \
|
|
15
|
+
"Show full details of a past mutation testing session, " \
|
|
12
16
|
"including survived mutations with diffs."
|
|
13
17
|
input_schema(
|
|
14
18
|
properties: {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "../session/store"
|
|
6
|
+
require_relative "../session/diff"
|
|
7
|
+
|
|
8
|
+
require_relative "../mcp"
|
|
9
|
+
|
|
10
|
+
class Evilution::MCP::SessionTool < MCP::Tool
|
|
11
|
+
tool_name "evilution-session"
|
|
12
|
+
description "Inspect mutation testing history without re-running any tests. " \
|
|
13
|
+
"One tool, three actions: " \
|
|
14
|
+
"'list' browses saved sessions (reverse chronological), " \
|
|
15
|
+
"'show' returns the full report for a session (summary, survived mutations with diffs, git context), " \
|
|
16
|
+
"'diff' compares two sessions and surfaces new regressions, fixed mutations, persistent survivors, and score delta. " \
|
|
17
|
+
"Prefer this over the CLI when auditing mutation score trends, triaging survivors, " \
|
|
18
|
+
"or verifying that a fix killed the right mutant."
|
|
19
|
+
input_schema(
|
|
20
|
+
properties: {
|
|
21
|
+
action: {
|
|
22
|
+
type: "string",
|
|
23
|
+
enum: %w[list show diff],
|
|
24
|
+
description: "Which session operation to perform. 'list' browses history; 'show' displays one session; 'diff' compares two."
|
|
25
|
+
},
|
|
26
|
+
results_dir: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "[list|show|diff] Session results directory (default: .evilution/results). " \
|
|
29
|
+
"For show/diff, acts as the containment root: path/base/head must resolve under this directory."
|
|
30
|
+
},
|
|
31
|
+
limit: {
|
|
32
|
+
type: "integer",
|
|
33
|
+
description: "[list] Return only the N most recent sessions"
|
|
34
|
+
},
|
|
35
|
+
path: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "[show] Path to a session JSON file (as returned by action=list); must be under results_dir"
|
|
38
|
+
},
|
|
39
|
+
base: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "[diff] Path to the base (older) session JSON file; must be under results_dir"
|
|
42
|
+
},
|
|
43
|
+
head: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "[diff] Path to the head (newer) session JSON file; must be under results_dir"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
required: ["action"]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
VALID_ACTIONS = %w[list show diff].freeze
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
55
|
+
def call(server_context:, action: nil, results_dir: nil, limit: nil, path: nil, base: nil, head: nil)
|
|
56
|
+
return error_response("config_error", "action is required") unless action
|
|
57
|
+
return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
|
|
58
|
+
|
|
59
|
+
case action
|
|
60
|
+
when "list" then list_action(results_dir: results_dir, limit: limit)
|
|
61
|
+
when "show" then show_action(path: path, results_dir: results_dir)
|
|
62
|
+
when "diff" then diff_action(base: base, head: head, results_dir: results_dir)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def list_action(results_dir:, limit:)
|
|
70
|
+
normalized_limit, limit_error = normalize_limit(limit)
|
|
71
|
+
return error_response("config_error", limit_error) if limit_error
|
|
72
|
+
|
|
73
|
+
store_opts = {}
|
|
74
|
+
store_opts[:results_dir] = results_dir if results_dir
|
|
75
|
+
store = Evilution::Session::Store.new(**store_opts)
|
|
76
|
+
entries = store.list
|
|
77
|
+
entries = entries.first(normalized_limit) unless normalized_limit.nil?
|
|
78
|
+
|
|
79
|
+
payload = entries.map { |e| e.transform_keys(&:to_s) }
|
|
80
|
+
success_response(payload)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_limit(limit)
|
|
84
|
+
return [nil, nil] if limit.nil?
|
|
85
|
+
|
|
86
|
+
coerced = Integer(limit)
|
|
87
|
+
return [nil, "limit must be a non-negative integer"] if coerced.negative?
|
|
88
|
+
|
|
89
|
+
[coerced, nil]
|
|
90
|
+
rescue ArgumentError, TypeError
|
|
91
|
+
[nil, "limit must be a non-negative integer"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def show_action(path:, results_dir:)
|
|
95
|
+
return error_response("config_error", "path is required") unless path
|
|
96
|
+
|
|
97
|
+
dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
|
|
98
|
+
return error_response("config_error", "path must be under results directory") unless within?(path, dir)
|
|
99
|
+
|
|
100
|
+
store = Evilution::Session::Store.new(results_dir: dir)
|
|
101
|
+
data = store.load(path)
|
|
102
|
+
success_response(data)
|
|
103
|
+
rescue Evilution::Error => e
|
|
104
|
+
error_response("not_found", e.message)
|
|
105
|
+
rescue ::JSON::ParserError => e
|
|
106
|
+
error_response("parse_error", e.message)
|
|
107
|
+
rescue SystemCallError => e
|
|
108
|
+
error_response("runtime_error", e.message)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def diff_action(base:, head:, results_dir:)
|
|
112
|
+
return error_response("config_error", "base is required") unless base
|
|
113
|
+
return error_response("config_error", "head is required") unless head
|
|
114
|
+
|
|
115
|
+
dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
|
|
116
|
+
return error_response("config_error", "base must be under results directory") unless within?(base, dir)
|
|
117
|
+
return error_response("config_error", "head must be under results directory") unless within?(head, dir)
|
|
118
|
+
|
|
119
|
+
store = Evilution::Session::Store.new(results_dir: dir)
|
|
120
|
+
base_data = store.load(base)
|
|
121
|
+
head_data = store.load(head)
|
|
122
|
+
|
|
123
|
+
diff = Evilution::Session::Diff.new
|
|
124
|
+
result = diff.call(base_data, head_data)
|
|
125
|
+
success_response(result.to_h)
|
|
126
|
+
rescue Evilution::Error => e
|
|
127
|
+
error_response("not_found", e.message)
|
|
128
|
+
rescue ::JSON::ParserError => e
|
|
129
|
+
error_response("parse_error", e.message)
|
|
130
|
+
rescue SystemCallError => e
|
|
131
|
+
error_response("runtime_error", e.message)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def within?(path, results_dir)
|
|
135
|
+
resolved_root = canonical_path(results_dir)
|
|
136
|
+
resolved_path = canonical_path(path)
|
|
137
|
+
resolved_path == resolved_root || resolved_path.start_with?(resolved_root + File::SEPARATOR)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def canonical_path(path)
|
|
141
|
+
File.realpath(path)
|
|
142
|
+
rescue Errno::ENOENT
|
|
143
|
+
File.expand_path(path)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def success_response(payload)
|
|
147
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def error_response(type, message)
|
|
151
|
+
::MCP::Tool::Response.new(
|
|
152
|
+
[{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
|
|
153
|
+
error: true
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -90,12 +90,13 @@ class Evilution::Reporter::CLI
|
|
|
90
90
|
"#{summary.survived} survived, #{summary.timed_out} timed out"
|
|
91
91
|
parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
|
|
92
92
|
parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
|
|
93
|
+
parts += ", #{summary.unresolved} unresolved" if summary.unresolved.positive?
|
|
93
94
|
parts += ", #{summary.skipped} skipped" if summary.skipped.positive?
|
|
94
95
|
parts
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
def score_line(summary)
|
|
98
|
-
denominator = summary.total - summary.errors - summary.neutral - summary.equivalent
|
|
99
|
+
denominator = summary.total - summary.errors - summary.neutral - summary.equivalent - summary.unresolved
|
|
99
100
|
score_pct = format_pct(summary.score)
|
|
100
101
|
"Score: #{score_pct} (#{summary.killed}/#{denominator})"
|
|
101
102
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
|
|
3
|
+
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
|
4
|
+
h1 { font-size: 1.5rem; color: #f0f6fc; }
|
|
5
|
+
.version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
|
|
6
|
+
.score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
|
|
7
|
+
.score-high { background: #1a4731; color: #3fb950; }
|
|
8
|
+
.score-medium { background: #4a3a10; color: #d29922; }
|
|
9
|
+
.score-low { background: #4a1a1a; color: #f85149; }
|
|
10
|
+
.summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
|
|
11
|
+
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
|
|
12
|
+
.card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
|
|
13
|
+
.card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
|
|
14
|
+
.card-killed { border-color: #238636; }
|
|
15
|
+
.card-survived { border-color: #da3633; }
|
|
16
|
+
.truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
|
|
17
|
+
.file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
|
|
18
|
+
.file-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: #1c2129; border-bottom: 1px solid #30363d; font-size: 0.9rem; }
|
|
19
|
+
.file-path { color: #58a6ff; font-family: monospace; }
|
|
20
|
+
.file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
|
|
21
|
+
.mutation-map { padding: 0.5rem 1rem; }
|
|
22
|
+
.map-line { display: flex; align-items: center; gap: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; font-family: monospace; }
|
|
23
|
+
.map-line.killed { color: #3fb950; }
|
|
24
|
+
.map-line.survived { color: #f85149; }
|
|
25
|
+
.map-line.timeout { color: #d29922; }
|
|
26
|
+
.map-line.error { color: #f85149; }
|
|
27
|
+
.map-line.neutral { color: #8b949e; }
|
|
28
|
+
.map-line.equivalent { color: #8b949e; }
|
|
29
|
+
.line-number { min-width: 60px; color: #8b949e; }
|
|
30
|
+
.operator { flex: 1; }
|
|
31
|
+
.status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
|
|
32
|
+
.status-badge.killed { background: #1a4731; }
|
|
33
|
+
.status-badge.survived { background: #4a1a1a; }
|
|
34
|
+
.status-badge.timeout { background: #4a3a10; }
|
|
35
|
+
.status-badge.neutral { background: #21262d; }
|
|
36
|
+
.status-badge.equivalent { background: #21262d; }
|
|
37
|
+
.survived-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
38
|
+
.survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
39
|
+
.survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
|
|
40
|
+
.survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
|
|
41
|
+
.survived-header .operator { color: #f85149; font-weight: bold; }
|
|
42
|
+
.survived-header .location { color: #8b949e; font-family: monospace; }
|
|
43
|
+
.diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
|
|
44
|
+
.diff-removed { color: #f85149; display: block; }
|
|
45
|
+
.diff-added { color: #3fb950; display: block; }
|
|
46
|
+
.suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
|
|
47
|
+
.coverage-gap { border: 1px solid #30363d; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; background: #161b22; }
|
|
48
|
+
.gap-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; font-size: 0.85rem; padding-bottom: 0.5rem; border-bottom: 1px solid #21262d; }
|
|
49
|
+
.gap-header .location { color: #58a6ff; font-family: monospace; }
|
|
50
|
+
.gap-count { background: #4a1a1a; color: #f85149; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: bold; }
|
|
51
|
+
.operator-tag { background: #21262d; color: #8b949e; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 4px; font-family: monospace; }
|
|
52
|
+
.empty { color: #8b949e; text-align: center; padding: 2rem; }
|
|
53
|
+
.baseline-comparison { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
|
|
54
|
+
.baseline-comparison h2 { font-size: 1rem; color: #f0f6fc; margin-bottom: 0.75rem; }
|
|
55
|
+
.comparison-scores { display: flex; gap: 2rem; font-size: 0.9rem; }
|
|
56
|
+
.delta-positive { color: #3fb950; font-weight: bold; }
|
|
57
|
+
.delta-negative { color: #f85149; font-weight: bold; }
|
|
58
|
+
.delta-neutral { color: #8b949e; font-weight: bold; }
|
|
59
|
+
.error-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
60
|
+
.error-details h3 { color: #d29922; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
61
|
+
.error-entry { background: #1c1a10; border: 1px solid #4a3a10; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
|
|
62
|
+
.error-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
|
|
63
|
+
.error-header .operator { color: #d29922; font-weight: bold; }
|
|
64
|
+
.error-header .location { color: #8b949e; font-family: monospace; }
|
|
65
|
+
.error-message { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; color: #f85149; margin-top: 0.5rem; white-space: pre-wrap; overflow-x: auto; }
|
|
66
|
+
.survived-entry.regression { border-color: #f85149; background: #2a1010; }
|
|
67
|
+
.regression-badge { background: #da3633; color: #fff; font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 4px; margin-left: 0.5rem; text-transform: uppercase; font-weight: bold; }
|
|
68
|
+
footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "namespace"
|
|
4
|
+
|
|
5
|
+
class Evilution::Reporter::HTML::BaselineKeys
|
|
6
|
+
def initialize(baseline)
|
|
7
|
+
@keys = build(baseline)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def regression?(mutation)
|
|
11
|
+
return false if @keys.nil?
|
|
12
|
+
|
|
13
|
+
!@keys.include?(key_for(mutation))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def build(baseline)
|
|
19
|
+
return nil unless baseline
|
|
20
|
+
|
|
21
|
+
survived = baseline["survived"] || []
|
|
22
|
+
survived.to_set { |m| [m["operator"], m["file"], m["line"], m["subject"]] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def key_for(mutation)
|
|
26
|
+
[mutation.operator_name, mutation.file_path, mutation.line, mutation.subject.name]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "namespace"
|
|
4
|
+
require_relative "escape"
|
|
5
|
+
|
|
6
|
+
module Evilution::Reporter::HTML::DiffFormatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(diff)
|
|
10
|
+
diff.split("\n").map { |line| format_line(line) }.join("\n")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def format_line(line)
|
|
14
|
+
css_class = line_class(line)
|
|
15
|
+
%(<span class="#{css_class}">#{Evilution::Reporter::HTML::Escape.call(line)}</span>)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def line_class(line)
|
|
19
|
+
if line.start_with?("- ")
|
|
20
|
+
"diff-removed"
|
|
21
|
+
elsif line.start_with?("+ ")
|
|
22
|
+
"diff-added"
|
|
23
|
+
else
|
|
24
|
+
""
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../reporter"
|
|
4
|
+
|
|
5
|
+
# rubocop:disable Style/OneClassPerFile
|
|
6
|
+
class Evilution::Reporter::HTML # rubocop:disable Lint/EmptyClass
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Evilution::Reporter::HTML::Sections
|
|
10
|
+
end
|
|
11
|
+
# rubocop:enable Style/OneClassPerFile
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "section"
|
|
4
|
+
require_relative "stylesheet"
|
|
5
|
+
require_relative "baseline_keys"
|
|
6
|
+
require_relative "sections/header"
|
|
7
|
+
require_relative "sections/summary_cards"
|
|
8
|
+
require_relative "sections/baseline_comparison"
|
|
9
|
+
require_relative "sections/truncation_notice"
|
|
10
|
+
require_relative "sections/file_section"
|
|
11
|
+
|
|
12
|
+
class Evilution::Reporter::HTML::Report < Evilution::Reporter::HTML::Section
|
|
13
|
+
template "report"
|
|
14
|
+
|
|
15
|
+
def initialize(summary, baseline:, baseline_keys:, suggestion:)
|
|
16
|
+
@summary = summary
|
|
17
|
+
@baseline = baseline
|
|
18
|
+
@baseline_keys = baseline_keys
|
|
19
|
+
@suggestion = suggestion
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def stylesheet
|
|
25
|
+
Evilution::Reporter::HTML::Stylesheet.call
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def header
|
|
29
|
+
Evilution::Reporter::HTML::Sections::Header.new(@summary).render
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def summary_cards
|
|
33
|
+
Evilution::Reporter::HTML::Sections::SummaryCards.new(@summary).render
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def baseline_comparison
|
|
37
|
+
Evilution::Reporter::HTML::Sections::BaselineComparison.render_if(@baseline, @summary)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def truncation_notice
|
|
41
|
+
Evilution::Reporter::HTML::Sections::TruncationNotice.render_if(@summary)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def file_sections
|
|
45
|
+
files = group_by_file(@summary.results)
|
|
46
|
+
return '<p class="empty">No mutations generated.</p>' if files.empty?
|
|
47
|
+
|
|
48
|
+
files.map { |path, results| file_section(path, results) }.join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def file_section(path, results)
|
|
52
|
+
Evilution::Reporter::HTML::Sections::FileSection.new(
|
|
53
|
+
path, results,
|
|
54
|
+
suggestion: @suggestion,
|
|
55
|
+
baseline_keys: @baseline_keys
|
|
56
|
+
).render
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def group_by_file(results)
|
|
60
|
+
grouped = {}
|
|
61
|
+
results.each do |result|
|
|
62
|
+
path = result.mutation.file_path
|
|
63
|
+
grouped[path] ||= []
|
|
64
|
+
grouped[path] << result
|
|
65
|
+
end
|
|
66
|
+
grouped.sort_by { |path, _| path }.to_h
|
|
67
|
+
end
|
|
68
|
+
end
|