mutato 0.1.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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE +21 -0
  4. data/README.md +218 -0
  5. data/exe/mutato +21 -0
  6. data/lib/mutato/abort.rb +12 -0
  7. data/lib/mutato/annotations.rb +65 -0
  8. data/lib/mutato/arid.rb +104 -0
  9. data/lib/mutato/baseline.rb +51 -0
  10. data/lib/mutato/boot.rb +99 -0
  11. data/lib/mutato/campaign.rb +52 -0
  12. data/lib/mutato/change.rb +49 -0
  13. data/lib/mutato/child.rb +135 -0
  14. data/lib/mutato/cli.rb +138 -0
  15. data/lib/mutato/clock.rb +21 -0
  16. data/lib/mutato/config.rb +39 -0
  17. data/lib/mutato/console.rb +33 -0
  18. data/lib/mutato/coverage_listener/tally.rb +26 -0
  19. data/lib/mutato/coverage_listener.rb +46 -0
  20. data/lib/mutato/covering.rb +45 -0
  21. data/lib/mutato/diff.rb +42 -0
  22. data/lib/mutato/emitter.rb +46 -0
  23. data/lib/mutato/equivalence.rb +30 -0
  24. data/lib/mutato/experience.rb +11 -0
  25. data/lib/mutato/flags.rb +58 -0
  26. data/lib/mutato/generation.rb +36 -0
  27. data/lib/mutato/generator.rb +10 -0
  28. data/lib/mutato/genres/conditions.rb +70 -0
  29. data/lib/mutato/genres/elements.rb +49 -0
  30. data/lib/mutato/genres/genre.rb +23 -0
  31. data/lib/mutato/genres/operators.rb +81 -0
  32. data/lib/mutato/genres/statements.rb +122 -0
  33. data/lib/mutato/genres/swaps.rb +58 -0
  34. data/lib/mutato/genres/values.rb +41 -0
  35. data/lib/mutato/genres.rb +53 -0
  36. data/lib/mutato/group_hooks.rb +45 -0
  37. data/lib/mutato/hits.rb +18 -0
  38. data/lib/mutato/installer/header.rb +40 -0
  39. data/lib/mutato/installer.rb +133 -0
  40. data/lib/mutato/measure.rb +37 -0
  41. data/lib/mutato/mode/control.rb +43 -0
  42. data/lib/mutato/mode/run.rb +33 -0
  43. data/lib/mutato/mode.rb +26 -0
  44. data/lib/mutato/mutation.rb +85 -0
  45. data/lib/mutato/nodes.rb +150 -0
  46. data/lib/mutato/options.rb +33 -0
  47. data/lib/mutato/output.rb +76 -0
  48. data/lib/mutato/privates.rb +46 -0
  49. data/lib/mutato/report.rb +36 -0
  50. data/lib/mutato/rspec_adapter/progress.rb +20 -0
  51. data/lib/mutato/rspec_adapter/result.rb +15 -0
  52. data/lib/mutato/rspec_adapter.rb +129 -0
  53. data/lib/mutato/run.rb +44 -0
  54. data/lib/mutato/selection.rb +100 -0
  55. data/lib/mutato/session.rb +53 -0
  56. data/lib/mutato/skips.rb +54 -0
  57. data/lib/mutato/soft_timeout.rb +8 -0
  58. data/lib/mutato/subject.rb +21 -0
  59. data/lib/mutato/subjects/scope.rb +44 -0
  60. data/lib/mutato/subjects.rb +67 -0
  61. data/lib/mutato/suite.rb +6 -0
  62. data/lib/mutato/trial.rb +93 -0
  63. data/lib/mutato/usage.rb +41 -0
  64. data/lib/mutato/version.rb +6 -0
  65. data/lib/mutato/visitor/cursor.rb +28 -0
  66. data/lib/mutato/visitor/walk.rb +17 -0
  67. data/lib/mutato/visitor.rb +88 -0
  68. data/lib/mutato/watchdog.rb +54 -0
  69. data/lib/mutato.rb +55 -0
  70. metadata +128 -0
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutato
6
+ class Installer
7
+ # What a mutant needs from its file: the frozen-string comment and top-level `using`.
8
+ module Header
9
+ module_function
10
+
11
+ # The code's second line is the def's; the header goes above the first.
12
+ def evaluate(code, subject)
13
+ path = File.expand_path(subject.file)
14
+ head = of(path)
15
+ source = head + code
16
+ TOPLEVEL_BINDING.eval(source, path, subject.node.location.start_line - 1 - head.count("\n"))
17
+ end
18
+
19
+ def of(path)
20
+ result = Prism.parse_file(path)
21
+ "#{frozen_line(result)}#{usings(result)}"
22
+ end
23
+
24
+ def frozen_line(result)
25
+ frozen = result.magic_comments.any? { |comment| Header.frozen?(comment) }
26
+ "# frozen_string_literal: true\n" if frozen
27
+ end
28
+
29
+ # Ruby accepts dashes and any case in the key and the value.
30
+ def frozen?(comment)
31
+ comment.key.tr("-", "_").casecmp?("frozen_string_literal") && comment.value.casecmp?("true")
32
+ end
33
+
34
+ def usings(result)
35
+ calls = result.value.statements.body.select { |node| Nodes.using?(node) }
36
+ calls.sum("") { |node| "#{node.slice}; " }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installer/header"
4
+
5
+ module Mutato
6
+ # Wrapped in the original nesting, so constant lookup in the mutant still works.
7
+ class Installer
8
+ class Unviable < StandardError
9
+ end
10
+ public_constant :Unviable
11
+
12
+ # Whatever stops the mutated method from loading makes the mutant unviable.
13
+ def self.install(subject, def_source)
14
+ quietly { new(subject).install(def_source) }
15
+ rescue ScriptError, StandardError => error
16
+ raise Unviable, "#{error.class}: #{error.message.lines.first.to_s.strip}"
17
+ end
18
+
19
+ # The method as it is now, to tell if the tests replaced the installed one.
20
+ def self.current(subject)
21
+ new(subject).current
22
+ end
23
+
24
+ def self.methods_of(owner)
25
+ owner.instance_methods(false) + owner.private_instance_methods(false)
26
+ end
27
+
28
+ # Redefining warns under -w, which a suite failing on warnings turns into unviable mutants.
29
+ def self.quietly
30
+ verbose = $VERBOSE
31
+ $VERBOSE = nil
32
+ yield
33
+ ensure
34
+ $VERBOSE = verbose
35
+ end
36
+
37
+ def initialize(subject)
38
+ @subject = subject
39
+ @name = subject.name
40
+ end
41
+
42
+ def install(def_source)
43
+ owner = loaded_owner
44
+ visibility = visibility_of(owner)
45
+ twins = twins_of(owner)
46
+ Header.evaluate(wrap(def_source, visibility), @subject)
47
+ restore(owner, visibility, twins)
48
+ end
49
+
50
+ def current
51
+ owner = resolve_owner
52
+ owner.instance_method(@name) if defines?(owner)
53
+ end
54
+
55
+ def wrap(def_source, visibility)
56
+ "#{openers.join(" ")}\n#{prefix(visibility)}#{def_source}\n#{" end" * @subject.chain.size}"
57
+ end
58
+
59
+ private
60
+
61
+ def openers
62
+ @subject.chain.map { |kind, name| kind == :sclass ? "class << self;" : "#{kind} #{name};" }
63
+ end
64
+
65
+ # `private def` keeps a method_added hook from seeing a public def.
66
+ def prefix(visibility)
67
+ visibility == :public || @subject.singleton ? "" : "#{visibility} "
68
+ end
69
+
70
+ # A class a framework loads on first use is loaded from the file.
71
+ def loaded_owner
72
+ name = @subject.owner_name
73
+ require File.expand_path(@subject.file) unless name.empty? || Object.const_defined?(name)
74
+ resolve_owner
75
+ end
76
+
77
+ # Aliases of the old body, and module_function's singleton copy, must follow.
78
+ def twins_of(owner)
79
+ return [] unless defines?(owner)
80
+
81
+ target = owner.instance_method(@name)
82
+ Installer.methods_of(owner).select do |name|
83
+ name != @name && owner.instance_method(name) == target
84
+ end
85
+ end
86
+
87
+ def defines?(owner)
88
+ owner.method_defined?(@name) || owner.private_method_defined?(@name)
89
+ end
90
+
91
+ # The visibility methods are private, hence __send__.
92
+ def restore(owner, visibility, twins)
93
+ twins.each { |name| owner.alias_method(name, @name) }
94
+ owner.__send__(:module_function, @name) if visibility == :private && module_function?(owner)
95
+ owner.__send__(visibility, @name) if @subject.singleton && visibility != :public
96
+ owner.instance_method(@name)
97
+ end
98
+
99
+ def module_function?(owner)
100
+ owner.instance_of?(Module) && owner.singleton_methods(false).include?(@name)
101
+ end
102
+
103
+ def resolve_owner
104
+ receiver = @subject.receiver
105
+ owner = receiver ? lexical(receiver) : nesting_owner
106
+ return owner if !@subject.singleton || @subject.chain.last == [:sclass, nil]
107
+
108
+ owner.singleton_class
109
+ end
110
+
111
+ def nesting_owner
112
+ @subject.chain.reduce(Object) do |mod, (kind, name)|
113
+ kind == :sclass ? mod.singleton_class : mod.const_get(name)
114
+ end
115
+ end
116
+
117
+ # A constant as the code naming it sees it: from the innermost nesting outward.
118
+ def lexical(name)
119
+ nestings = @subject.chain.filter_map(&:last).reduce([Object]) do |found, part|
120
+ found << found.last.const_get(part)
121
+ end
122
+ nestings.reverse.find { |mod| mod.const_defined?(name) }
123
+ .const_get(name)
124
+ end
125
+
126
+ def visibility_of(owner)
127
+ return :private if owner.private_method_defined?(@name, false)
128
+ return :protected if owner.protected_method_defined?(@name, false)
129
+
130
+ :public
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ Measure = Data.define(:status, :line_map, :durations, :failed, :seen, :hooks) do
5
+ # A set: an array here would take a minute on a large suite.
6
+ def self.judged(ids, judges)
7
+ ids.select { |id| judges.include?(id) }
8
+ end
9
+
10
+ def summary(seconds)
11
+ format(
12
+ "baseline: status %<status>d, %<seconds>.2fs, %<lines>d covered lines, %<tests>d tests",
13
+ status:,
14
+ seconds:,
15
+ lines: line_map.size,
16
+ tests: durations.size
17
+ )
18
+ end
19
+
20
+ def all_failed?
21
+ durations.size == failed.size
22
+ end
23
+
24
+ # Only tests that ran and passed can judge a mutant; a group hook may credit others.
25
+ def baseline(**rest)
26
+ judges = (durations.keys - failed).to_set
27
+ Baseline.new(
28
+ line_map: line_map.transform_values { |ids| Measure.judged(ids, judges) },
29
+ durations:,
30
+ order: durations.each_key.with_index.to_h,
31
+ hooks:,
32
+ **rest
33
+ )
34
+ end
35
+ end
36
+ public_constant :Measure
37
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ module Mode
5
+ # A control that exercised nothing proved nothing: exit 3.
6
+ module Control
7
+ module_function
8
+
9
+ def todo(selection)
10
+ selection.controls
11
+ end
12
+
13
+ def exit_code(outcomes)
14
+ return 1 if Mode.failed_controls(outcomes).any?
15
+
16
+ outcomes.any? { |outcome| Mode.exercised?(outcome) } ? 0 : 3
17
+ end
18
+
19
+ def summary(outcomes)
20
+ bad = Mode.failed_controls(outcomes)
21
+ return verdict(outcomes) if bad.empty?
22
+
23
+ [
24
+ "control FAILED:",
25
+ *bad.map do |outcome|
26
+ " #{outcome.fetch(:outcome)} #{outcome.fetch(:label)}"
27
+ end
28
+ ]
29
+ end
30
+
31
+ def verdict(outcomes)
32
+ exercised = outcomes.select { |outcome| Mode.exercised?(outcome) }
33
+ if exercised.empty?
34
+ return ["control proved nothing: no reinstalled method was exercised by a test"]
35
+ end
36
+
37
+ ["control OK: #{exercised.size} of #{outcomes.size} reinstalled methods passed their tests"]
38
+ end
39
+
40
+ def annotate(_outcomes, _console, _format); end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ module Mode
5
+ module Run
6
+ module_function
7
+
8
+ def todo(selection)
9
+ selection.mutations
10
+ end
11
+
12
+ def exit_code(outcomes)
13
+ Mode.missed(outcomes).any? ? 2 : 0
14
+ end
15
+
16
+ def summary(outcomes)
17
+ lines = Mode.missed(outcomes).map do |outcome|
18
+ " #{outcome.fetch(:id)} #{outcome.fetch(:label)}#{flaky_note(outcome)}"
19
+ end
20
+ lines.empty? ? [] : ["\nMISSED:", *lines]
21
+ end
22
+
23
+ def flaky_note(outcome)
24
+ excluded = outcome.fetch(:flaky, [])
25
+ " (#{excluded.size} flaky tests excluded)" if excluded.any?
26
+ end
27
+
28
+ def annotate(outcomes, console, format)
29
+ Annotations.new(console).show(outcomes, format)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mode/control"
4
+ require_relative "mode/run"
5
+
6
+ module Mutato
7
+ module Mode
8
+ MISSED = %i[missed unjudged].freeze
9
+ PASSING_CONTROL = %i[missed uncovered load-time unloaded].freeze
10
+ private_constant :MISSED, :PASSING_CONTROL
11
+
12
+ module_function
13
+
14
+ def missed(outcomes)
15
+ outcomes.select { |outcome| MISSED.include?(outcome.fetch(:outcome)) }
16
+ end
17
+
18
+ def exercised?(outcome)
19
+ outcome.fetch(:examples).positive?
20
+ end
21
+
22
+ def failed_controls(outcomes)
23
+ outcomes.reject { |outcome| PASSING_CONTROL.include?(outcome.fetch(:outcome)) }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ Mutation = Data.define(
5
+ :id,
6
+ :file,
7
+ :subject,
8
+ :genre,
9
+ :line,
10
+ :col,
11
+ :start_line,
12
+ :end_line,
13
+ :outer_lines,
14
+ :offset,
15
+ :bytes,
16
+ :original,
17
+ :replacement,
18
+ :description
19
+ ) do
20
+ # The span starts below the def line, which runs at load; a one-liner's is empty: uncovered.
21
+ def self.control(subject)
22
+ location = subject.node.location
23
+ first = location.start_line
24
+ new(
25
+ id: "control-#{subject.qualified.tr("/", "_")}",
26
+ file: subject.file,
27
+ subject:,
28
+ genre: :control,
29
+ line: first,
30
+ col: 1,
31
+ start_line: first + 1,
32
+ end_line: location.end_line,
33
+ outer_lines: nil,
34
+ offset: location.start_offset,
35
+ bytes: 0,
36
+ original: "",
37
+ replacement: "",
38
+ description: "reinstall #{subject} unchanged"
39
+ )
40
+ end
41
+
42
+ def label
43
+ "#{file}:#{line}:#{col}: [#{genre}] #{description}"
44
+ end
45
+
46
+ alias_method :to_s, :label
47
+
48
+ def listing
49
+ "#{id} #{label} (#{subject})"
50
+ end
51
+
52
+ def span
53
+ start_line..end_line
54
+ end
55
+
56
+ def record(ids)
57
+ {
58
+ id:,
59
+ label:,
60
+ genre:,
61
+ file:,
62
+ line:,
63
+ col:,
64
+ description:,
65
+ examples: ids.size,
66
+ ids:,
67
+ seconds: 0.0
68
+ }
69
+ end
70
+
71
+ def apply(source)
72
+ mutated = source.b
73
+ mutated[offset, bytes] = replacement.b
74
+ mutated.force_encoding(source.encoding)
75
+ end
76
+
77
+ def mutated_source(file_source)
78
+ mutated = apply(file_source)
79
+ delta = replacement.bytesize - bytes
80
+ location = subject.node.location
81
+ mutated.b[location.start_offset, location.length + delta].force_encoding(file_source.encoding)
82
+ end
83
+ end
84
+ public_constant :Mutation
85
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutato
6
+ module Nodes
7
+ BLOCKS = [Prism::BlockNode, Prism::LambdaNode].freeze
8
+ CONDITIONALS = [Prism::IfNode, Prism::UnlessNode].freeze
9
+ HEREDOC_CAPABLE = [
10
+ Prism::StringNode,
11
+ Prism::InterpolatedStringNode,
12
+ Prism::XStringNode,
13
+ Prism::InterpolatedXStringNode
14
+ ].freeze
15
+ LITERALS = [
16
+ Prism::StringNode,
17
+ Prism::SymbolNode,
18
+ Prism::IntegerNode,
19
+ Prism::FloatNode,
20
+ Prism::NilNode,
21
+ Prism::TrueNode,
22
+ Prism::FalseNode
23
+ ].freeze
24
+ MEMO_GUARD = /\Areturn (@\w+) if \1\z/
25
+ public_constant :BLOCKS, :CONDITIONALS, :LITERALS
26
+ private_constant :HEREDOC_CAPABLE, :MEMO_GUARD
27
+
28
+ module_function
29
+
30
+ # Named, not anonymous: Ruby 3.3.0 rejects `&` forwarded from inside a block.
31
+ def each_node(node, &block)
32
+ return enum_for(:each_node, node) unless block
33
+
34
+ yield(node)
35
+ node.compact_child_nodes.each { |child| each_node(child, &block) }
36
+ end
37
+
38
+ def using?(node)
39
+ node.is_a?(Prism::CallNode) && node.name == :using && node.receiver.nil?
40
+ end
41
+
42
+ def one_of?(node, classes)
43
+ classes.any? { |klass| node.is_a?(klass) }
44
+ end
45
+
46
+ # The statements of a def body, looking through the begin a rescue adds.
47
+ def statements_of(body)
48
+ body = body.statements if body.is_a?(Prism::BeginNode)
49
+ body.body if body.is_a?(Prism::StatementsNode)
50
+ end
51
+
52
+ def else_less?(node)
53
+ node.is_a?(Prism::IfNode) ? node.subsequent.nil? : node.else_clause.nil?
54
+ end
55
+
56
+ def constant_receiver(definition)
57
+ receiver = definition.receiver
58
+ receiver.slice if receiver.is_a?(Prism::ConstantReadNode)
59
+ end
60
+
61
+ def singleton_receiver?(definition)
62
+ receiver = definition.receiver
63
+ receiver.is_a?(Prism::SelfNode) || receiver.is_a?(Prism::ConstantReadNode)
64
+ end
65
+
66
+ def foreign_receiver?(definition)
67
+ receiver = definition.receiver
68
+ receiver && !singleton_receiver?(definition)
69
+ end
70
+
71
+ # Lines inside blocks may run later than the method itself (define_method, Thread.new).
72
+ def block_lines(definition)
73
+ blocks = each_node(definition).select { |inner| one_of?(inner, BLOCKS) }
74
+ blocks.flat_map do |block|
75
+ location = block.location
76
+ ((location.start_line + 1)..location.end_line).to_a
77
+ end
78
+ end
79
+
80
+ # `x.size > 0`: a comparison a count can never fail.
81
+ def count_against_zero?(call)
82
+ receiver = call.receiver
83
+ argument = sole_argument(call)
84
+ receiver.is_a?(Prism::CallNode) && %i[length size count].include?(receiver.name) &&
85
+ argument.is_a?(Prism::IntegerNode) && argument.value.zero?
86
+ end
87
+
88
+ # `initialize` and setters: their caller never sees the value they return.
89
+ def value_dropped?(name)
90
+ name == :initialize || name.to_s.match?(/\A\w+=\z/)
91
+ end
92
+
93
+ # `return @x if @x`: deleting it, or making it never return, only skips a cache.
94
+ def memo_guard?(statement)
95
+ statement.slice.match?(MEMO_GUARD)
96
+ end
97
+
98
+ def nil_store?(call)
99
+ call.name == :[]= && arguments_of(call).last.is_a?(Prism::NilNode)
100
+ end
101
+
102
+ def nil_valued?(element)
103
+ value = element.is_a?(Prism::AssocNode) ? element.value : element
104
+ value.is_a?(Prism::NilNode) || value.is_a?(Prism::FalseNode)
105
+ end
106
+
107
+ def single_statement(conditional)
108
+ return unless conditional.statements&.body in [only]
109
+
110
+ only
111
+ end
112
+
113
+ def arguments_of(call)
114
+ call.arguments&.arguments || []
115
+ end
116
+
117
+ def sole_argument(call)
118
+ argument, = arguments_of(call)
119
+ argument
120
+ end
121
+
122
+ # `a + b`, `x < 1`: one receiver, one argument, no parentheses, the name spelled out.
123
+ def binary_operator?(call)
124
+ message = call.message_loc
125
+ call.receiver && message && call.opening_loc.nil? &&
126
+ arguments_of(call).size == 1 && message.slice == call.name.to_s
127
+ end
128
+
129
+ # A heredoc's body lies below its statement and goes with it; the terminator's newline stays.
130
+ def extent(node, source)
131
+ location = node.location
132
+ start = location.start_offset
133
+ stop = [location.end_offset, *heredoc_ends(node)].max
134
+ stop -= 1 if source.getbyte(stop - 1) == "\n".ord
135
+ [start, stop - start]
136
+ end
137
+
138
+ def heredoc_ends(node)
139
+ heredocs = each_node(node).select do |inner|
140
+ one_of?(inner, HEREDOC_CAPABLE) && inner.heredoc?
141
+ end
142
+ heredocs.map { |heredoc| heredoc.closing_loc.end_offset }
143
+ end
144
+
145
+ def compact(text)
146
+ joined = text.gsub(/\s*\\\n\s*/, " ").gsub(/\s+/, " ")
147
+ joined.sub(/\A(.{57}).{4,}\z/, "\\1...")
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Mutato
6
+ Options = Data.define(
7
+ :help,
8
+ :version,
9
+ :spec,
10
+ :config,
11
+ :diff,
12
+ :format,
13
+ :limit,
14
+ :sample,
15
+ :genres,
16
+ :only,
17
+ :out,
18
+ :timeout_min
19
+ ) do
20
+ def self.parse(argv)
21
+ Flags.parse(argv)
22
+ end
23
+
24
+ def spec_args
25
+ Shellwords.split(spec)
26
+ end
27
+
28
+ def genre?(genre)
29
+ genres.nil? || genres.include?(genre.to_s)
30
+ end
31
+ end
32
+ public_constant :Options
33
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Mutato
7
+ class Output
8
+ OUTCOMES = %w[
9
+ caught
10
+ missed
11
+ timeout
12
+ unjudged
13
+ equivalent
14
+ overwritten
15
+ unviable
16
+ crashed
17
+ uncovered
18
+ load-time
19
+ unloaded
20
+ ].freeze
21
+ LOG_CAP = 1_000_000
22
+ private_constant :OUTCOMES, :LOG_CAP
23
+
24
+ # Logs hold whatever the application printed; keep the last megabyte.
25
+ def self.cap(log)
26
+ size = File.size?(log)
27
+ return unless size && size > LOG_CAP
28
+
29
+ File.binwrite(log, "mutato: log truncated to its last #{LOG_CAP} bytes\n#{tail(log)}")
30
+ end
31
+
32
+ # Only the tail is read: a runaway mutant's log can be gigabytes.
33
+ def self.tail(log)
34
+ File.open(log, "rb") do |file|
35
+ file.seek(-LOG_CAP, IO::SEEK_END)
36
+ file.read
37
+ end
38
+ end
39
+
40
+ def self.listed(outcomes, name)
41
+ chosen = outcomes.select { |outcome| outcome.fetch(:outcome).to_s == name }
42
+ chosen.map { |outcome| "#{outcome.fetch(:id)} #{outcome.fetch(:label)}" }
43
+ end
44
+
45
+ # A run starts outcomes.jsonl afresh, as it rewrites the other lists.
46
+ def initialize(dir)
47
+ @dir = dir
48
+ FileUtils.mkdir_p(File.join(dir, "logs"))
49
+ File.write(File.join(dir, "outcomes.jsonl"), "")
50
+ end
51
+
52
+ def log_path(id)
53
+ File.join(@dir, "logs", "#{id}.log")
54
+ end
55
+
56
+ def append(outcome)
57
+ File.write(File.join(@dir, "outcomes.jsonl"), "#{JSON.generate(outcome)}\n", mode: "a")
58
+ end
59
+
60
+ def write(outcomes, flaky)
61
+ File.write(File.join(@dir, "outcomes.json"), JSON.pretty_generate(outcomes))
62
+ OUTCOMES.each { |name| write_list("#{name}.txt", Output.listed(outcomes, name)) }
63
+ write_list("flaky.txt", flaky)
64
+ end
65
+
66
+ private
67
+
68
+ def write_list(name, lines)
69
+ File.write(
70
+ File.join(@dir, name),
71
+ lines.map { |line| "#{line}\n" }
72
+ .join
73
+ )
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutato
6
+ # The private method names of one statement list: `private def x`, `private :x`, and the
7
+ # defs after a bare `private`, until `public` or `protected`.
8
+ module Privates
9
+ VISIBILITY = %i[private public protected].freeze
10
+ private_constant :VISIBILITY
11
+
12
+ module_function
13
+
14
+ def names(statements)
15
+ hidden = false
16
+ statements.body.each_with_object([]) do |statement, names|
17
+ hidden = section(statement, hidden)
18
+ names.concat(listed(statement))
19
+ names << statement.name if hidden && statement.is_a?(Prism::DefNode)
20
+ end
21
+ end
22
+
23
+ def section(statement, hidden)
24
+ return hidden unless visibility?(statement) && Nodes.arguments_of(statement).empty?
25
+
26
+ statement.name == :private
27
+ end
28
+
29
+ def listed(statement)
30
+ return [] unless visibility?(statement) && statement.name == :private
31
+
32
+ Nodes.arguments_of(statement).filter_map { |argument| Privates.name_of(argument) }
33
+ end
34
+
35
+ def name_of(argument)
36
+ return argument.name if argument.is_a?(Prism::DefNode)
37
+
38
+ argument.unescaped.to_sym if Nodes.one_of?(argument, [Prism::SymbolNode, Prism::StringNode])
39
+ end
40
+
41
+ def visibility?(statement)
42
+ statement.is_a?(Prism::CallNode) && statement.receiver.nil? &&
43
+ VISIBILITY.include?(statement.name)
44
+ end
45
+ end
46
+ end