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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +133 -0
  4. data/Rakefile +8 -0
  5. data/exe/kaisoku +6 -0
  6. data/lib/kaisoku/adapter.rb +81 -0
  7. data/lib/kaisoku/adapters/cucumber.rb +91 -0
  8. data/lib/kaisoku/adapters/minitest.rb +96 -0
  9. data/lib/kaisoku/adapters/rspec.rb +111 -0
  10. data/lib/kaisoku/adapters/test_unit.rb +35 -0
  11. data/lib/kaisoku/checksum.rb +55 -0
  12. data/lib/kaisoku/cli.rb +158 -0
  13. data/lib/kaisoku/commands/daemon_command.rb +31 -0
  14. data/lib/kaisoku/commands/map_command.rb +86 -0
  15. data/lib/kaisoku/commands/preload_command.rb +40 -0
  16. data/lib/kaisoku/commands/run_command.rb +93 -0
  17. data/lib/kaisoku/commands/watch_command.rb +37 -0
  18. data/lib/kaisoku/configuration.rb +69 -0
  19. data/lib/kaisoku/coverage_collector.rb +83 -0
  20. data/lib/kaisoku/daemon/client.rb +22 -0
  21. data/lib/kaisoku/daemon/server.rb +39 -0
  22. data/lib/kaisoku/daemon/socket_path.rb +13 -0
  23. data/lib/kaisoku/doctor.rb +46 -0
  24. data/lib/kaisoku/entity.rb +77 -0
  25. data/lib/kaisoku/entity_result.rb +19 -0
  26. data/lib/kaisoku/environment_tracker.rb +28 -0
  27. data/lib/kaisoku/evaluator/cli.rb +112 -0
  28. data/lib/kaisoku/evaluator/command_result.rb +64 -0
  29. data/lib/kaisoku/evaluator/history.rb +23 -0
  30. data/lib/kaisoku/evaluator/metrics.rb +45 -0
  31. data/lib/kaisoku/evaluator/mutation_seeder.rb +78 -0
  32. data/lib/kaisoku/evaluator/replay.rb +121 -0
  33. data/lib/kaisoku/evaluator/report.rb +128 -0
  34. data/lib/kaisoku/hybrid_policy.rb +14 -0
  35. data/lib/kaisoku/listen_watcher.rb +34 -0
  36. data/lib/kaisoku/lsp/message_io.rb +42 -0
  37. data/lib/kaisoku/lsp/server.rb +100 -0
  38. data/lib/kaisoku/method_map.rb +75 -0
  39. data/lib/kaisoku/preload/client.rb +22 -0
  40. data/lib/kaisoku/preload/daemon.rb +48 -0
  41. data/lib/kaisoku/preload/fallback_runner.rb +45 -0
  42. data/lib/kaisoku/preload/server.rb +123 -0
  43. data/lib/kaisoku/preload/socket_path.rb +13 -0
  44. data/lib/kaisoku/prioritizer.rb +18 -0
  45. data/lib/kaisoku/rails/integration.rb +56 -0
  46. data/lib/kaisoku/reporters/console.rb +24 -0
  47. data/lib/kaisoku/reporters/factory.rb +24 -0
  48. data/lib/kaisoku/reporters/json.rb +28 -0
  49. data/lib/kaisoku/reporters/junit.rb +50 -0
  50. data/lib/kaisoku/reporters/tui.rb +29 -0
  51. data/lib/kaisoku/runner.rb +48 -0
  52. data/lib/kaisoku/runtime_context.rb +28 -0
  53. data/lib/kaisoku/scheduler/fork_pool.rb +105 -0
  54. data/lib/kaisoku/scheduler/result_persister.rb +43 -0
  55. data/lib/kaisoku/selection.rb +51 -0
  56. data/lib/kaisoku/selector.rb +173 -0
  57. data/lib/kaisoku/snapshot/inline.rb +51 -0
  58. data/lib/kaisoku/snapshot/rspec.rb +22 -0
  59. data/lib/kaisoku/static_analysis/analyzer.rb +120 -0
  60. data/lib/kaisoku/test_map/dependency_queries.rb +81 -0
  61. data/lib/kaisoku/test_map/health.rb +24 -0
  62. data/lib/kaisoku/test_map/hot_files.rb +32 -0
  63. data/lib/kaisoku/test_map/schema.rb +81 -0
  64. data/lib/kaisoku/test_map/snapshot.rb +53 -0
  65. data/lib/kaisoku/test_map/stats.rb +19 -0
  66. data/lib/kaisoku/test_map.rb +170 -0
  67. data/lib/kaisoku/version.rb +5 -0
  68. data/lib/kaisoku/watch_session.rb +35 -0
  69. data/lib/kaisoku/watcher.rb +63 -0
  70. data/lib/kaisoku/worker.rb +46 -0
  71. data/lib/kaisoku.rb +67 -0
  72. metadata +186 -0
@@ -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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Daemon
5
+ module SocketPath
6
+ module_function
7
+
8
+ def default(configuration)
9
+ File.join(configuration.project_root, '.kaisoku', 'daemon.sock')
10
+ end
11
+ end
12
+ end
13
+ end