kimera 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +18 -1
- data/lib/kimera/cli/report_file.rb +1 -1
- data/lib/kimera/cli/run/pass.rb +9 -2
- data/lib/kimera/execution/baseline_failure/breakdown.rb +35 -0
- data/lib/kimera/execution/baseline_failure.rb +18 -8
- data/lib/kimera/execution/baseline_pass.rb +11 -3
- data/lib/kimera/execution/harness.rb +2 -2
- data/lib/kimera/execution/isolated.rb +8 -0
- data/lib/kimera/execution/isolated_child.rb +7 -3
- data/lib/kimera/execution/isolated_child_command.rb +11 -4
- data/lib/kimera/execution/isolated_child_minitest.rb +11 -5
- data/lib/kimera/execution/isolated_outcome.rb +33 -6
- data/lib/kimera/execution/isolated_plan.rb +13 -5
- data/lib/kimera/execution/isolated_verdict.rb +14 -5
- data/lib/kimera/execution/kinship.rb +27 -0
- data/lib/kimera/execution/overlay_guard_modules.rb +17 -0
- data/lib/kimera/execution/overlay_guards.rb +6 -0
- data/lib/kimera/execution/parallel_test_databases.rb +11 -3
- data/lib/kimera/execution/pool_driver.rb +3 -3
- data/lib/kimera/execution/reflections.rb +35 -0
- data/lib/kimera/execution/reload.rb +33 -39
- data/lib/kimera/execution/reload_errand.rb +39 -0
- data/lib/kimera/execution/schedule.rb +17 -9
- data/lib/kimera/execution/worker_pool/worker.rb +5 -0
- data/lib/kimera/execution/worker_pool.rb +4 -2
- data/lib/kimera/frameworks/adapter.rb +5 -1
- data/lib/kimera/frameworks/minitest_adapter.rb +19 -0
- data/lib/kimera/incremental/session.rb +1 -1
- data/lib/kimera/memo_guard.rb +66 -0
- data/lib/kimera/registry/source_file.rb +6 -1
- data/lib/kimera/support/version.rb +1 -1
- data/skills/kimera/SKILL.md +3 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05af564bc168e88e752fd28c2c623d8fe03b3d02402472819930015ceaff5492
|
|
4
|
+
data.tar.gz: 1708453bd3e4fb16b5eb054be04e94ff4ab8dc06a2c6297adc257c8e23d5fb9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98620d29d7d69bdca3fc0bd51b51ee040e7a033627841e41e42860623cfd006d617805b817d428a2d688ca5df4c24ffde65aca0337985d7898ffae03b2f816c0
|
|
7
|
+
data.tar.gz: e509c0ff5416eb1cb41d8d933190d8e9d63249e010791e07ba5b0c0b0678d7e7d00aef6d2bee28dac103cfa4d8ffe6aea22b8515549b79a0bfe299f58df30d58
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
- Guard-style memoization is no longer mutated, just as `@x ||=` never was.
|
|
6
|
+
In a method that opens with `return @x if @x` (and assigns `@x` later) or
|
|
7
|
+
`return if @x` then `@x = true`, the guard gets no `condition` mutant and
|
|
8
|
+
the flag no `true => false`. Removing a memo only recomputes an equal value,
|
|
9
|
+
so those mutants always survived. A re-entrancy guard of the same shape is
|
|
10
|
+
skipped too.
|
|
11
|
+
- `--isolated` first runs the unmutated suite once in a fresh mirror. If it
|
|
12
|
+
isn't green there (for example, a test helper that won't load), the run
|
|
13
|
+
stops with exit 1 and the error, as a red warm baseline does. Before, every
|
|
14
|
+
mutant it covered was scored `killed` with no failing test named.
|
|
15
|
+
- An isolated mirror symlinks `vendor/`, `node_modules/` and `.bundle/`
|
|
16
|
+
instead of leaving them out. It creates `tmp/` and `log/` empty, and still
|
|
17
|
+
leaves out `.git/` and `coverage/`. A boot that needs frontend tooling or a
|
|
18
|
+
vendored bundle now loads in the mirror.
|
|
19
|
+
- An isolated kill caused by a test file failing to load now puts the load
|
|
20
|
+
error (Minitest: message and first backtrace lines; RSpec: its load-error
|
|
21
|
+
report) in the result's `detail`.
|
|
22
|
+
- Parallel warm workers follow Rails' fork contract more closely. Each sets
|
|
23
|
+
`ActiveSupport::TestCase.parallel_worker_id` to its slot, so per-worker
|
|
24
|
+
resources keyed on it (a Redis db, lease or key namespace, a port) no
|
|
25
|
+
longer collide between workers. `parallelize_before_fork` hooks run once per
|
|
26
|
+
fleet, not again before every spawn (including replacement workers forked
|
|
27
|
+
while other workers are mid-test). `parallelize_teardown` hooks get the
|
|
28
|
+
worker number.
|
|
29
|
+
- A red parallel baseline lists, for each worker that ran a failing test,
|
|
30
|
+
where each failure fell in that worker's run and which tests ran just
|
|
31
|
+
before it. A test lost when its worker hit the hard timeout or crashed now
|
|
32
|
+
says so, instead of showing up as an unexplained failure.
|
|
33
|
+
|
|
34
|
+
## 0.1.3 (2026-09-27)
|
|
35
|
+
|
|
36
|
+
- `--isolated` no longer scores a child that crashes before reporting results
|
|
37
|
+
as a kill. It is `harness_error`, and its `detail` gives the exit status and
|
|
38
|
+
the ends of the child's stderr. A Minitest child that fails to load a test
|
|
39
|
+
file still reports that as a kill, as the RSpec child already did.
|
|
40
|
+
- `--isolated` passes test ids and test files to the child in a request file
|
|
41
|
+
instead of argv, so a mutant covered by thousands of tests no longer fails
|
|
42
|
+
with `Errno::E2BIG: Argument list too long`.
|
|
43
|
+
- Warm reload-path mutants (for example, statements in a memoized method) run
|
|
44
|
+
one test at a time and stop at the first failure. Tests covering the
|
|
45
|
+
mutant's method run first. The hard timeout now applies to each test, not
|
|
46
|
+
the whole suite, so a mutant a test kills is no longer reported as
|
|
47
|
+
"reload worker produced no result". A test that overruns the timeout is a
|
|
48
|
+
`timeout`, and a worker that dies names its exit status or signal.
|
|
49
|
+
- A scope or association declared in a concern's `included do` block is now
|
|
50
|
+
instrumented in the classes that already included the concern. It used to be
|
|
51
|
+
reported `no_coverage`, or `survived` when it was covered through another
|
|
52
|
+
path.
|
|
53
|
+
- An association overlaid on a parent model now also reaches subclasses that
|
|
54
|
+
declared associations of their own (such as STI children). They used to keep
|
|
55
|
+
the unguarded reflection, so mutants in the association's scope lambda
|
|
56
|
+
survived.
|
|
57
|
+
- The red-baseline hint prints a plain Minitest command for a Minitest suite
|
|
58
|
+
instead of `rspec …`.
|
|
59
|
+
- `kimera report`/`kimera mutant` and incremental sessions read report JSON as
|
|
60
|
+
UTF-8, so they no longer crash under a non-UTF-8 locale when a report
|
|
61
|
+
contains non-ASCII text.
|
|
62
|
+
|
|
3
63
|
## 0.1.2 (2026-09-26)
|
|
4
64
|
|
|
5
65
|
- Kimera sets `KIMERA=1` in its own process before loading the suite, and in
|
data/README.md
CHANGED
|
@@ -220,7 +220,10 @@ selector and Kimera's own harness-critical files (see below).
|
|
|
220
220
|
|
|
221
221
|
An isolated child is judged on its failed tests, not its exit status. A child
|
|
222
222
|
that exits non-zero with no failing test is `harness_error` (unjudged, gated
|
|
223
|
-
by `--max-errors`), not a kill.
|
|
223
|
+
by `--max-errors`), not a kill. So is a child that dies before reporting any
|
|
224
|
+
results (a boot error, an exit hook, a crash); its `detail` carries the
|
|
225
|
+
child's exit status and the ends of its stderr. The usual cause of a non-zero
|
|
226
|
+
exit with no failures is a coverage floor such as
|
|
224
227
|
SimpleCov's `minimum_coverage`, which a partial run always trips. Kimera sets
|
|
225
228
|
`KIMERA=1` wherever it loads your suite (the warm process, `--isolated`
|
|
226
229
|
children, and `kimera doctor`'s test commands), so skip the floor there:
|
|
@@ -231,6 +234,13 @@ SimpleCov.start { minimum_coverage(line: 100, branch: 100) unless ENV["KIMERA"]
|
|
|
231
234
|
|
|
232
235
|
`kimera doctor` warns when it finds an ungated `minimum_coverage`.
|
|
233
236
|
|
|
237
|
+
Each isolated mirror is a copy of the project root with some exceptions:
|
|
238
|
+
`.git/` and `coverage/` are left out, `tmp/` and `log/` start empty, and
|
|
239
|
+
`vendor/`, `node_modules/` and `.bundle/` are symlinked to the originals. The
|
|
240
|
+
exception is a directory that holds a mutated file, which is always copied.
|
|
241
|
+
Before judging any mutant, `--isolated` runs the unmutated suite once in a
|
|
242
|
+
mirror. If it isn't green there, the run stops with exit 1 and the error.
|
|
243
|
+
|
|
234
244
|
### Progress output
|
|
235
245
|
|
|
236
246
|
During a run, a progress bar tracks each phase on stderr. It shows only when
|
|
@@ -303,6 +313,13 @@ The *default* set is small and low-noise:
|
|
|
303
313
|
- `!x => x`
|
|
304
314
|
- forced `if` conditions
|
|
305
315
|
|
|
316
|
+
Memoization is not a mutation target. Dropping a memo only recomputes an equal
|
|
317
|
+
value, so that mutant would always survive. `@x ||= …` has no such mutant, and
|
|
318
|
+
neither does a guard that opens a method: `return @x if defined?(@x)`,
|
|
319
|
+
`return @x if @x` (with `@x` assigned later in the method), or `return if @x`
|
|
320
|
+
followed by `@x = true`. Everything the method computes is still mutated. A
|
|
321
|
+
re-entrancy guard with the run-once shape is skipped too.
|
|
322
|
+
|
|
306
323
|
The extended families are one flag away. Use `--operators all`, or pick from:
|
|
307
324
|
|
|
308
325
|
| family | mutation |
|
|
@@ -13,7 +13,7 @@ module Kimera::CLI::ReportFile
|
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
15
|
def parse(path)
|
|
16
|
-
JSON.parse(File.read(path))
|
|
16
|
+
JSON.parse(File.read(path, encoding: Encoding::UTF_8))
|
|
17
17
|
rescue JSON::ParserError => error
|
|
18
18
|
raise(Kimera::UsageError, "unreadable report #{path}: #{error.message}")
|
|
19
19
|
end
|
data/lib/kimera/cli/run/pass.rb
CHANGED
|
@@ -63,7 +63,7 @@ class Kimera::CLI::Run::Pass
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def run(remaining)
|
|
66
|
-
isolated = ->(ids) {
|
|
66
|
+
isolated = ->(ids) { judge(ids) }
|
|
67
67
|
return isolated.call(remaining) if @options[:isolated]
|
|
68
68
|
pooled(remaining, isolated)
|
|
69
69
|
end
|
|
@@ -74,8 +74,13 @@ class Kimera::CLI::Run::Pass
|
|
|
74
74
|
harness.run(ids: together, label: "mutants (warm)").merge(isolated.call(apart))
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
def judge(ids)
|
|
78
|
+
isolation.verify!
|
|
79
|
+
isolation.run(ids: ids, label: "mutants (isolated)")
|
|
80
|
+
end
|
|
81
|
+
|
|
77
82
|
def isolation
|
|
78
|
-
Kimera::Execution::IsolatedExecution.new(**core, **
|
|
83
|
+
@_isolation ||= Kimera::Execution::IsolatedExecution.new(**core, **limits)
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
def core
|
|
@@ -84,6 +89,8 @@ class Kimera::CLI::Run::Pass
|
|
|
84
89
|
.merge(framework: @options[:framework], test_files: files)
|
|
85
90
|
end
|
|
86
91
|
|
|
92
|
+
def limits = { hard_timeout: @options[:hard_timeout] }.compact
|
|
93
|
+
|
|
87
94
|
def split(ids)
|
|
88
95
|
patterns = Array(@options[:isolate_when_covered_by])
|
|
89
96
|
ids.partition do |id|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Kimera::Execution::BaselineFailure::Breakdown
|
|
4
|
+
MAX_FAILURES = 5
|
|
5
|
+
PRECEDING = 3
|
|
6
|
+
|
|
7
|
+
def initialize(failed, workers)
|
|
8
|
+
@failed = failed
|
|
9
|
+
@workers = workers
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_s
|
|
13
|
+
lines = @workers.sort_by(&:first).filter_map { |slot, ids| worker(slot, ids) }
|
|
14
|
+
lines.empty? ? "" : "\n per worker (tests in the order it ran them):\n#{lines.join("\n")}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def worker(slot, ids)
|
|
20
|
+
hits = ids.each_index.select { |index| @failed.include?(ids[index]) }
|
|
21
|
+
return if hits.empty?
|
|
22
|
+
" worker #{slot}: ran #{ids.size}; failed #{failures(ids, hits)}#{preceding(ids, hits.first)}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def failures(ids, hits)
|
|
26
|
+
shown = hits.first(MAX_FAILURES).map { |index| "##{index + 1} #{ids[index]}" }
|
|
27
|
+
extra = hits.size - shown.size
|
|
28
|
+
shown.join(", ") + (extra.positive? ? " (+#{extra} more)" : "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def preceding(ids, first)
|
|
32
|
+
before = ids[[first - PRECEDING, 0].max...first]
|
|
33
|
+
before.empty? ? " (first test on this worker)" : "; just before: #{before.join(", ")}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -10,16 +10,28 @@ end
|
|
|
10
10
|
class Kimera::Execution::BaselineFailure < Kimera::Error
|
|
11
11
|
MAX_DETAILS = 3
|
|
12
12
|
MAX_MESSAGE = 300
|
|
13
|
+
LOSSES = { timeout: "its worker was killed at the hard timeout (--hard-timeout) before reporting a result" }.freeze
|
|
14
|
+
CRASH = "its worker died before reporting a result"
|
|
13
15
|
|
|
14
16
|
class << self
|
|
15
|
-
def build(failed, messages)
|
|
16
|
-
new(summary(failed, messages))
|
|
17
|
+
def build(failed, messages, command, workers: {})
|
|
18
|
+
new(summary(failed, messages, command, workers: workers))
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def
|
|
21
|
+
def lost(reason) = LOSSES.fetch(reason, CRASH)
|
|
22
|
+
|
|
23
|
+
def mirrored(reason, hint)
|
|
24
|
+
new(
|
|
25
|
+
"isolated baseline is not green: the unmutated suite fails in a mirror of the project\n " \
|
|
26
|
+
"#{reason.gsub("\n", "\n ")}\n#{hint}"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def summary(failed, messages, command, workers: {})
|
|
20
31
|
text = "baseline suite is not green: #{failed.join(", ")}"
|
|
21
32
|
text += appendix(failed, messages)
|
|
22
|
-
|
|
33
|
+
text += Kimera::Execution::BaselineFailure::Breakdown.new(failed, workers).to_s
|
|
34
|
+
"#{text}\n reproduce without kimera: #{command}"
|
|
23
35
|
end
|
|
24
36
|
|
|
25
37
|
def appendix(failed, messages)
|
|
@@ -39,9 +51,7 @@ class Kimera::Execution::BaselineFailure < Kimera::Error
|
|
|
39
51
|
def truncate(text)
|
|
40
52
|
text.length > MAX_MESSAGE ? "#{text[0, MAX_MESSAGE]}…" : text
|
|
41
53
|
end
|
|
42
|
-
|
|
43
|
-
def reproduce(failed)
|
|
44
|
-
" reproduce without kimera: rspec #{failed.first(MAX_DETAILS).join(" ")} --order defined"
|
|
45
|
-
end
|
|
46
54
|
end
|
|
47
55
|
end
|
|
56
|
+
|
|
57
|
+
require_relative "baseline_failure/breakdown"
|
|
@@ -71,7 +71,13 @@ class Kimera::Execution::BaselinePass
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def dispatch
|
|
74
|
-
yield(resolve: resolve, lost: loss)
|
|
74
|
+
yield(resolve: resolve, lost: loss, trace: tracer)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def journal = @_journal ||= Hash.new { |workers, slot| workers[slot] = [] }
|
|
78
|
+
|
|
79
|
+
def tracer
|
|
80
|
+
->(slot, test_id) { journal[slot] << test_id }
|
|
75
81
|
end
|
|
76
82
|
|
|
77
83
|
def resolve
|
|
@@ -90,7 +96,8 @@ class Kimera::Execution::BaselinePass
|
|
|
90
96
|
method(:lost)
|
|
91
97
|
end
|
|
92
98
|
|
|
93
|
-
def lost(test_id,
|
|
99
|
+
def lost(test_id, reason)
|
|
100
|
+
@messages[test_id] = Kimera::Execution::BaselineFailure.lost(reason)
|
|
94
101
|
tally.failures << test_id
|
|
95
102
|
@progress.tick
|
|
96
103
|
end
|
|
@@ -118,6 +125,7 @@ class Kimera::Execution::BaselinePass
|
|
|
118
125
|
end
|
|
119
126
|
|
|
120
127
|
def failure!(failed)
|
|
121
|
-
|
|
128
|
+
command = @adapter.reproduce(failed.first(Kimera::Execution::BaselineFailure::MAX_DETAILS))
|
|
129
|
+
raise(Kimera::Execution::BaselineFailure.build(failed, @messages, command, workers: journal))
|
|
122
130
|
end
|
|
123
131
|
end
|
|
@@ -126,8 +126,8 @@ class Kimera::Execution::Harness
|
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
def parallel(pass)
|
|
129
|
-
pass.parallel! do |resolve:, lost:|
|
|
130
|
-
driver.drive(adapter.test_ids, driver.method(:channel), resolve: resolve, lost: lost)
|
|
129
|
+
pass.parallel! do |resolve:, lost:, trace:|
|
|
130
|
+
driver.drive(adapter.test_ids, driver.method(:channel), resolve: resolve, lost: lost, trace: trace)
|
|
131
131
|
end
|
|
132
132
|
end
|
|
133
133
|
|
|
@@ -5,6 +5,7 @@ require "tmpdir"
|
|
|
5
5
|
require_relative "../results/result"
|
|
6
6
|
require_relative "../results/run_report"
|
|
7
7
|
require_relative "../synthesis/overlay"
|
|
8
|
+
require_relative "baseline_failure"
|
|
8
9
|
require_relative "isolated_plan"
|
|
9
10
|
require_relative "isolated_scheduling"
|
|
10
11
|
require_relative "isolated_verdict"
|
|
@@ -30,6 +31,13 @@ class Kimera::Execution::IsolatedExecution
|
|
|
30
31
|
@options = options
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
def verify!
|
|
35
|
+
errors.puts("kimera: isolated baseline (unmutated mirror)") unless progress.enabled?
|
|
36
|
+
outcome = with_mirror { |mirror| verdict(mirror, plan.suite) }
|
|
37
|
+
return if outcome.status == :survived
|
|
38
|
+
raise(Kimera::Execution::BaselineFailure.mirrored(outcome.explain(limit), Kimera::Execution::IsolatedPlan::MIRROR_HINT))
|
|
39
|
+
end
|
|
40
|
+
|
|
33
41
|
def run(ids: self.ids, label: "mutants")
|
|
34
42
|
progress.start(ids.size, label)
|
|
35
43
|
report(ids)
|
|
@@ -9,15 +9,19 @@ begin
|
|
|
9
9
|
rescue StandardError, ScriptError
|
|
10
10
|
nil
|
|
11
11
|
end
|
|
12
|
-
|
|
12
|
+
output = StringIO.new
|
|
13
|
+
RSpec.configuration.output_stream = output
|
|
13
14
|
RSpec.configuration.error_stream = StringIO.new
|
|
14
15
|
RSpec.configuration.deprecation_stream = StringIO.new
|
|
15
16
|
|
|
16
|
-
ledger,
|
|
17
|
+
ledger, request = ARGV
|
|
18
|
+
locations = JSON.parse(File.read(request, encoding: Encoding::UTF_8)).fetch("tests")
|
|
17
19
|
code = RSpec::Core::Runner.run(locations)
|
|
18
20
|
|
|
19
21
|
failed = RSpec.world.all_examples.select { |example| example.execution_result.status == :failed }
|
|
20
22
|
outside = RSpec.world.non_example_failure ? 1 : 0
|
|
21
|
-
|
|
23
|
+
report = { failures: failed.size + outside, failing: failed.map(&:id) }
|
|
24
|
+
report[:load_error] = output.string.strip[0, 4000] if outside.positive?
|
|
25
|
+
File.write(ledger, JSON.generate(report))
|
|
22
26
|
|
|
23
27
|
exit code
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require_relative "suite_env"
|
|
4
5
|
|
|
5
6
|
module Kimera
|
|
@@ -19,19 +20,25 @@ class Kimera::Execution::IsolatedPlan::ChildCommand
|
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def command(mirror, locations, ledger:, paths:)
|
|
23
|
+
File.write(request_path(ledger), JSON.generate(request(locations)))
|
|
22
24
|
includes = paths.flat_map { |path| ["-I", path] }
|
|
23
|
-
bundled(mirror, Kimera::Execution::SUITE_ENV.dup, ["ruby", *includes, "-I", helpers, *argv(
|
|
25
|
+
bundled(mirror, Kimera::Execution::SUITE_ENV.dup, ["ruby", *includes, "-I", helpers, *argv(ledger)])
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
def argv(
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
def argv(ledger)
|
|
29
|
+
[minitest? ? MINITEST_CHILD : CHILD, ledger, request_path(ledger)]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def request(locations)
|
|
33
|
+
{ "tests" => locations, "files" => @test_files }
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def helpers
|
|
32
37
|
minitest? ? "test" : "spec"
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
def request_path(ledger) = "#{ledger}.request"
|
|
41
|
+
|
|
35
42
|
private
|
|
36
43
|
|
|
37
44
|
def bundled(mirror, env, argv)
|
|
@@ -12,13 +12,19 @@ rescue NameError, ArgumentError
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
ledger,
|
|
16
|
-
|
|
17
|
-
files =
|
|
18
|
-
ids =
|
|
15
|
+
ledger, request = ARGV
|
|
16
|
+
request = JSON.parse(File.read(request, encoding: Encoding::UTF_8))
|
|
17
|
+
files = request.fetch("files")
|
|
18
|
+
ids = request.fetch("tests")
|
|
19
19
|
ARGV.clear
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
begin
|
|
22
|
+
files.each { |f| require File.expand_path(f) }
|
|
23
|
+
rescue StandardError, ScriptError => error
|
|
24
|
+
cause = ["#{error.class}: #{error.message}", *Array(error.backtrace).first(5).map { |line| " #{line}" }]
|
|
25
|
+
File.write(ledger, JSON.generate(failures: 1, failing: [], load_error: cause.join("\n")))
|
|
26
|
+
exit(1)
|
|
27
|
+
end
|
|
22
28
|
|
|
23
29
|
methods = {}
|
|
24
30
|
Minitest::Runnable.runnables.each do |runnable|
|
|
@@ -8,6 +8,9 @@ module Kimera
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
class Kimera::Execution::IsolatedOutcome
|
|
11
|
+
HEAD_LINES = 5
|
|
12
|
+
TAIL_LINES = 15
|
|
13
|
+
|
|
11
14
|
attr_reader :status, :failing, :detail
|
|
12
15
|
|
|
13
16
|
def initialize(status, failing = nil, detail = nil)
|
|
@@ -16,32 +19,56 @@ class Kimera::Execution::IsolatedOutcome
|
|
|
16
19
|
@detail = detail
|
|
17
20
|
end
|
|
18
21
|
|
|
22
|
+
def explain(limit)
|
|
23
|
+
return "timed out after #{limit}s" if status == :timeout
|
|
24
|
+
named = Array(failing)
|
|
25
|
+
return "failing: #{named.join(", ")}" if named.any?
|
|
26
|
+
detail || "the test child failed without naming a test"
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
class << self
|
|
20
|
-
def judge(exit, path)
|
|
30
|
+
def judge(exit, path, stderr = "")
|
|
21
31
|
return new(:timeout) if exit == :timeout
|
|
22
32
|
ledger = read(path)
|
|
23
|
-
return unreported(exit) unless ledger
|
|
33
|
+
return unreported(exit, stderr) unless ledger
|
|
24
34
|
tally(exit, ledger)
|
|
25
35
|
end
|
|
26
36
|
|
|
27
37
|
private
|
|
28
38
|
|
|
29
|
-
def unreported(exit
|
|
39
|
+
def unreported(exit, stderr)
|
|
40
|
+
new(:harness_error, nil, "test child #{status_of(exit)} without reporting results#{tail(stderr)}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def tail(stderr)
|
|
44
|
+
lines = stderr.to_s.strip.lines(chomp: true)
|
|
45
|
+
lines.empty? ? "" : ":\n#{ends(lines).join("\n")}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ends(lines)
|
|
49
|
+
return lines if lines.size <= HEAD_LINES + TAIL_LINES
|
|
50
|
+
[*lines.first(HEAD_LINES), "…", *lines.last(TAIL_LINES)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def status_of(exit)
|
|
54
|
+
code = exit.exitstatus
|
|
55
|
+
code ? "exited #{code}" : "died on signal #{exit.termsig}"
|
|
56
|
+
end
|
|
30
57
|
|
|
31
58
|
def tally(exit, ledger)
|
|
32
59
|
failing = Array(ledger["failing"])
|
|
33
|
-
return new(:killed, failing) if ledger["failures"].positive?
|
|
60
|
+
return new(:killed, failing, ledger["load_error"]) if ledger["failures"].positive?
|
|
34
61
|
return new(:survived) if exit.success?
|
|
35
62
|
new(:harness_error, nil, unexplained(exit))
|
|
36
63
|
end
|
|
37
64
|
|
|
38
65
|
def unexplained(exit)
|
|
39
|
-
"suite
|
|
66
|
+
"suite #{status_of(exit)} with 0 failures " \
|
|
40
67
|
"(a coverage floor or exit hook? skip it when ENV[\"KIMERA\"] is set)"
|
|
41
68
|
end
|
|
42
69
|
|
|
43
70
|
def read(path)
|
|
44
|
-
ledger = JSON.parse(File.read(path))
|
|
71
|
+
ledger = JSON.parse(File.read(path, encoding: Encoding::UTF_8))
|
|
45
72
|
ledger if ledger.is_a?(Hash) && ledger["failures"].is_a?(Integer)
|
|
46
73
|
rescue Errno::ENOENT, JSON::ParserError
|
|
47
74
|
nil
|
|
@@ -10,7 +10,12 @@ module Kimera
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
class Kimera::Execution::IsolatedPlan
|
|
13
|
-
MIRROR_SKIP = %w[.git
|
|
13
|
+
MIRROR_SKIP = %w[.git coverage].freeze
|
|
14
|
+
MIRROR_EMPTY = %w[tmp log].freeze
|
|
15
|
+
MIRROR_LINK = %w[vendor node_modules .bundle].freeze
|
|
16
|
+
MIRROR_HINT =
|
|
17
|
+
" the mirror copies the project except #{MIRROR_SKIP.join(", ")} " \
|
|
18
|
+
"(#{MIRROR_EMPTY.join(", ")} start empty; #{MIRROR_LINK.join(", ")} are symlinked)".freeze
|
|
14
19
|
|
|
15
20
|
attr_reader :registry
|
|
16
21
|
|
|
@@ -34,14 +39,17 @@ class Kimera::Execution::IsolatedPlan
|
|
|
34
39
|
child.command(mirror, locations, ledger: ledger, paths: mutables)
|
|
35
40
|
end
|
|
36
41
|
|
|
42
|
+
def suite = selection.all
|
|
43
|
+
|
|
37
44
|
def mutables
|
|
38
45
|
@registry.files.map { |f| f.split(File::SEPARATOR).first }.uniq
|
|
39
46
|
end
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
def placement(entry)
|
|
49
|
+
return :copy if mutables.include?(entry)
|
|
50
|
+
return :skip if MIRROR_SKIP.include?(entry)
|
|
51
|
+
return :empty if MIRROR_EMPTY.include?(entry)
|
|
52
|
+
MIRROR_LINK.include?(entry) ? :link : :copy
|
|
45
53
|
end
|
|
46
54
|
|
|
47
55
|
private
|
|
@@ -38,13 +38,16 @@ module Kimera
|
|
|
38
38
|
def verdict(mirror, locations)
|
|
39
39
|
Dir.mktmpdir("kimera-ledger") do |dir|
|
|
40
40
|
ledger = File.join(dir, "ledger.json")
|
|
41
|
-
|
|
41
|
+
stderr = File.join(dir, "stderr.log")
|
|
42
|
+
IsolatedOutcome.judge(launch(mirror, locations, ledger, stderr), ledger, captured(stderr))
|
|
42
43
|
end
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def
|
|
46
|
+
def captured(path) = File.read(path, encoding: Encoding::UTF_8).scrub
|
|
47
|
+
|
|
48
|
+
def launch(mirror, locations, ledger, stderr)
|
|
46
49
|
env, cmd = plan.command(mirror, locations, ledger)
|
|
47
|
-
waitfor(Process.spawn(env, *cmd, chdir: mirror, pgroup: true, out: File::NULL, err:
|
|
50
|
+
waitfor(Process.spawn(env, *cmd, chdir: mirror, pgroup: true, out: File::NULL, err: stderr))
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
def with_mirror
|
|
@@ -56,8 +59,14 @@ module Kimera
|
|
|
56
59
|
|
|
57
60
|
def populate(mirror)
|
|
58
61
|
root = plan.root
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
Dir.children(root).each { |entry| place(File.join(root, entry), File.join(mirror, entry), entry) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def place(source, target, entry)
|
|
66
|
+
case plan.placement(entry)
|
|
67
|
+
when :copy then FileUtils.cp_r(source, target)
|
|
68
|
+
when :link then File.symlink(source, target)
|
|
69
|
+
when :empty then FileUtils.mkdir_p(target) if File.directory?(source)
|
|
61
70
|
end
|
|
62
71
|
end
|
|
63
72
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../registry/mutation_point"
|
|
4
|
+
|
|
5
|
+
module Kimera
|
|
6
|
+
module Execution
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Kimera::Execution::Kinship
|
|
11
|
+
def initialize(registry, coverage)
|
|
12
|
+
@registry = registry
|
|
13
|
+
@coverage = coverage
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def order(id, tests)
|
|
17
|
+
(related(@registry.index.fetch(id, Kimera::MutationPoint::NONE)) & tests) | tests
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def related(point)
|
|
23
|
+
siblings = @registry.at(point.file).sort_by { |other| other.method_name == point.method_name ? 0 : 1 }
|
|
24
|
+
known = @coverage.to_h
|
|
25
|
+
siblings.flat_map { |other| other.ids.flat_map { |mutant| Array(known[mutant]) } }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "callback"
|
|
4
|
+
require_relative "reflections"
|
|
4
5
|
|
|
5
6
|
module Kimera
|
|
6
7
|
module Execution
|
|
@@ -56,14 +57,30 @@ module Kimera
|
|
|
56
57
|
define_method(hook) do |base = nil, &block|
|
|
57
58
|
next super(base, &block) unless block && OverlayGuardModules.replacing?(self, stored, base)
|
|
58
59
|
instance_variable_set(stored, block)
|
|
60
|
+
OverlayGuardModules.reapply(self, block)
|
|
59
61
|
end
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
def serialization = SERIALIZEGUARD
|
|
64
66
|
|
|
67
|
+
def reflection = Reflections::GUARD
|
|
68
|
+
|
|
69
|
+
MIXES = Module.instance_method(:include?)
|
|
70
|
+
|
|
65
71
|
def concern = CONCERNGUARD
|
|
66
72
|
|
|
73
|
+
def reapply(concern, block)
|
|
74
|
+
ObjectSpace.each_object(Class).each { |klass| klass.class_eval(&block) if owner?(klass, concern) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def owner?(klass, concern)
|
|
78
|
+
return false if klass.singleton_class? || !mixes?(klass, concern)
|
|
79
|
+
[klass.superclass].compact.none? { |parent| mixes?(parent, concern) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def mixes?(klass, concern) = MIXES.bind_call(klass, concern)
|
|
83
|
+
|
|
67
84
|
def replacing?(target, stored, base)
|
|
68
85
|
Kimera::Execution::OverlayGuards.overlaying? && !base && target.instance_variable_defined?(stored)
|
|
69
86
|
end
|
|
@@ -26,6 +26,7 @@ module Kimera
|
|
|
26
26
|
callbacks!
|
|
27
27
|
concern!
|
|
28
28
|
serialize!
|
|
29
|
+
reflections!
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def enum!
|
|
@@ -43,6 +44,11 @@ module Kimera
|
|
|
43
44
|
ActiveRecord::Base.singleton_class.prepend(OverlayGuardModules.serialization)
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
def reflections!
|
|
48
|
+
return unless defined?(ActiveRecord::Reflection)
|
|
49
|
+
ActiveRecord::Reflection.singleton_class.prepend(OverlayGuardModules.reflection)
|
|
50
|
+
end
|
|
51
|
+
|
|
46
52
|
def concern!
|
|
47
53
|
return unless defined?(ActiveSupport::Concern)
|
|
48
54
|
ActiveSupport::Concern.prepend(OverlayGuardModules.concern)
|
|
@@ -19,23 +19,31 @@ class Kimera::Execution::ParallelTestDatabases
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def after_fork(index)
|
|
22
|
+
identify(index)
|
|
22
23
|
return unless index && active?
|
|
23
24
|
ActiveSupport::Testing::Parallelization.after_fork_hooks.each { |hook| hook.call(index) }
|
|
24
25
|
@adapter.start
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def before_exit(index)
|
|
28
|
-
Array(index).each { cleanup }
|
|
29
|
+
Array(index).each { |worker| cleanup(worker) }
|
|
29
30
|
rescue StandardError => error
|
|
30
31
|
errors.puts("kimera: parallelize_teardown failed (#{error.class}: #{error.message})")
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
35
|
-
def cleanup
|
|
36
|
+
def cleanup(index)
|
|
36
37
|
return unless active?
|
|
37
38
|
scrub
|
|
38
|
-
ActiveSupport::Testing::Parallelization.run_cleanup_hooks.each(
|
|
39
|
+
ActiveSupport::Testing::Parallelization.run_cleanup_hooks.each { |hook| hook.call(index) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def identify(index)
|
|
43
|
+
return unless index && @jobs > 1 && defined?(ActiveSupport::TestCase)
|
|
44
|
+
ActiveSupport::TestCase.parallel_worker_id = index
|
|
45
|
+
rescue NoMethodError
|
|
46
|
+
nil
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
def scrub
|
|
@@ -46,10 +46,11 @@ class Kimera::Execution::Pool
|
|
|
46
46
|
@options[:coverage] = measured
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
def drive(queue, spawner, resolve:, lost:)
|
|
49
|
+
def drive(queue, spawner, resolve:, lost:, trace: nil)
|
|
50
|
+
database.before_fork
|
|
50
51
|
Kimera::Execution::WorkerPool.new(
|
|
51
52
|
queue: queue, spawner: spawner, jobs: @options.fetch(:jobs),
|
|
52
|
-
hard_timeout: @options.fetch(:hard), resolve: resolve, lost: lost
|
|
53
|
+
hard_timeout: @options.fetch(:hard), resolve: resolve, lost: lost, trace: trace
|
|
53
54
|
).run
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -66,7 +67,6 @@ class Kimera::Execution::Pool
|
|
|
66
67
|
def database = @options.fetch(:paralleldb)
|
|
67
68
|
|
|
68
69
|
def launch(entry, index)
|
|
69
|
-
database.before_fork
|
|
70
70
|
duty = Duty.new(entry, index, channels)
|
|
71
71
|
[spawn(duty), *duty.pipes.parent!]
|
|
72
72
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kimera
|
|
4
|
+
module Execution
|
|
5
|
+
module Reflections
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
GUARD =
|
|
9
|
+
Module.new do
|
|
10
|
+
def add_reflection(owner, name, reflection)
|
|
11
|
+
key = Reflections.key(owner, name)
|
|
12
|
+
stale = OverlayGuards.overlaying? && owner._reflections[key]
|
|
13
|
+
super
|
|
14
|
+
Reflections.inherit(owner, key, stale, reflection) if stale
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def key(owner, name)
|
|
19
|
+
text = name.to_s
|
|
20
|
+
owner._reflections.key?(text) ? text : name.to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def inherit(owner, key, stale, reflection)
|
|
24
|
+
owner.descendants.each { |heir| replace(heir, key, stale, reflection) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def replace(heir, key, stale, reflection)
|
|
28
|
+
held = heir._reflections
|
|
29
|
+
return unless held[key].equal?(stale)
|
|
30
|
+
heir.clear_reflections_cache
|
|
31
|
+
heir._reflections = held.merge(key => reflection)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -5,38 +5,16 @@ require_relative "../runtime"
|
|
|
5
5
|
require_relative "../synthesis/overlay"
|
|
6
6
|
require_relative "child_process"
|
|
7
7
|
require_relative "overlay_guards"
|
|
8
|
+
require_relative "reload_errand"
|
|
8
9
|
require_relative "verdicts"
|
|
9
10
|
|
|
10
11
|
class Kimera::Execution::Reload
|
|
12
|
+
include Kimera::Execution::ChildProcess
|
|
13
|
+
|
|
11
14
|
Failure =
|
|
12
15
|
Data.define(:exception) do
|
|
13
16
|
def message = "#{exception.class}: #{exception.message}"
|
|
14
17
|
end
|
|
15
|
-
include Kimera::Execution::ChildProcess
|
|
16
|
-
|
|
17
|
-
Errand =
|
|
18
|
-
Struct.new(:id, :reader, :writer) do
|
|
19
|
-
def parent!
|
|
20
|
-
writer.close
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def child!
|
|
24
|
-
reader.close
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def emit(payload)
|
|
28
|
-
writer.puts(payload)
|
|
29
|
-
writer.close
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def await(deadline, &)
|
|
33
|
-
return reader.gets if reader.wait_readable(deadline)
|
|
34
|
-
yield
|
|
35
|
-
nil
|
|
36
|
-
ensure
|
|
37
|
-
reader.close
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
18
|
|
|
41
19
|
def initialize(registry:, adapter:, isolation:, root:)
|
|
42
20
|
@registry = registry
|
|
@@ -45,8 +23,8 @@ class Kimera::Execution::Reload
|
|
|
45
23
|
@root = root
|
|
46
24
|
end
|
|
47
25
|
|
|
48
|
-
def run(id, deadline:)
|
|
49
|
-
errand = Errand.new(id, *IO.pipe)
|
|
26
|
+
def run(id, deadline:, tests: @adapter.test_ids)
|
|
27
|
+
errand = Errand.new(id, tests, *IO.pipe)
|
|
50
28
|
collect(errand, child(errand), deadline)
|
|
51
29
|
end
|
|
52
30
|
|
|
@@ -59,32 +37,42 @@ class Kimera::Execution::Reload
|
|
|
59
37
|
def work(errand)
|
|
60
38
|
errand.child!
|
|
61
39
|
silence!
|
|
62
|
-
errand.emit(report(errand
|
|
40
|
+
errand.emit(report(errand))
|
|
63
41
|
exit!(0)
|
|
64
42
|
end
|
|
65
43
|
|
|
66
|
-
def report(
|
|
67
|
-
status, fails = evaluate(
|
|
68
|
-
JSON.generate(id: id, status: status.to_s, fails: fails)
|
|
44
|
+
def report(errand)
|
|
45
|
+
status, fails = evaluate(errand)
|
|
46
|
+
JSON.generate(id: errand.id, status: status.to_s, fails: Array(fails).map { |fail| fail.to_s.scrub })
|
|
69
47
|
end
|
|
70
48
|
|
|
71
49
|
def collect(errand, pid, deadline)
|
|
72
50
|
line = errand.await(deadline) { kill(pid) }
|
|
73
|
-
reap(pid)
|
|
74
|
-
verdict(errand.id, line)
|
|
51
|
+
verdict(errand.id, line, reap(pid), deadline)
|
|
75
52
|
end
|
|
76
53
|
|
|
77
|
-
def evaluate(
|
|
78
|
-
mutate(
|
|
54
|
+
def evaluate(errand)
|
|
55
|
+
failed = mutate(errand)
|
|
56
|
+
failed ? failed.verdict : [:survived, []]
|
|
79
57
|
rescue StandardError, ScriptError => error
|
|
80
58
|
[:error, [Failure.new(error).message]]
|
|
81
59
|
end
|
|
82
60
|
|
|
83
|
-
def mutate(
|
|
61
|
+
def mutate(errand)
|
|
62
|
+
id = errand.id
|
|
84
63
|
file = verdicts.point(id).file
|
|
85
64
|
overlay(file, File.join(File.expand_path(@root), file), id)
|
|
86
65
|
Kimera::Runtime.active = nil
|
|
87
|
-
@isolation.around {
|
|
66
|
+
@isolation.around { hunt(errand) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def hunt(errand)
|
|
70
|
+
errand.tests.lazy.map { |test| trial(test, errand) }.reject(&:passed?).first
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def trial(test, errand)
|
|
74
|
+
errand.tick
|
|
75
|
+
@adapter.run([test])
|
|
88
76
|
end
|
|
89
77
|
|
|
90
78
|
def overlay(file, path, id)
|
|
@@ -92,12 +80,18 @@ class Kimera::Execution::Reload
|
|
|
92
80
|
Kimera::Execution::OverlayGuards.overlay { Kimera::Overlay.evaluate(baked, path) }
|
|
93
81
|
end
|
|
94
82
|
|
|
95
|
-
def verdict(id, line)
|
|
96
|
-
return verdicts.
|
|
83
|
+
def verdict(id, line, status = nil, deadline = nil)
|
|
84
|
+
return verdicts.timeout(id, deadline) if line == :timeout
|
|
85
|
+
return verdicts.unjudged(id, "reload worker produced no result#{exited(status)}") unless line
|
|
97
86
|
message = parse(line)
|
|
98
87
|
message ? verdicts.parse(message) : verdicts.unjudged(id, "reload worker output unparseable")
|
|
99
88
|
end
|
|
100
89
|
|
|
90
|
+
def exited(status)
|
|
91
|
+
return "" unless status
|
|
92
|
+
status.signaled? ? " (died on signal #{status.termsig})" : " (exited #{status.exitstatus})"
|
|
93
|
+
end
|
|
94
|
+
|
|
101
95
|
def verdicts
|
|
102
96
|
Kimera::Execution::Verdicts.new(@registry)
|
|
103
97
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kimera
|
|
4
|
+
module Execution
|
|
5
|
+
end
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class Kimera::Execution::Reload; end
|
|
9
|
+
|
|
10
|
+
Kimera::Execution::Reload::Errand =
|
|
11
|
+
Struct.new(:id, :tests, :reader, :writer) do
|
|
12
|
+
tick = "tick\n"
|
|
13
|
+
def parent!
|
|
14
|
+
writer.close
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def child!
|
|
18
|
+
reader.close
|
|
19
|
+
writer.sync = true
|
|
20
|
+
end
|
|
21
|
+
define_method(:tick) { writer.write(tick) }
|
|
22
|
+
def emit(payload)
|
|
23
|
+
writer.puts(payload)
|
|
24
|
+
writer.close
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def await(deadline)
|
|
28
|
+
heard(deadline).tap { |line| yield if line == :timeout }
|
|
29
|
+
ensure
|
|
30
|
+
reader.close
|
|
31
|
+
end
|
|
32
|
+
define_method(:heard) do |deadline|
|
|
33
|
+
while reader.wait_readable(deadline)
|
|
34
|
+
line = reader.gets
|
|
35
|
+
return line unless line == tick
|
|
36
|
+
end
|
|
37
|
+
:timeout
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "priority"
|
|
4
|
+
require_relative "kinship"
|
|
4
5
|
require_relative "reload"
|
|
5
6
|
require_relative "verdicts"
|
|
6
7
|
|
|
7
8
|
class Kimera::Execution::Schedule
|
|
8
9
|
Ledger = Struct.new(:results, :leaks)
|
|
9
|
-
Batch = Struct.new(:safe, :reloadable, :deferred, :ledger)
|
|
10
|
+
Batch = Struct.new(:safe, :reloadable, :deferred, :ledger, :coverage)
|
|
10
11
|
|
|
11
12
|
LOSSES = { timeout: :expired }.freeze
|
|
12
13
|
|
|
@@ -37,14 +38,16 @@ class Kimera::Execution::Schedule
|
|
|
37
38
|
safe, unsafe = ids.partition { |id| verdicts.safe?(id) }
|
|
38
39
|
reloadable, deferred = unsafe.partition { |id| verdicts.reloadable?(id) }
|
|
39
40
|
progress.start(safe.size + unsafe.size, label)
|
|
40
|
-
Batch.new(
|
|
41
|
+
Batch.new(prioritized(safe, coverage), reloadable, deferred, Ledger.new({}, []), coverage)
|
|
41
42
|
end
|
|
42
43
|
|
|
44
|
+
def prioritized(safe, coverage) = Kimera::Execution::Priority.new(coverage).order(safe)
|
|
45
|
+
|
|
43
46
|
def process(batch)
|
|
44
|
-
|
|
45
|
-
pool(safe, ledger)
|
|
46
|
-
reload(
|
|
47
|
-
defer(deferred, ledger)
|
|
47
|
+
ledger = batch.ledger
|
|
48
|
+
pool(batch.safe, ledger)
|
|
49
|
+
reload(batch)
|
|
50
|
+
defer(batch.deferred, ledger)
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
def finish(ledger)
|
|
@@ -58,8 +61,13 @@ class Kimera::Execution::Schedule
|
|
|
58
61
|
progress.tick(result.status)
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
def reload(
|
|
62
|
-
|
|
64
|
+
def reload(batch)
|
|
65
|
+
kinship = Kimera::Execution::Kinship.new(@registry, batch.coverage)
|
|
66
|
+
batch.reloadable.each { |id| record(batch.ledger, id, rerun(id, kinship)) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rerun(id, kinship)
|
|
70
|
+
reloader.run(id, deadline: hard, tests: kinship.order(id, @options.fetch(:adapter).test_ids))
|
|
63
71
|
end
|
|
64
72
|
|
|
65
73
|
def defer(ids, ledger)
|
|
@@ -97,7 +105,7 @@ class Kimera::Execution::Schedule
|
|
|
97
105
|
def crashed(id) = verdicts.unjudged(id, "worker crashed before result")
|
|
98
106
|
|
|
99
107
|
def reloader
|
|
100
|
-
Kimera::Execution::Reload.new(
|
|
108
|
+
@_reloader ||= Kimera::Execution::Reload.new(
|
|
101
109
|
registry: @registry, adapter: @options.fetch(:adapter),
|
|
102
110
|
isolation: @options.fetch(:isolation), root: @options.fetch(:root)
|
|
103
111
|
)
|
|
@@ -29,8 +29,8 @@ class Kimera::Execution::WorkerPool
|
|
|
29
29
|
def assign(worker)
|
|
30
30
|
id = fleet.take
|
|
31
31
|
return close(worker) unless id
|
|
32
|
-
worker.
|
|
33
|
-
worker.
|
|
32
|
+
worker.claim(id, limit)
|
|
33
|
+
trace(worker.slot, id)
|
|
34
34
|
worker.offer(id)
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -83,6 +83,8 @@ class Kimera::Execution::WorkerPool
|
|
|
83
83
|
worker.renew(limit)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
def trace(slot, id) = context.options[:trace]&.call(slot, id)
|
|
87
|
+
|
|
86
88
|
def close(worker) = shut(worker.retire)
|
|
87
89
|
|
|
88
90
|
def watch
|
|
@@ -51,8 +51,27 @@ class Kimera::Frameworks::MinitestAdapter < Kimera::Frameworks::Adapter
|
|
|
51
51
|
Kimera::Frameworks::RunOutcome.new(passed: failed.empty?, failed_ids: failed, failures: failures)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def reproduce(ids)
|
|
55
|
+
"bundle exec ruby -Itest -rminitest/autorun -e #{quote(loads(ids))} -- -n #{quote(filter(ids))} --seed 1"
|
|
56
|
+
end
|
|
57
|
+
|
|
54
58
|
private
|
|
55
59
|
|
|
60
|
+
def loads(ids)
|
|
61
|
+
ids.filter_map { |id| origin(id) }.uniq.map { |file| "require File.expand_path(#{file.dump})" }.join("; ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def filter(ids) = "/^(?:#{ids.map { |id| Regexp.escape(id) }.join("|")})$/"
|
|
65
|
+
|
|
66
|
+
def origin(id)
|
|
67
|
+
klass, name = @methods[id]
|
|
68
|
+
return unless klass
|
|
69
|
+
path, = klass.instance_method(name).source_location
|
|
70
|
+
path.delete_prefix("#{Dir.pwd}#{File::SEPARATOR}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def quote(text) = "'#{text.gsub("'") { "'\\''" }}'"
|
|
74
|
+
|
|
56
75
|
def record(id, failed, failures)
|
|
57
76
|
klass, name = @methods[id]
|
|
58
77
|
return unless klass
|
|
@@ -24,7 +24,7 @@ class Kimera::Incremental::Session
|
|
|
24
24
|
class << self
|
|
25
25
|
def load(path, registry: nil)
|
|
26
26
|
return new unless path && File.exist?(path)
|
|
27
|
-
session = decode(JSON.parse(File.read(path)))
|
|
27
|
+
session = decode(JSON.parse(File.read(path, encoding: Encoding::UTF_8)))
|
|
28
28
|
session.prune!(registry) if registry
|
|
29
29
|
session
|
|
30
30
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support/syntax"
|
|
4
|
+
require_relative "support/syntax_types"
|
|
5
|
+
|
|
6
|
+
class Kimera::MemoGuard
|
|
7
|
+
CONDITIONAL = "conditional"
|
|
8
|
+
BOOLEAN_LITERAL = "boolean_literal"
|
|
9
|
+
READ = Kimera::SyntaxTypes::InstanceVariableReadNode
|
|
10
|
+
WRITE = Kimera::SyntaxTypes::InstanceVariableWriteNode
|
|
11
|
+
|
|
12
|
+
Exemption = Data.define(:location, :operator)
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def exemptions(source)
|
|
16
|
+
result = Kimera::Syntax.parse(source)
|
|
17
|
+
return [] if result.failure?
|
|
18
|
+
defs(result.value).flat_map { new(statements(it.body)).exemptions }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def statements(body) = body.is_a?(Kimera::SyntaxTypes::StatementsNode) ? body.body : []
|
|
22
|
+
|
|
23
|
+
def defs(node, found = [])
|
|
24
|
+
found << node if node.is_a?(Kimera::SyntaxTypes::DefNode)
|
|
25
|
+
node.compact_child_nodes.each { defs(it, found) }
|
|
26
|
+
found
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(statements)
|
|
31
|
+
@guard, @flag, *@rest = statements
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exemptions
|
|
35
|
+
return [] unless name && (value? || flag?)
|
|
36
|
+
[Exemption.new(@guard.location, CONDITIONAL), *raised]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def name
|
|
42
|
+
predicate = @guard.predicate if guard?
|
|
43
|
+
predicate.name if predicate.is_a?(READ)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def guard? = @guard.is_a?(Kimera::SyntaxTypes::IfNode) && @guard.compact_child_nodes.size == 2 && returns?
|
|
47
|
+
|
|
48
|
+
def returns? = @guard.statements.body.size == 1 && returned.is_a?(Kimera::SyntaxTypes::ReturnNode)
|
|
49
|
+
|
|
50
|
+
def returned = @guard.statements.body.first
|
|
51
|
+
|
|
52
|
+
def value? = caches? && [@flag, *@rest].any? { named?(it, WRITE) }
|
|
53
|
+
|
|
54
|
+
def caches?
|
|
55
|
+
values = returned.arguments&.arguments
|
|
56
|
+
values&.size == 1 && named?(values.first, READ)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def flag? = returned.compact_child_nodes.empty? && raises?
|
|
60
|
+
|
|
61
|
+
def raises? = named?(@flag, WRITE) && @flag.value.is_a?(Kimera::SyntaxTypes::TrueNode)
|
|
62
|
+
|
|
63
|
+
def raised = flag? ? [Exemption.new(@flag.value.location, BOOLEAN_LITERAL)] : []
|
|
64
|
+
|
|
65
|
+
def named?(node, kind) = node.is_a?(kind) && node.name == name
|
|
66
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require_relative "../memo_guard"
|
|
4
5
|
require_relative "../memoization"
|
|
5
6
|
require_relative "mutation_point"
|
|
6
7
|
require_relative "walking"
|
|
@@ -25,6 +26,8 @@ class Kimera::RegistryScan::SourceFile
|
|
|
25
26
|
|
|
26
27
|
def tainted = @_tainted ||= Kimera::Memoization.ranges(@source)
|
|
27
28
|
|
|
29
|
+
def guards = @_guards ||= Kimera::MemoGuard.exemptions(@source)
|
|
30
|
+
|
|
28
31
|
def mined(root)
|
|
29
32
|
found = []
|
|
30
33
|
walk(root, origin) { |node, cursor| bank(found, node, cursor) }
|
|
@@ -36,7 +39,7 @@ class Kimera::RegistryScan::SourceFile
|
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def bank(found, node, cursor)
|
|
39
|
-
pairs = harvest(applicable(cursor), node, cursor.position)
|
|
42
|
+
pairs = harvest(applicable(cursor), node, cursor.position).reject { |key, _| guarded?(node, key) }
|
|
40
43
|
return if pairs.empty?
|
|
41
44
|
found << assemble(node, pairs, cursor).taint!(tainted)
|
|
42
45
|
end
|
|
@@ -46,6 +49,8 @@ class Kimera::RegistryScan::SourceFile
|
|
|
46
49
|
cursor.position ? chosen : chosen.reject(&:statement?)
|
|
47
50
|
end
|
|
48
51
|
|
|
52
|
+
def guarded?(node, key) = guards.include?(Kimera::MemoGuard::Exemption.new(node.location, key))
|
|
53
|
+
|
|
49
54
|
def harvest(operators, node, position)
|
|
50
55
|
operators.flat_map { |operator| operator.pairs(node, position: position) }
|
|
51
56
|
end
|
data/skills/kimera/SKILL.md
CHANGED
|
@@ -119,7 +119,9 @@ bundle exec kimera run --isolated --jobs 4 # oracle mode (see Strengthen)
|
|
|
119
119
|
suppress the warm path.
|
|
120
120
|
- `--isolated` judges on failed tests, not the exit status. A child that
|
|
121
121
|
exits non-zero with zero failures (typically a SimpleCov `minimum_coverage`
|
|
122
|
-
floor tripped by the partial run) is `harness_error`, not a kill
|
|
122
|
+
floor tripped by the partial run) is `harness_error`, not a kill, and so is
|
|
123
|
+
a child that crashes before reporting (read its `detail` for the stderr).
|
|
124
|
+
Kimera
|
|
123
125
|
sets `KIMERA=1` wherever it loads the suite (warm, `--isolated`, doctor), so
|
|
124
126
|
gate the floor on it (`minimum_coverage ... unless ENV["KIMERA"]`); `kimera
|
|
125
127
|
doctor` warns when it isn't.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kimera
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Giacomo GK
|
|
@@ -99,6 +99,7 @@ files:
|
|
|
99
99
|
- lib/kimera/cli/workflows.rb
|
|
100
100
|
- lib/kimera/error.rb
|
|
101
101
|
- lib/kimera/execution/baseline_failure.rb
|
|
102
|
+
- lib/kimera/execution/baseline_failure/breakdown.rb
|
|
102
103
|
- lib/kimera/execution/baseline_pass.rb
|
|
103
104
|
- lib/kimera/execution/boot.rb
|
|
104
105
|
- lib/kimera/execution/callback.rb
|
|
@@ -116,13 +117,16 @@ files:
|
|
|
116
117
|
- lib/kimera/execution/isolated_verdict.rb
|
|
117
118
|
- lib/kimera/execution/isolated_watchdog.rb
|
|
118
119
|
- lib/kimera/execution/isolation.rb
|
|
120
|
+
- lib/kimera/execution/kinship.rb
|
|
119
121
|
- lib/kimera/execution/null_progress.rb
|
|
120
122
|
- lib/kimera/execution/overlay_guard_modules.rb
|
|
121
123
|
- lib/kimera/execution/overlay_guards.rb
|
|
122
124
|
- lib/kimera/execution/parallel_test_databases.rb
|
|
123
125
|
- lib/kimera/execution/pool_driver.rb
|
|
124
126
|
- lib/kimera/execution/priority.rb
|
|
127
|
+
- lib/kimera/execution/reflections.rb
|
|
125
128
|
- lib/kimera/execution/reload.rb
|
|
129
|
+
- lib/kimera/execution/reload_errand.rb
|
|
126
130
|
- lib/kimera/execution/rig.rb
|
|
127
131
|
- lib/kimera/execution/schedule.rb
|
|
128
132
|
- lib/kimera/execution/schemata.rb
|
|
@@ -147,6 +151,7 @@ files:
|
|
|
147
151
|
- lib/kimera/incremental/git_diff.rb
|
|
148
152
|
- lib/kimera/incremental/selection.rb
|
|
149
153
|
- lib/kimera/incremental/session.rb
|
|
154
|
+
- lib/kimera/memo_guard.rb
|
|
150
155
|
- lib/kimera/memoization.rb
|
|
151
156
|
- lib/kimera/operators.rb
|
|
152
157
|
- lib/kimera/operators/argument_drop.rb
|