evilution 0.25.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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +15 -0
  3. data/.claude/prompts/architect.md +14 -1
  4. data/.claude/skills/create-issue/SKILL.md +55 -0
  5. data/.rubocop_todo.yml +7 -0
  6. data/CHANGELOG.md +38 -0
  7. data/README.md +57 -3
  8. data/lib/evilution/ast/constant_names.rb +34 -0
  9. data/lib/evilution/cache.rb +2 -0
  10. data/lib/evilution/child_output.rb +24 -0
  11. data/lib/evilution/cli/commands/run.rb +9 -0
  12. data/lib/evilution/cli/commands/version.rb +2 -0
  13. data/lib/evilution/cli/parser/options_builder.rb +16 -2
  14. data/lib/evilution/compare/invalid_input.rb +12 -0
  15. data/lib/evilution/compare.rb +1 -10
  16. data/lib/evilution/config/builders/spec_resolver.rb +15 -0
  17. data/lib/evilution/config/builders/spec_selector.rb +16 -0
  18. data/lib/evilution/config/builders.rb +4 -0
  19. data/lib/evilution/config/env_loader.rb +12 -0
  20. data/lib/evilution/config/file_loader.rb +22 -0
  21. data/lib/evilution/config/sources.rb +14 -0
  22. data/lib/evilution/config/validators/base.rb +37 -0
  23. data/lib/evilution/config/validators/example_targeting_cache.rb +37 -0
  24. data/lib/evilution/config/validators/example_targeting_fallback.rb +22 -0
  25. data/lib/evilution/config/validators/fail_fast.rb +11 -0
  26. data/lib/evilution/config/validators/hooks.rb +12 -0
  27. data/lib/evilution/config/validators/ignore_patterns.rb +16 -0
  28. data/lib/evilution/config/validators/integration.rb +11 -0
  29. data/lib/evilution/config/validators/isolation.rb +19 -0
  30. data/lib/evilution/config/validators/jobs.rb +9 -0
  31. data/lib/evilution/config/validators/preload.rb +13 -0
  32. data/lib/evilution/config/validators/spec_mappings.rb +56 -0
  33. data/lib/evilution/config/validators/spec_pattern.rb +12 -0
  34. data/lib/evilution/config/validators.rb +4 -0
  35. data/lib/evilution/config.rb +78 -268
  36. data/lib/evilution/feedback/detector.rb +15 -0
  37. data/lib/evilution/feedback/messages.rb +42 -0
  38. data/lib/evilution/feedback.rb +5 -0
  39. data/lib/evilution/integration/base.rb +4 -155
  40. data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
  41. data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
  42. data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
  43. data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
  44. data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
  45. data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
  46. data/lib/evilution/integration/loading.rb +6 -0
  47. data/lib/evilution/integration/rspec/baseline_runner.rb +16 -0
  48. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +17 -0
  49. data/lib/evilution/integration/rspec/example_filter_applier.rb +21 -0
  50. data/lib/evilution/integration/rspec/framework_loader.rb +28 -0
  51. data/lib/evilution/integration/rspec/result_builder.rb +40 -0
  52. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +28 -0
  53. data/lib/evilution/integration/rspec/state_guard/internals.rb +19 -0
  54. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +35 -0
  55. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +32 -0
  56. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +20 -0
  57. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +20 -0
  58. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +20 -0
  59. data/lib/evilution/integration/rspec/state_guard.rb +40 -0
  60. data/lib/evilution/integration/rspec/test_file_resolver.rb +30 -0
  61. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +18 -0
  62. data/lib/evilution/integration/rspec.rb +61 -232
  63. data/lib/evilution/isolation/fork.rb +7 -2
  64. data/lib/evilution/load_path/subpath_resolver.rb +25 -0
  65. data/lib/evilution/load_path.rb +4 -0
  66. data/lib/evilution/mcp/info_tool/actions/base.rb +22 -0
  67. data/lib/evilution/mcp/info_tool/actions/environment.rb +42 -0
  68. data/lib/evilution/mcp/info_tool/actions/feedback.rb +16 -0
  69. data/lib/evilution/mcp/info_tool/actions/statuses.rb +10 -0
  70. data/lib/evilution/mcp/info_tool/actions/subjects.rb +47 -0
  71. data/lib/evilution/mcp/info_tool/actions/tests.rb +60 -0
  72. data/lib/evilution/mcp/info_tool/actions.rb +16 -0
  73. data/lib/evilution/mcp/info_tool/config_factory.rb +24 -0
  74. data/lib/evilution/mcp/info_tool/error_mapper.rb +15 -0
  75. data/lib/evilution/mcp/info_tool/request_parser.rb +34 -0
  76. data/lib/evilution/mcp/info_tool/response_formatter.rb +24 -0
  77. data/lib/evilution/mcp/info_tool/status_glossary.rb +75 -0
  78. data/lib/evilution/mcp/info_tool.rb +43 -261
  79. data/lib/evilution/mcp/mutate_tool/error_payload.rb +8 -1
  80. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +13 -1
  81. data/lib/evilution/mcp/mutate_tool.rb +5 -2
  82. data/lib/evilution/mutator/operator/block_removal.rb +1 -1
  83. data/lib/evilution/mutator/operator/method_body_replacement.rb +18 -2
  84. data/lib/evilution/parallel/work_queue/channel/frame.rb +21 -0
  85. data/lib/evilution/parallel/work_queue/channel.rb +23 -0
  86. data/lib/evilution/parallel/work_queue/collection_state.rb +14 -0
  87. data/lib/evilution/parallel/work_queue/dispatcher.rb +133 -0
  88. data/lib/evilution/parallel/work_queue/validators/optional_positive_int.rb +11 -0
  89. data/lib/evilution/parallel/work_queue/validators/optional_positive_number.rb +11 -0
  90. data/lib/evilution/parallel/work_queue/validators/positive_int.rb +11 -0
  91. data/lib/evilution/parallel/work_queue/validators.rb +6 -0
  92. data/lib/evilution/parallel/work_queue/worker/loop.rb +45 -0
  93. data/lib/evilution/parallel/work_queue/worker.rb +114 -0
  94. data/lib/evilution/parallel/work_queue/worker_stat.rb +17 -0
  95. data/lib/evilution/parallel/work_queue.rb +42 -327
  96. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +18 -0
  97. data/lib/evilution/reporter/cli/item_formatters/disabled.rb +9 -0
  98. data/lib/evilution/reporter/cli/item_formatters/error.rb +14 -0
  99. data/lib/evilution/reporter/cli/item_formatters/result_location.rb +10 -0
  100. data/lib/evilution/reporter/cli/item_formatters.rb +6 -0
  101. data/lib/evilution/reporter/cli/line_formatters/duration.rb +9 -0
  102. data/lib/evilution/reporter/cli/line_formatters/efficiency.rb +18 -0
  103. data/lib/evilution/reporter/cli/line_formatters/feedback_footer.rb +13 -0
  104. data/lib/evilution/reporter/cli/line_formatters/header.rb +10 -0
  105. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +16 -0
  106. data/lib/evilution/reporter/cli/line_formatters/peak_memory.rb +12 -0
  107. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +20 -0
  108. data/lib/evilution/reporter/cli/line_formatters/score.rb +14 -0
  109. data/lib/evilution/reporter/cli/line_formatters/truncation_notice.rb +11 -0
  110. data/lib/evilution/reporter/cli/line_formatters.rb +6 -0
  111. data/lib/evilution/reporter/cli/metrics_block.rb +26 -0
  112. data/lib/evilution/reporter/cli/pct.rb +9 -0
  113. data/lib/evilution/reporter/cli/section.rb +13 -0
  114. data/lib/evilution/reporter/cli/section_renderer.rb +15 -0
  115. data/lib/evilution/reporter/cli/trailer.rb +22 -0
  116. data/lib/evilution/reporter/cli.rb +79 -162
  117. data/lib/evilution/runner/isolation_resolver.rb +20 -2
  118. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +32 -0
  119. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +16 -0
  120. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +46 -0
  121. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +75 -0
  122. data/lib/evilution/runner/mutation_executor/result_cache.rb +69 -0
  123. data/lib/evilution/runner/mutation_executor/result_notifier.rb +48 -0
  124. data/lib/evilution/runner/mutation_executor/result_packer.rb +39 -0
  125. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +80 -0
  126. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +34 -0
  127. data/lib/evilution/runner/mutation_executor.rb +58 -289
  128. data/lib/evilution/runner/subject_pipeline.rb +18 -8
  129. data/lib/evilution/runner.rb +21 -0
  130. data/lib/evilution/version.rb +1 -1
  131. metadata +125 -5
  132. data/lib/evilution/mcp/session_diff_tool.rb +0 -63
  133. data/lib/evilution/mcp/session_list_tool.rb +0 -50
  134. data/lib/evilution/mcp/session_show_tool.rb +0 -57
@@ -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, four actions: " \
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: %w[subjects tests environment statuses],
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 error_response("config_error", "action is required") unless action
127
- return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
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
- case action
132
- when "subjects"
133
- subjects_action(files: parsed_files, line_ranges: line_ranges, target: target,
134
- integration: integration, skip_config: skip_config)
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
- error_response_for(e)
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
- { error: payload }
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 }])