kaisoku 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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +8 -0
- data/exe/kaisoku +6 -0
- data/lib/kaisoku/adapter.rb +81 -0
- data/lib/kaisoku/adapters/cucumber.rb +91 -0
- data/lib/kaisoku/adapters/minitest.rb +96 -0
- data/lib/kaisoku/adapters/rspec.rb +111 -0
- data/lib/kaisoku/adapters/test_unit.rb +35 -0
- data/lib/kaisoku/checksum.rb +55 -0
- data/lib/kaisoku/cli.rb +158 -0
- data/lib/kaisoku/commands/daemon_command.rb +31 -0
- data/lib/kaisoku/commands/map_command.rb +86 -0
- data/lib/kaisoku/commands/preload_command.rb +40 -0
- data/lib/kaisoku/commands/run_command.rb +93 -0
- data/lib/kaisoku/commands/watch_command.rb +37 -0
- data/lib/kaisoku/configuration.rb +69 -0
- data/lib/kaisoku/coverage_collector.rb +83 -0
- data/lib/kaisoku/daemon/client.rb +22 -0
- data/lib/kaisoku/daemon/server.rb +39 -0
- data/lib/kaisoku/daemon/socket_path.rb +13 -0
- data/lib/kaisoku/doctor.rb +46 -0
- data/lib/kaisoku/entity.rb +77 -0
- data/lib/kaisoku/entity_result.rb +19 -0
- data/lib/kaisoku/environment_tracker.rb +28 -0
- data/lib/kaisoku/evaluator/cli.rb +112 -0
- data/lib/kaisoku/evaluator/command_result.rb +64 -0
- data/lib/kaisoku/evaluator/history.rb +23 -0
- data/lib/kaisoku/evaluator/metrics.rb +45 -0
- data/lib/kaisoku/evaluator/mutation_seeder.rb +78 -0
- data/lib/kaisoku/evaluator/replay.rb +121 -0
- data/lib/kaisoku/evaluator/report.rb +128 -0
- data/lib/kaisoku/hybrid_policy.rb +14 -0
- data/lib/kaisoku/listen_watcher.rb +34 -0
- data/lib/kaisoku/lsp/message_io.rb +42 -0
- data/lib/kaisoku/lsp/server.rb +100 -0
- data/lib/kaisoku/method_map.rb +75 -0
- data/lib/kaisoku/preload/client.rb +22 -0
- data/lib/kaisoku/preload/daemon.rb +48 -0
- data/lib/kaisoku/preload/fallback_runner.rb +45 -0
- data/lib/kaisoku/preload/server.rb +123 -0
- data/lib/kaisoku/preload/socket_path.rb +13 -0
- data/lib/kaisoku/prioritizer.rb +18 -0
- data/lib/kaisoku/rails/integration.rb +56 -0
- data/lib/kaisoku/reporters/console.rb +24 -0
- data/lib/kaisoku/reporters/factory.rb +24 -0
- data/lib/kaisoku/reporters/json.rb +28 -0
- data/lib/kaisoku/reporters/junit.rb +50 -0
- data/lib/kaisoku/reporters/tui.rb +29 -0
- data/lib/kaisoku/runner.rb +48 -0
- data/lib/kaisoku/runtime_context.rb +28 -0
- data/lib/kaisoku/scheduler/fork_pool.rb +105 -0
- data/lib/kaisoku/scheduler/result_persister.rb +43 -0
- data/lib/kaisoku/selection.rb +51 -0
- data/lib/kaisoku/selector.rb +173 -0
- data/lib/kaisoku/snapshot/inline.rb +51 -0
- data/lib/kaisoku/snapshot/rspec.rb +22 -0
- data/lib/kaisoku/static_analysis/analyzer.rb +120 -0
- data/lib/kaisoku/test_map/dependency_queries.rb +81 -0
- data/lib/kaisoku/test_map/health.rb +24 -0
- data/lib/kaisoku/test_map/hot_files.rb +32 -0
- data/lib/kaisoku/test_map/schema.rb +81 -0
- data/lib/kaisoku/test_map/snapshot.rb +53 -0
- data/lib/kaisoku/test_map/stats.rb +19 -0
- data/lib/kaisoku/test_map.rb +170 -0
- data/lib/kaisoku/version.rb +5 -0
- data/lib/kaisoku/watch_session.rb +35 -0
- data/lib/kaisoku/watcher.rb +63 -0
- data/lib/kaisoku/worker.rb +46 -0
- data/lib/kaisoku.rb +67 -0
- metadata +186 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Snapshot
|
|
5
|
+
module Inline
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def matches?(actual, expected, location:)
|
|
9
|
+
return update(actual, location) if update?
|
|
10
|
+
|
|
11
|
+
actual == expected
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update(actual, location)
|
|
15
|
+
return true unless location && File.file?(location.path)
|
|
16
|
+
|
|
17
|
+
lines = File.readlines(location.path)
|
|
18
|
+
index = location.lineno - 1
|
|
19
|
+
replace_in_lines(lines, index, actual)
|
|
20
|
+
File.write(location.path, lines.join)
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update?
|
|
25
|
+
ENV['KAISOKU_UPDATE_SNAPSHOTS'] == '1'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def replace_argument(line, replacement)
|
|
29
|
+
line.sub(/match_inline_snapshot\((.*)\)/, "match_inline_snapshot(#{replacement})")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def replace_in_lines(lines, index, actual)
|
|
33
|
+
line = lines[index]
|
|
34
|
+
if (match = line.match(/match_inline_snapshot\(<<~?['"]?([A-Z_]+)['"]?\)/))
|
|
35
|
+
replace_heredoc(lines, index, match[1], actual.to_s)
|
|
36
|
+
else
|
|
37
|
+
lines[index] = replace_argument(line, actual.inspect)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def replace_heredoc(lines, index, terminator, actual)
|
|
42
|
+
finish = ((index + 1)...lines.length).find { |line_index| lines[line_index].strip == terminator }
|
|
43
|
+
return unless finish
|
|
44
|
+
|
|
45
|
+
indent = lines[index + 1][/^\s*/] || ''
|
|
46
|
+
replacement = actual.lines.map { |line| "#{indent}#{line.chomp}\n" }
|
|
47
|
+
lines[(index + 1)...finish] = replacement
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Snapshot
|
|
5
|
+
module RSpec
|
|
6
|
+
def self.install
|
|
7
|
+
return unless defined?(::RSpec::Matchers)
|
|
8
|
+
|
|
9
|
+
::RSpec::Matchers.define :match_inline_snapshot do |expected|
|
|
10
|
+
match do |actual|
|
|
11
|
+
location = caller_locations.find { |frame| frame.path.end_with?('_spec.rb') }
|
|
12
|
+
Inline.matches?(actual, expected, location: location)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
failure_message do |actual|
|
|
16
|
+
"expected #{actual.inspect} to match inline snapshot #{expected.inspect}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module StaticAnalysis
|
|
7
|
+
class Analyzer
|
|
8
|
+
def initialize(configuration: Configuration.new)
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def inferred_dependencies_for_test(path)
|
|
13
|
+
absolute = @configuration.absolute_path(path)
|
|
14
|
+
return [] unless File.file?(absolute)
|
|
15
|
+
|
|
16
|
+
result = Prism.parse_file(absolute)
|
|
17
|
+
return [] unless result.success?
|
|
18
|
+
|
|
19
|
+
program = result.value
|
|
20
|
+
(requires(program, absolute) + constants(program)).uniq
|
|
21
|
+
rescue StandardError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def conventional_tests_for(path)
|
|
26
|
+
relative = @configuration.relative_path(path)
|
|
27
|
+
stem = relative.sub(%r{\A(app|lib)/}, '').sub(/\.rb\z/, '')
|
|
28
|
+
candidates = [
|
|
29
|
+
"spec/#{stem}_spec.rb",
|
|
30
|
+
"test/#{stem}_test.rb"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
candidates.select { |candidate| File.file?(@configuration.absolute_path(candidate)) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def requires(program, absolute)
|
|
39
|
+
nodes(program).filter_map do |node|
|
|
40
|
+
next unless node.is_a?(Prism::CallNode)
|
|
41
|
+
next unless %i[require require_relative].include?(node.name)
|
|
42
|
+
|
|
43
|
+
feature = first_string_argument(node)
|
|
44
|
+
next unless feature
|
|
45
|
+
|
|
46
|
+
require_path(feature, absolute, relative: node.name == :require_relative)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def require_path(feature, absolute, relative:)
|
|
51
|
+
candidate = if relative
|
|
52
|
+
File.expand_path("#{feature}.rb", File.dirname(absolute))
|
|
53
|
+
else
|
|
54
|
+
require_candidates(feature).find { |path| File.file?(path) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return nil unless candidate && File.file?(candidate)
|
|
58
|
+
|
|
59
|
+
@configuration.relative_path(candidate)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def require_candidates(feature)
|
|
63
|
+
[
|
|
64
|
+
@configuration.absolute_path("#{feature}.rb"),
|
|
65
|
+
@configuration.absolute_path("lib/#{feature}.rb"),
|
|
66
|
+
@configuration.absolute_path("app/#{feature}.rb")
|
|
67
|
+
]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def constants(program)
|
|
71
|
+
nodes(program).filter_map do |node|
|
|
72
|
+
constant = constant_name(node)
|
|
73
|
+
next unless constant
|
|
74
|
+
|
|
75
|
+
constant_to_path(constant)
|
|
76
|
+
end.select { |path| File.file?(@configuration.absolute_path(path)) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def nodes(root, &block)
|
|
80
|
+
return enum_for(:nodes, root) unless block_given?
|
|
81
|
+
|
|
82
|
+
yield root
|
|
83
|
+
return unless root.respond_to?(:child_nodes)
|
|
84
|
+
|
|
85
|
+
root.child_nodes.compact.each { |child| nodes(child, &block) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def first_string_argument(node)
|
|
89
|
+
argument = node.arguments&.arguments&.first
|
|
90
|
+
return unless argument.respond_to?(:unescaped)
|
|
91
|
+
|
|
92
|
+
argument.unescaped
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def constant_name(node)
|
|
96
|
+
return node.name.to_s if node.is_a?(Prism::ConstantReadNode)
|
|
97
|
+
return constant_path_name(node) if node.is_a?(Prism::ConstantPathNode)
|
|
98
|
+
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def constant_path_name(node)
|
|
103
|
+
parent = constant_name(node.parent)
|
|
104
|
+
[parent, node.name].compact.join('::')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def constant_to_path(constant)
|
|
108
|
+
basename = constant.split('::').map { |part| underscore(part) }.join('/')
|
|
109
|
+
"app/#{basename}.rb"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def underscore(value)
|
|
113
|
+
value
|
|
114
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
|
|
115
|
+
.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
|
|
116
|
+
.downcase
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class TestMap
|
|
5
|
+
module DependencyQueries
|
|
6
|
+
def dependencies_for(entity)
|
|
7
|
+
id = entity_id(entity)
|
|
8
|
+
return {} unless id
|
|
9
|
+
|
|
10
|
+
@db.execute('SELECT dep_file, checksum FROM deps WHERE entity_id = ?', [id]).to_h do |row|
|
|
11
|
+
[row['dep_file'], row['checksum']]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def checksums_for_file(path)
|
|
16
|
+
file = configuration.relative_path(path)
|
|
17
|
+
@db.execute('SELECT DISTINCT checksum FROM deps WHERE dep_file = ?', [file]).map { |row| row['checksum'] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def method_checksums_for_file(path)
|
|
21
|
+
file = configuration.relative_path(path)
|
|
22
|
+
@db.execute('SELECT dep_file, checksum FROM deps WHERE dep_file LIKE ?', ["#{file}#%"]).to_h do |row|
|
|
23
|
+
[row['dep_file'], row['checksum']]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entities_for_changed_files(paths, adapter: nil)
|
|
28
|
+
files = paths.map { |path| configuration.relative_path(path) }.uniq
|
|
29
|
+
entities_for_dependency_keys(files, adapter: adapter)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def entities_for_dependency_keys(keys, adapter: nil)
|
|
33
|
+
return [] if keys.empty?
|
|
34
|
+
|
|
35
|
+
adapter_clause = adapter ? 'AND entities.adapter = ?' : ''
|
|
36
|
+
bind = adapter ? keys + [adapter.to_s] : keys
|
|
37
|
+
sql = <<~SQL
|
|
38
|
+
SELECT DISTINCT entities.*
|
|
39
|
+
FROM entities
|
|
40
|
+
INNER JOIN deps ON deps.entity_id = entities.id
|
|
41
|
+
WHERE deps.dep_file IN (#{placeholders(keys)})
|
|
42
|
+
#{adapter_clause}
|
|
43
|
+
ORDER BY entities.file ASC, entities.identifier ASC
|
|
44
|
+
SQL
|
|
45
|
+
@db.execute(sql, bind).map { |row| entity_from_row(row) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dependency_entity_count(path, adapter: nil)
|
|
49
|
+
file = configuration.relative_path(path)
|
|
50
|
+
adapter_clause = adapter ? 'AND entities.adapter = ?' : ''
|
|
51
|
+
bind = adapter ? [file, adapter.to_s] : [file]
|
|
52
|
+
sql = <<~SQL
|
|
53
|
+
SELECT COUNT(DISTINCT deps.entity_id)
|
|
54
|
+
FROM deps
|
|
55
|
+
INNER JOIN entities ON entities.id = deps.entity_id
|
|
56
|
+
WHERE deps.dep_file = ?
|
|
57
|
+
#{adapter_clause}
|
|
58
|
+
SQL
|
|
59
|
+
@db.get_first_value(sql, bind).to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def entities_with_dependency_prefixes(prefixes, adapter: nil)
|
|
63
|
+
return [] if prefixes.empty?
|
|
64
|
+
|
|
65
|
+
clauses = prefixes.map { 'deps.dep_file LIKE ?' }.join(' OR ')
|
|
66
|
+
bind = prefixes.map { |prefix| "#{prefix}%" }
|
|
67
|
+
adapter_clause = adapter ? 'AND entities.adapter = ?' : ''
|
|
68
|
+
bind << adapter.to_s if adapter
|
|
69
|
+
sql = <<~SQL
|
|
70
|
+
SELECT DISTINCT entities.*
|
|
71
|
+
FROM entities
|
|
72
|
+
INNER JOIN deps ON deps.entity_id = entities.id
|
|
73
|
+
WHERE (#{clauses})
|
|
74
|
+
#{adapter_clause}
|
|
75
|
+
ORDER BY entities.file ASC, entities.identifier ASC
|
|
76
|
+
SQL
|
|
77
|
+
@db.execute(sql, bind).map { |row| entity_from_row(row) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class TestMap
|
|
5
|
+
module Health
|
|
6
|
+
def health_warnings
|
|
7
|
+
warnings = []
|
|
8
|
+
warnings << 'schema version mismatch' unless stats[:schema_version] == SCHEMA_VERSION
|
|
9
|
+
warnings << 'ruby version mismatch' unless stats[:ruby_version] == RUBY_VERSION
|
|
10
|
+
warnings << 'adapter mismatch' unless stats[:adapter] == configuration.adapter
|
|
11
|
+
warnings << 'Gemfile.lock hash mismatch' if gemfile_lock_changed?
|
|
12
|
+
warnings
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def gemfile_lock_changed?
|
|
18
|
+
current = Schema.gemfile_lock_hash(configuration)
|
|
19
|
+
stored = stats[:gemfile_lock_hash]
|
|
20
|
+
!stored.to_s.empty? && current != stored
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class TestMap
|
|
5
|
+
module HotFiles
|
|
6
|
+
def hot_files(threshold: 20, limit: 20, adapter: nil)
|
|
7
|
+
adapter_clause = adapter ? 'AND entities.adapter = ?' : ''
|
|
8
|
+
bind = []
|
|
9
|
+
bind << adapter.to_s if adapter
|
|
10
|
+
bind << threshold.to_i
|
|
11
|
+
bind << limit.to_i
|
|
12
|
+
|
|
13
|
+
sql = <<~SQL
|
|
14
|
+
SELECT deps.dep_file AS file, COUNT(DISTINCT deps.entity_id) AS entity_count
|
|
15
|
+
FROM deps
|
|
16
|
+
INNER JOIN entities ON entities.id = deps.entity_id
|
|
17
|
+
WHERE substr(deps.dep_file, 1, 2) != '__'
|
|
18
|
+
AND instr(deps.dep_file, '#') = 0
|
|
19
|
+
#{adapter_clause}
|
|
20
|
+
GROUP BY deps.dep_file
|
|
21
|
+
HAVING COUNT(DISTINCT deps.entity_id) >= ?
|
|
22
|
+
ORDER BY entity_count DESC, deps.dep_file ASC
|
|
23
|
+
LIMIT ?
|
|
24
|
+
SQL
|
|
25
|
+
|
|
26
|
+
@db.execute(sql, bind).map do |row|
|
|
27
|
+
{ file: row['file'], entity_count: row['entity_count'].to_i }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
class TestMap
|
|
7
|
+
module Schema
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def setup(db)
|
|
11
|
+
db.execute_batch(<<~SQL)
|
|
12
|
+
PRAGMA foreign_keys = ON;
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS map_meta (
|
|
15
|
+
key TEXT PRIMARY KEY,
|
|
16
|
+
value TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
20
|
+
id INTEGER PRIMARY KEY,
|
|
21
|
+
adapter TEXT NOT NULL,
|
|
22
|
+
identifier TEXT NOT NULL,
|
|
23
|
+
file TEXT NOT NULL,
|
|
24
|
+
line INTEGER,
|
|
25
|
+
avg_duration_ms INTEGER,
|
|
26
|
+
last_status TEXT,
|
|
27
|
+
last_failed_at INTEGER,
|
|
28
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
UNIQUE(adapter, identifier)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS deps (
|
|
33
|
+
entity_id INTEGER NOT NULL,
|
|
34
|
+
dep_file TEXT NOT NULL,
|
|
35
|
+
checksum TEXT NOT NULL,
|
|
36
|
+
granularity TEXT NOT NULL DEFAULT 'file',
|
|
37
|
+
PRIMARY KEY(entity_id, dep_file, granularity),
|
|
38
|
+
FOREIGN KEY(entity_id) REFERENCES entities(id) ON DELETE CASCADE
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_deps_file ON deps(dep_file);
|
|
42
|
+
SQL
|
|
43
|
+
add_column(db, 'entities', 'failure_count', 'INTEGER NOT NULL DEFAULT 0')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def write_runtime_metadata(db, adapter:, configuration:)
|
|
47
|
+
write_meta(db, 'schema_version', TestMap::SCHEMA_VERSION)
|
|
48
|
+
write_meta(db, 'ruby_version', RUBY_VERSION)
|
|
49
|
+
write_meta(db, 'adapter', adapter)
|
|
50
|
+
write_meta(db, 'gemfile_lock_hash', gemfile_lock_hash(configuration))
|
|
51
|
+
write_meta(db, 'created_at', Time.now.to_i) unless meta(db, 'created_at')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def meta(db, key)
|
|
55
|
+
db.get_first_value('SELECT value FROM map_meta WHERE key = ?', [key])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def write_meta(db, key, value)
|
|
59
|
+
sql = <<~SQL
|
|
60
|
+
INSERT INTO map_meta (key, value)
|
|
61
|
+
VALUES (?, ?)
|
|
62
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
63
|
+
SQL
|
|
64
|
+
db.execute(sql, [key, value.to_s])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def gemfile_lock_hash(configuration)
|
|
68
|
+
path = configuration.absolute_path('Gemfile.lock')
|
|
69
|
+
return '' unless File.file?(path)
|
|
70
|
+
|
|
71
|
+
Digest::SHA256.file(path).hexdigest
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_column(db, table, column, definition)
|
|
75
|
+
db.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{definition}")
|
|
76
|
+
rescue SQLite3::SQLException
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
class TestMap
|
|
7
|
+
module Snapshot
|
|
8
|
+
def export_json
|
|
9
|
+
::JSON.pretty_generate(
|
|
10
|
+
stats: stats,
|
|
11
|
+
entities: all_entities.map { |entity| export_entity(entity) }
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def import_json(payload)
|
|
16
|
+
data = ::JSON.parse(payload)
|
|
17
|
+
Array(data['entities']).each do |row|
|
|
18
|
+
entity = import_entity(row)
|
|
19
|
+
replace_dependencies(entity, row.fetch('dependencies', {}))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def export_entity(entity)
|
|
26
|
+
{
|
|
27
|
+
adapter: entity.adapter,
|
|
28
|
+
identifier: entity.identifier,
|
|
29
|
+
file: entity.file,
|
|
30
|
+
line: entity.line,
|
|
31
|
+
avg_duration_ms: entity.avg_duration_ms,
|
|
32
|
+
last_status: entity.last_status,
|
|
33
|
+
last_failed_at: entity.last_failed_at,
|
|
34
|
+
failure_count: entity.failure_count,
|
|
35
|
+
dependencies: dependencies_for(entity)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def import_entity(row)
|
|
40
|
+
Entity.new(
|
|
41
|
+
adapter: row['adapter'],
|
|
42
|
+
identifier: row['identifier'],
|
|
43
|
+
file: row['file'],
|
|
44
|
+
line: row['line'],
|
|
45
|
+
avg_duration_ms: row['avg_duration_ms'],
|
|
46
|
+
last_status: row['last_status'],
|
|
47
|
+
last_failed_at: row['last_failed_at'],
|
|
48
|
+
failure_count: row['failure_count']
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class TestMap
|
|
5
|
+
module Stats
|
|
6
|
+
def stats
|
|
7
|
+
{
|
|
8
|
+
entities: @db.get_first_value('SELECT COUNT(*) FROM entities').to_i,
|
|
9
|
+
dependencies: @db.get_first_value('SELECT COUNT(*) FROM deps').to_i,
|
|
10
|
+
schema_version: Schema.meta(@db, 'schema_version'),
|
|
11
|
+
ruby_version: Schema.meta(@db, 'ruby_version'),
|
|
12
|
+
adapter: Schema.meta(@db, 'adapter'),
|
|
13
|
+
gemfile_lock_hash: Schema.meta(@db, 'gemfile_lock_hash'),
|
|
14
|
+
created_at: Schema.meta(@db, 'created_at')
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'sqlite3'
|
|
5
|
+
require_relative 'test_map/dependency_queries'
|
|
6
|
+
require_relative 'test_map/health'
|
|
7
|
+
require_relative 'test_map/hot_files'
|
|
8
|
+
require_relative 'test_map/schema'
|
|
9
|
+
require_relative 'test_map/snapshot'
|
|
10
|
+
require_relative 'test_map/stats'
|
|
11
|
+
|
|
12
|
+
module Kaisoku
|
|
13
|
+
class TestMap
|
|
14
|
+
include DependencyQueries
|
|
15
|
+
include Health
|
|
16
|
+
include HotFiles
|
|
17
|
+
include Snapshot
|
|
18
|
+
include Stats
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = '1'
|
|
21
|
+
attr_reader :configuration, :path
|
|
22
|
+
|
|
23
|
+
def initialize(path: nil, configuration: Configuration.new)
|
|
24
|
+
@configuration = configuration
|
|
25
|
+
@path = path || configuration.map_path
|
|
26
|
+
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
28
|
+
@db = SQLite3::Database.new(@path)
|
|
29
|
+
@db.results_as_hash = true
|
|
30
|
+
Schema.setup(@db)
|
|
31
|
+
Schema.write_runtime_metadata(@db, adapter: configuration.adapter, configuration: configuration)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def upsert_entity(entity)
|
|
35
|
+
sql = <<~SQL
|
|
36
|
+
INSERT INTO entities (adapter, identifier, file, line, avg_duration_ms, last_status, last_failed_at, failure_count)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
38
|
+
ON CONFLICT(adapter, identifier) DO UPDATE SET
|
|
39
|
+
file = excluded.file,
|
|
40
|
+
line = excluded.line,
|
|
41
|
+
avg_duration_ms = COALESCE(excluded.avg_duration_ms, entities.avg_duration_ms),
|
|
42
|
+
last_status = COALESCE(excluded.last_status, entities.last_status),
|
|
43
|
+
last_failed_at = COALESCE(excluded.last_failed_at, entities.last_failed_at),
|
|
44
|
+
failure_count = excluded.failure_count
|
|
45
|
+
SQL
|
|
46
|
+
@db.execute(
|
|
47
|
+
sql,
|
|
48
|
+
[
|
|
49
|
+
entity.adapter,
|
|
50
|
+
entity.identifier,
|
|
51
|
+
entity.file,
|
|
52
|
+
entity.line,
|
|
53
|
+
entity.avg_duration_ms,
|
|
54
|
+
entity.last_status,
|
|
55
|
+
entity.last_failed_at,
|
|
56
|
+
entity.failure_count
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
entity_id(entity)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def replace_dependencies(entity, dependencies, granularity: 'file')
|
|
64
|
+
normalized = dependencies.to_h.transform_keys { |file| configuration.relative_path(file) }
|
|
65
|
+
|
|
66
|
+
@db.transaction do
|
|
67
|
+
id = upsert_entity(entity)
|
|
68
|
+
@db.execute('DELETE FROM deps WHERE entity_id = ? AND granularity = ?', [id, granularity])
|
|
69
|
+
|
|
70
|
+
normalized.each do |file, checksum|
|
|
71
|
+
@db.execute(
|
|
72
|
+
'INSERT INTO deps (entity_id, dep_file, checksum, granularity) VALUES (?, ?, ?, ?)',
|
|
73
|
+
[id, file, checksum, granularity]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def entities_for_test_files(paths, adapter: nil)
|
|
80
|
+
files = paths.map { |path| configuration.relative_path(path) }.uniq
|
|
81
|
+
return [] if files.empty?
|
|
82
|
+
|
|
83
|
+
adapter_clause = adapter ? 'AND adapter = ?' : ''
|
|
84
|
+
bind = adapter ? files + [adapter.to_s] : files
|
|
85
|
+
|
|
86
|
+
sql = <<~SQL
|
|
87
|
+
SELECT *
|
|
88
|
+
FROM entities
|
|
89
|
+
WHERE file IN (#{placeholders(files)})
|
|
90
|
+
#{adapter_clause}
|
|
91
|
+
ORDER BY file ASC, identifier ASC
|
|
92
|
+
SQL
|
|
93
|
+
|
|
94
|
+
@db.execute(sql, bind).map { |row| entity_from_row(row) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def all_entities(adapter: nil)
|
|
98
|
+
rows = if adapter
|
|
99
|
+
@db.execute('SELECT * FROM entities WHERE adapter = ? ORDER BY file ASC, identifier ASC', [adapter.to_s])
|
|
100
|
+
else
|
|
101
|
+
@db.execute('SELECT * FROM entities ORDER BY adapter ASC, file ASC, identifier ASC')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
rows.map { |row| entity_from_row(row) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def failed_entities(adapter: nil)
|
|
108
|
+
if adapter
|
|
109
|
+
sql = <<~SQL
|
|
110
|
+
SELECT * FROM entities WHERE adapter = ? AND last_status = 'failed'
|
|
111
|
+
ORDER BY last_failed_at DESC, file ASC
|
|
112
|
+
SQL
|
|
113
|
+
rows = @db.execute(sql, [adapter.to_s])
|
|
114
|
+
else
|
|
115
|
+
sql = <<~SQL
|
|
116
|
+
SELECT * FROM entities WHERE last_status = 'failed'
|
|
117
|
+
ORDER BY last_failed_at DESC, file ASC
|
|
118
|
+
SQL
|
|
119
|
+
rows = @db.execute(sql)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
rows.map { |row| entity_from_row(row) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def known_file?(path)
|
|
126
|
+
file = configuration.relative_path(path)
|
|
127
|
+
dep_count = @db.get_first_value('SELECT COUNT(*) FROM deps WHERE dep_file = ?', [file])
|
|
128
|
+
entity_count = @db.get_first_value('SELECT COUNT(*) FROM entities WHERE file = ?', [file])
|
|
129
|
+
|
|
130
|
+
dep_count.to_i.positive? || entity_count.to_i.positive?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def empty?
|
|
134
|
+
@db.get_first_value('SELECT COUNT(*) FROM entities').to_i.zero?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def clear!
|
|
138
|
+
@db.transaction do
|
|
139
|
+
@db.execute('DELETE FROM deps')
|
|
140
|
+
@db.execute('DELETE FROM entities')
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def entity_id(entity)
|
|
147
|
+
@db.get_first_value(
|
|
148
|
+
'SELECT id FROM entities WHERE adapter = ? AND identifier = ?',
|
|
149
|
+
[entity.adapter, entity.identifier]
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def entity_from_row(row)
|
|
154
|
+
Entity.new(
|
|
155
|
+
adapter: row['adapter'],
|
|
156
|
+
identifier: row['identifier'],
|
|
157
|
+
file: row['file'],
|
|
158
|
+
line: row['line'],
|
|
159
|
+
avg_duration_ms: row['avg_duration_ms'],
|
|
160
|
+
last_status: row['last_status'],
|
|
161
|
+
last_failed_at: row['last_failed_at'],
|
|
162
|
+
failure_count: row['failure_count']
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def placeholders(values)
|
|
167
|
+
(['?'] * values.length).join(', ')
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class WatchSession
|
|
5
|
+
def initialize(watcher:, runner:, debounce: 0.05)
|
|
6
|
+
@watcher = watcher
|
|
7
|
+
@debounce = debounce.to_f
|
|
8
|
+
@runner = runner
|
|
9
|
+
@current_pid = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def start
|
|
13
|
+
@watcher.each_change do |changed_paths|
|
|
14
|
+
sleep @debounce
|
|
15
|
+
cancel_current
|
|
16
|
+
@current_pid = fork { exit!(@runner.call(changed_paths).to_i) }
|
|
17
|
+
Process.detach(@current_pid)
|
|
18
|
+
end
|
|
19
|
+
ensure
|
|
20
|
+
cancel_current
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def cancel_current
|
|
26
|
+
return unless @current_pid
|
|
27
|
+
|
|
28
|
+
Process.kill('TERM', @current_pid)
|
|
29
|
+
rescue Errno::ESRCH
|
|
30
|
+
nil
|
|
31
|
+
ensure
|
|
32
|
+
@current_pid = nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|