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
data/lib/kaisoku/cli.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
class CLI
|
|
7
|
+
def self.start(argv)
|
|
8
|
+
new(argv).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(argv)
|
|
12
|
+
@argv = argv.dup
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
command = @argv.shift || 'help'
|
|
17
|
+
|
|
18
|
+
case command
|
|
19
|
+
when 'run'
|
|
20
|
+
run_command(@argv)
|
|
21
|
+
when 'watch'
|
|
22
|
+
watch_command(@argv)
|
|
23
|
+
when 'map'
|
|
24
|
+
map_command(@argv)
|
|
25
|
+
when 'doctor'
|
|
26
|
+
doctor_command(@argv)
|
|
27
|
+
when 'eval'
|
|
28
|
+
eval_command(@argv)
|
|
29
|
+
when 'daemon'
|
|
30
|
+
daemon_command(@argv)
|
|
31
|
+
when 'preload'
|
|
32
|
+
preload_command(@argv)
|
|
33
|
+
when 'lsp'
|
|
34
|
+
lsp_command(@argv)
|
|
35
|
+
when 'help', '--help', '-h'
|
|
36
|
+
puts usage
|
|
37
|
+
0
|
|
38
|
+
else
|
|
39
|
+
warn "unknown command: #{command}"
|
|
40
|
+
warn usage
|
|
41
|
+
1
|
|
42
|
+
end
|
|
43
|
+
rescue Error => e
|
|
44
|
+
warn "kaisoku: #{e.message}"
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def run_command(argv)
|
|
51
|
+
options = parse_common_options(argv)
|
|
52
|
+
Commands::RunCommand.new(argv, options).call
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def watch_command(argv)
|
|
56
|
+
options = parse_common_options(argv) do |opts, parsed|
|
|
57
|
+
opts.on('--interval SECONDS', Float) { |value| parsed[:interval] = value }
|
|
58
|
+
opts.on('--watcher KIND') { |value| parsed[:watcher] = value }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Commands::WatchCommand.new(argv, options).call
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def map_command(argv)
|
|
65
|
+
options = parse_common_options(argv)
|
|
66
|
+
Commands::MapCommand.new(argv, options).call
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def doctor_command(argv)
|
|
70
|
+
options = parse_common_options(argv)
|
|
71
|
+
context = RuntimeContext.new(options)
|
|
72
|
+
preload = Preload::Server.new(configuration: context.configuration) if options[:preload]
|
|
73
|
+
|
|
74
|
+
puts Doctor.new(test_map: context.test_map, preload_server: preload).report
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def eval_command(argv)
|
|
79
|
+
Evaluator::CLI.new(argv).call
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def daemon_command(argv)
|
|
83
|
+
options = parse_daemon_options(argv)
|
|
84
|
+
subcommand = argv.shift
|
|
85
|
+
Commands::DaemonCommand.new([subcommand] + argv, options).call
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def preload_command(argv)
|
|
89
|
+
options = parse_daemon_options(argv)
|
|
90
|
+
subcommand = argv.shift
|
|
91
|
+
Commands::PreloadCommand.new([subcommand] + argv, options).call
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def lsp_command(argv)
|
|
95
|
+
options = parse_common_options(argv)
|
|
96
|
+
context = RuntimeContext.new(options)
|
|
97
|
+
LSP::Server.new(configuration: context.configuration).start
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_daemon_options(argv)
|
|
101
|
+
options = default_options
|
|
102
|
+
parser = OptionParser.new do |opts|
|
|
103
|
+
opts.on('--adapter NAME') { |value| options[:adapter] = value }
|
|
104
|
+
opts.on('--map PATH') { |value| options[:map_path] = value }
|
|
105
|
+
opts.on('--root PATH') { |value| options[:project_root] = value }
|
|
106
|
+
opts.on('--socket PATH') { |value| options[:socket_path] = value }
|
|
107
|
+
end
|
|
108
|
+
parser.order!(argv)
|
|
109
|
+
options
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_common_options(argv)
|
|
113
|
+
options = default_options
|
|
114
|
+
parser = OptionParser.new do |opts|
|
|
115
|
+
opts.on('--adapter NAME') { |value| options[:adapter] = value }
|
|
116
|
+
opts.on('--map PATH') { |value| options[:map_path] = value }
|
|
117
|
+
opts.on('--root PATH') { |value| options[:project_root] = value }
|
|
118
|
+
opts.on('--all') { options[:all] = true }
|
|
119
|
+
opts.on('--failed') { options[:failed] = true }
|
|
120
|
+
opts.on('--predictive') { options[:predictive] = true }
|
|
121
|
+
opts.on('--reporter FORMAT') { |value| options[:reporter] = value }
|
|
122
|
+
opts.on('--workers N', Integer) { |value| options[:workers] = value }
|
|
123
|
+
opts.on('--method-threshold N', Integer) { |value| options[:method_level_threshold] = value }
|
|
124
|
+
opts.on('--socket PATH') { |value| options[:socket_path] = value }
|
|
125
|
+
opts.on('--preload') { options[:preload] = true }
|
|
126
|
+
yield opts, options if block_given?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
parser.parse!(argv)
|
|
130
|
+
options
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def default_options
|
|
134
|
+
{
|
|
135
|
+
adapter: 'rspec',
|
|
136
|
+
project_root: Dir.pwd,
|
|
137
|
+
all: false,
|
|
138
|
+
failed: false,
|
|
139
|
+
predictive: false,
|
|
140
|
+
reporter: 'console',
|
|
141
|
+
workers: 1
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def usage
|
|
146
|
+
<<~USAGE
|
|
147
|
+
Usage:
|
|
148
|
+
kaisoku run [changed paths] [--all] [--failed] [--adapter rspec|minitest|test-unit|cucumber] [--workers N] [--method-threshold N]
|
|
149
|
+
kaisoku watch [paths] [--adapter rspec|minitest|test-unit|cucumber] [--interval SECONDS] [--watcher listen|poll]
|
|
150
|
+
kaisoku map build|export|import|hot [paths] [--adapter rspec|minitest|test-unit|cucumber]
|
|
151
|
+
kaisoku doctor [--map PATH]
|
|
152
|
+
kaisoku eval --total N --selected N [--ci]
|
|
153
|
+
kaisoku preload start|status|refresh [paths]
|
|
154
|
+
kaisoku lsp
|
|
155
|
+
USAGE
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Commands
|
|
5
|
+
class DaemonCommand
|
|
6
|
+
def initialize(argv, options)
|
|
7
|
+
@argv = argv
|
|
8
|
+
@options = options
|
|
9
|
+
@context = RuntimeContext.new(options)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
subcommand = @argv.shift
|
|
14
|
+
socket_path = @options[:socket_path] || Daemon::SocketPath.default(@context.configuration)
|
|
15
|
+
return Daemon::Server.new(socket_path: socket_path).start if subcommand == 'start'
|
|
16
|
+
return request(socket_path) if subcommand == 'request'
|
|
17
|
+
|
|
18
|
+
warn 'usage: kaisoku daemon start|request [--socket PATH]'
|
|
19
|
+
1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def request(socket_path)
|
|
25
|
+
response = Daemon::Client.new(socket_path: socket_path).request(@argv)
|
|
26
|
+
warn response['error'] if response['error']
|
|
27
|
+
response.fetch('status', 1)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module Commands
|
|
7
|
+
class MapCommand
|
|
8
|
+
def initialize(argv, options)
|
|
9
|
+
@argv = argv
|
|
10
|
+
@context = RuntimeContext.new(options)
|
|
11
|
+
@options = options
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
subcommand = @argv.shift
|
|
16
|
+
return build if subcommand == 'build'
|
|
17
|
+
return export if subcommand == 'export'
|
|
18
|
+
return import if subcommand == 'import'
|
|
19
|
+
return hot if subcommand == 'hot'
|
|
20
|
+
|
|
21
|
+
warn 'usage: kaisoku map build|export|import|hot [--adapter NAME] [--map PATH]'
|
|
22
|
+
1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build
|
|
28
|
+
entities = @context.adapter.discover(@argv)
|
|
29
|
+
seed_static_dependencies(entities)
|
|
30
|
+
puts "building map for #{entities.length} #{@options[:adapter]} entities"
|
|
31
|
+
results = Runner.new(
|
|
32
|
+
adapter: @context.adapter,
|
|
33
|
+
test_map: @context.test_map,
|
|
34
|
+
collector: CoverageCollector.new(configuration: @context.configuration),
|
|
35
|
+
reporter: nil
|
|
36
|
+
).run(entities)
|
|
37
|
+
|
|
38
|
+
results.values.any? { |result| result[:status] == 'failed' } ? 1 : 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def export
|
|
42
|
+
puts @context.test_map.export_json
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def import
|
|
47
|
+
path = @argv.shift
|
|
48
|
+
payload = path ? File.read(path) : $stdin.read
|
|
49
|
+
@context.test_map.import_json(payload)
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def hot
|
|
54
|
+
options = { threshold: 20, limit: 20 }
|
|
55
|
+
OptionParser.new do |opts|
|
|
56
|
+
opts.on('--threshold N', Integer) { |value| options[:threshold] = value }
|
|
57
|
+
opts.on('--limit N', Integer) { |value| options[:limit] = value }
|
|
58
|
+
end.parse!(@argv)
|
|
59
|
+
|
|
60
|
+
rows = @context.test_map.hot_files(
|
|
61
|
+
threshold: options[:threshold],
|
|
62
|
+
limit: options[:limit],
|
|
63
|
+
adapter: @options[:adapter]
|
|
64
|
+
)
|
|
65
|
+
return puts("no hot files at threshold #{options[:threshold]}") || 0 if rows.empty?
|
|
66
|
+
|
|
67
|
+
rows.each { |row| puts "#{row[:file]}\t#{row[:entity_count]} entities" }
|
|
68
|
+
0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def seed_static_dependencies(entities)
|
|
72
|
+
analyzer = StaticAnalysis::Analyzer.new(configuration: @context.configuration)
|
|
73
|
+
checksum = Checksum.new
|
|
74
|
+
|
|
75
|
+
entities.each do |entity|
|
|
76
|
+
deps = analyzer.inferred_dependencies_for_test(entity.file).to_h do |path|
|
|
77
|
+
[path, checksum.digest(@context.configuration.absolute_path(path))]
|
|
78
|
+
end.compact
|
|
79
|
+
next if deps.empty?
|
|
80
|
+
|
|
81
|
+
@context.test_map.replace_dependencies(entity, deps)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Commands
|
|
5
|
+
class PreloadCommand
|
|
6
|
+
def initialize(argv, options)
|
|
7
|
+
@argv = argv
|
|
8
|
+
@options = options
|
|
9
|
+
@context = RuntimeContext.new(options)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
subcommand = @argv.shift
|
|
14
|
+
return start if subcommand == 'start'
|
|
15
|
+
return request('status') if subcommand == 'status'
|
|
16
|
+
return request('refresh', 'changed_paths' => @argv) if subcommand == 'refresh'
|
|
17
|
+
|
|
18
|
+
warn 'usage: kaisoku preload start|status|refresh [paths]'
|
|
19
|
+
1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def start
|
|
25
|
+
server = Preload::Server.new(configuration: @context.configuration)
|
|
26
|
+
Preload::Daemon.new(socket_path: socket_path, server: server).start
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def request(command, payload = {})
|
|
30
|
+
response = Preload::Client.new(socket_path: socket_path).request(command, payload)
|
|
31
|
+
puts response
|
|
32
|
+
response['ok'] ? 0 : 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def socket_path
|
|
36
|
+
@options[:socket_path] || Preload::SocketPath.default(@context.configuration)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Commands
|
|
5
|
+
class RunCommand
|
|
6
|
+
def initialize(paths, options)
|
|
7
|
+
@paths = paths
|
|
8
|
+
@options = options
|
|
9
|
+
@context = RuntimeContext.new(options)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
all_entities = @context.adapter.discover
|
|
14
|
+
selection = build_run_selection(@paths, all_entities)
|
|
15
|
+
@context.test_map.clear! if selection.invalidate_map
|
|
16
|
+
entities = Prioritizer.new.order(selection.entities)
|
|
17
|
+
reporter.selection(selection, selected_count: entities.length, total_count: all_entities.length)
|
|
18
|
+
return 0 if entities.empty?
|
|
19
|
+
|
|
20
|
+
results = execute_entities(entities)
|
|
21
|
+
reporter.results(results)
|
|
22
|
+
results.values.any? { |result| result[:status] == 'failed' } ? 1 : 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_run_selection(paths, all_entities)
|
|
28
|
+
return Selection.new(entities: all_entities, fallback: false, reason: 'all tests requested') if @options[:all]
|
|
29
|
+
return failed_selection if @options[:failed]
|
|
30
|
+
return predictive_selection if @options[:predictive]
|
|
31
|
+
if paths.empty?
|
|
32
|
+
return Selection.new(entities: all_entities, fallback: false,
|
|
33
|
+
reason: 'no change paths supplied')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Selector.new(test_map: @context.test_map, configuration: @context.configuration).select(
|
|
37
|
+
changed_paths: paths,
|
|
38
|
+
all_entities: all_entities
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def failed_selection
|
|
43
|
+
Selection.new(
|
|
44
|
+
entities: @context.test_map.failed_entities(adapter: @options[:adapter]),
|
|
45
|
+
reason: 'previous failures'
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def predictive_selection
|
|
50
|
+
Selection.new(
|
|
51
|
+
entities: @context.test_map.failed_entities(adapter: @options[:adapter]),
|
|
52
|
+
safe: false,
|
|
53
|
+
reason: 'predictive mode: previous failures only',
|
|
54
|
+
warnings: ['predictive selection is unsafe and may miss failures']
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def execute_entities(entities)
|
|
59
|
+
return execute_parallel(entities) if @options[:workers].to_i > 1
|
|
60
|
+
return execute_with_preload(entities) if @options[:preload]
|
|
61
|
+
|
|
62
|
+
Runner.new(
|
|
63
|
+
adapter: @context.adapter,
|
|
64
|
+
test_map: @context.test_map,
|
|
65
|
+
collector: CoverageCollector.new(configuration: @context.configuration),
|
|
66
|
+
reporter: nil
|
|
67
|
+
).run(entities)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def execute_parallel(entities)
|
|
71
|
+
results = Scheduler::ForkPool.new(
|
|
72
|
+
configuration: @context.configuration,
|
|
73
|
+
workers: @options[:workers]
|
|
74
|
+
).run(entities)
|
|
75
|
+
Scheduler::ResultPersister.new(test_map: @context.test_map).persist(results)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def execute_with_preload(entities)
|
|
79
|
+
server = Preload::Server.new(configuration: @context.configuration)
|
|
80
|
+
results = Preload::FallbackRunner.new(preload_server: server).run do
|
|
81
|
+
adapter = Adapters::Factory.build(@context.configuration.adapter, configuration: @context.configuration)
|
|
82
|
+
collector = CoverageCollector.new(configuration: @context.configuration)
|
|
83
|
+
entities.map { |entity| Worker.new(adapter: adapter, collector: collector).run(entity) }
|
|
84
|
+
end
|
|
85
|
+
Scheduler::ResultPersister.new(test_map: @context.test_map).persist(results)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reporter
|
|
89
|
+
@reporter ||= Reporters::Factory.build(@options[:reporter])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Commands
|
|
5
|
+
class WatchCommand
|
|
6
|
+
def initialize(paths, options)
|
|
7
|
+
@paths = paths
|
|
8
|
+
@options = options
|
|
9
|
+
@context = RuntimeContext.new(options)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
watcher = watcher_class.new(
|
|
14
|
+
configuration: @context.configuration,
|
|
15
|
+
paths: @paths.empty? ? Watcher::DEFAULT_PATHS : @paths,
|
|
16
|
+
interval: @options[:interval] || 0.2
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
puts "watching #{watcher.paths.join(', ')}"
|
|
20
|
+
WatchSession.new(watcher: watcher, debounce: 0.05, runner: runner).start
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def runner
|
|
26
|
+
proc do |changed_paths|
|
|
27
|
+
puts "changed #{changed_paths.join(', ')}"
|
|
28
|
+
RunCommand.new(changed_paths, @options.merge(all: false, failed: false)).call
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def watcher_class
|
|
33
|
+
@options[:watcher] == 'poll' ? Watcher : ListenWatcher
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_BOOT_GLOBS = [
|
|
8
|
+
'config/**',
|
|
9
|
+
'config.ru',
|
|
10
|
+
'db/schema.rb',
|
|
11
|
+
'db/migrate/**',
|
|
12
|
+
'.env',
|
|
13
|
+
'.env.*'
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
MAP_INVALIDATING_FILES = [
|
|
17
|
+
'Gemfile.lock',
|
|
18
|
+
'.ruby-version',
|
|
19
|
+
'.tool-versions'
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :project_root, :map_path, :adapter, :track_paths, :method_level_threshold
|
|
23
|
+
|
|
24
|
+
def initialize(project_root: Dir.pwd, map_path: nil, adapter: 'rspec', track_paths: [], method_level_threshold: 20)
|
|
25
|
+
@project_root = File.expand_path(project_root)
|
|
26
|
+
@map_path = map_path || File.join(@project_root, '.kaisoku', 'map.db')
|
|
27
|
+
@adapter = adapter.to_s
|
|
28
|
+
@track_paths = track_paths.map { |path| File.expand_path(path, @project_root) }
|
|
29
|
+
@method_level_threshold = method_level_threshold.to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def relative_path(path)
|
|
33
|
+
return path.to_s if path.to_s.start_with?('__')
|
|
34
|
+
|
|
35
|
+
expanded = File.expand_path(path, project_root)
|
|
36
|
+
root = Pathname.new(project_root)
|
|
37
|
+
target = Pathname.new(expanded)
|
|
38
|
+
|
|
39
|
+
target.relative_path_from(root).to_s
|
|
40
|
+
rescue ArgumentError
|
|
41
|
+
path.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def absolute_path(path)
|
|
45
|
+
File.expand_path(path, project_root)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def project_file?(path)
|
|
49
|
+
expanded = File.expand_path(path)
|
|
50
|
+
expanded == project_root ||
|
|
51
|
+
expanded.start_with?("#{project_root}#{File::SEPARATOR}") ||
|
|
52
|
+
track_paths.any? { |tracked| expanded.start_with?("#{tracked}#{File::SEPARATOR}") }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def boot_impact_file?(path)
|
|
56
|
+
relative = relative_path(path)
|
|
57
|
+
DEFAULT_BOOT_GLOBS.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def invalidates_map?(path)
|
|
61
|
+
MAP_INVALIDATING_FILES.include?(relative_path(path))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_file?(path)
|
|
65
|
+
relative = relative_path(path)
|
|
66
|
+
relative.match?(%r{\A(spec/.+_spec|test/.+_test)\.rb\z})
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'coverage'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
class CoverageCollector
|
|
7
|
+
Result = Struct.new(:dependencies, :skipped, :reason, keyword_init: true) do
|
|
8
|
+
def skipped?
|
|
9
|
+
skipped
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(configuration: Configuration.new, checksum: Checksum.new)
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
@checksum = checksum
|
|
16
|
+
@method_map = MethodMap.new(configuration: configuration)
|
|
17
|
+
@started_by_collector = false
|
|
18
|
+
@loaded_features_before = []
|
|
19
|
+
@conflict_reason = nil
|
|
20
|
+
@environment_tracker = EnvironmentTracker.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start_entity
|
|
24
|
+
@loaded_features_before = $LOADED_FEATURES.dup
|
|
25
|
+
@conflict_reason = nil
|
|
26
|
+
@environment_tracker = EnvironmentTracker.new
|
|
27
|
+
|
|
28
|
+
if Coverage.running? && !@started_by_collector
|
|
29
|
+
@conflict_reason = 'Coverage is already running; map update skipped'
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
return if @started_by_collector
|
|
34
|
+
|
|
35
|
+
Coverage.start(oneshot_lines: true)
|
|
36
|
+
@started_by_collector = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def finish_entity
|
|
40
|
+
return Result.new(dependencies: {}, skipped: true, reason: @conflict_reason) if @conflict_reason
|
|
41
|
+
|
|
42
|
+
coverage = Coverage.result(stop: false, clear: true)
|
|
43
|
+
coverage_files = coverage.keys
|
|
44
|
+
loaded_files = $LOADED_FEATURES - @loaded_features_before
|
|
45
|
+
files = (coverage_files + loaded_files).uniq
|
|
46
|
+
deps = dependency_checksums(files)
|
|
47
|
+
deps.merge!(method_dependency_checksums(coverage))
|
|
48
|
+
deps[EnvironmentTracker::DEPENDENCY_KEY] = @environment_tracker.dependency.last if @environment_tracker.changed?
|
|
49
|
+
|
|
50
|
+
Result.new(dependencies: deps, skipped: false, reason: nil)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def dependency_checksums(files)
|
|
56
|
+
files.each_with_object({}) do |file, deps|
|
|
57
|
+
next unless tracked_file?(file)
|
|
58
|
+
|
|
59
|
+
absolute = File.expand_path(file)
|
|
60
|
+
checksum = @checksum.digest(absolute)
|
|
61
|
+
next unless checksum
|
|
62
|
+
|
|
63
|
+
deps[@configuration.relative_path(absolute)] = checksum
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tracked_file?(file)
|
|
68
|
+
absolute = File.expand_path(file)
|
|
69
|
+
@configuration.project_file?(absolute) && File.file?(absolute)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def method_dependency_checksums(coverage)
|
|
73
|
+
coverage.each_with_object({}) do |(file, data), deps|
|
|
74
|
+
next unless tracked_file?(file)
|
|
75
|
+
|
|
76
|
+
touched = data[:oneshot_lines] || data['oneshot_lines']
|
|
77
|
+
keys = @method_map.keys_for_lines(file, touched)
|
|
78
|
+
checksums = @method_map.checksums(file)
|
|
79
|
+
keys.each { |key| deps[key] = checksums[key] if checksums[key] }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'msgpack'
|
|
5
|
+
|
|
6
|
+
module Kaisoku
|
|
7
|
+
module Daemon
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(socket_path:)
|
|
10
|
+
@socket_path = socket_path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def request(argv)
|
|
14
|
+
UNIXSocket.open(@socket_path) do |socket|
|
|
15
|
+
socket.write(MessagePack.pack('argv' => argv))
|
|
16
|
+
socket.close_write
|
|
17
|
+
MessagePack.unpack(socket.read)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'msgpack'
|
|
6
|
+
|
|
7
|
+
module Kaisoku
|
|
8
|
+
module Daemon
|
|
9
|
+
class Server
|
|
10
|
+
def initialize(socket_path:, cli_factory: ->(argv) { CLI.new(argv) })
|
|
11
|
+
@socket_path = socket_path
|
|
12
|
+
@cli_factory = cli_factory
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
17
|
+
FileUtils.rm_f(@socket_path)
|
|
18
|
+
|
|
19
|
+
UNIXServer.open(@socket_path) do |server|
|
|
20
|
+
loop { handle(server.accept) }
|
|
21
|
+
end
|
|
22
|
+
ensure
|
|
23
|
+
File.unlink(@socket_path) if @socket_path && File.socket?(@socket_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def handle(socket)
|
|
29
|
+
request = MessagePack.unpack(socket.read)
|
|
30
|
+
status = @cli_factory.call(Array(request['argv'])).call
|
|
31
|
+
socket.write(MessagePack.pack('status' => status))
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
socket.write(MessagePack.pack('status' => 1, 'error' => "#{e.class}: #{e.message}"))
|
|
34
|
+
ensure
|
|
35
|
+
socket.close
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|