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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Evaluator
5
+ class Report
6
+ def initialize(results)
7
+ @results = results
8
+ end
9
+
10
+ def render
11
+ [
12
+ "=== Kaisoku Evaluation Report (#{@results.length} commits) ===",
13
+ "Safety violations : #{safety_violations}",
14
+ "Selection ratio : #{format('%.1f%%', selection_ratio * 100)}",
15
+ "Reduction ratio : #{format('%.1f%%', reduction_ratio * 100)}",
16
+ "Precision : #{format('%.1f%%', precision * 100)}",
17
+ "APFD : #{format('%.2f', apfd)}",
18
+ "E2E time ratio : #{format('%.1f%%', e2e_time_ratio * 100)}",
19
+ "Fallback commits : #{fallback_count}/#{@results.length}",
20
+ diagnostics
21
+ ].join("\n")
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ commit_count: @results.length,
27
+ safety_violations: safety_violations,
28
+ selection_ratio: selection_ratio,
29
+ reduction_ratio: reduction_ratio,
30
+ precision: precision,
31
+ apfd: apfd,
32
+ e2e_time_ratio: e2e_time_ratio,
33
+ fallback_count: fallback_count,
34
+ commits: @results.map { |result| result_payload(result) }
35
+ }
36
+ end
37
+
38
+ def safe?
39
+ safety_violations.zero?
40
+ end
41
+
42
+ private
43
+
44
+ def safety_violations
45
+ @results.sum { |result| Array(result.safety_violations).length }
46
+ end
47
+
48
+ def precision
49
+ return 1.0 if @results.empty?
50
+
51
+ @results.sum { |result| result.precision || 1.0 } / @results.length
52
+ end
53
+
54
+ def selection_ratio
55
+ median(selection_ratios)
56
+ end
57
+
58
+ def reduction_ratio
59
+ ratios = selection_ratios
60
+ return 0.0 if ratios.empty?
61
+
62
+ 1.0 - median(ratios)
63
+ end
64
+
65
+ def e2e_time_ratio
66
+ full = @results.sum(&:full_time)
67
+ return 0.0 if full.zero?
68
+
69
+ @results.sum(&:selected_time) / full
70
+ end
71
+
72
+ def apfd
73
+ return 1.0 if @results.empty?
74
+
75
+ @results.sum { |result| result.apfd || 1.0 } / @results.length
76
+ end
77
+
78
+ def fallback_count
79
+ @results.count(&:fallback)
80
+ end
81
+
82
+ def diagnostics
83
+ violations = @results.select { |result| Array(result.safety_violations).any? }
84
+ return 'Diagnostics : none' if violations.empty?
85
+
86
+ lines = violations.flat_map do |result|
87
+ result.safety_violations.map { |id| "#{result.commit}: missed #{id}" }
88
+ end
89
+ "Diagnostics :\n#{lines.join("\n")}"
90
+ end
91
+
92
+ def result_payload(result)
93
+ {
94
+ commit: result.commit,
95
+ full_status: result.full_status,
96
+ selected_status: result.selected_status,
97
+ full_time: result.full_time,
98
+ selected_time: result.selected_time,
99
+ fallback: result.fallback,
100
+ safety_violations: Array(result.safety_violations),
101
+ precision: result.precision,
102
+ apfd: result.apfd,
103
+ changed_files: Array(result.changed_files),
104
+ selected_count: result.selected_count,
105
+ total_count: result.total_count
106
+ }
107
+ end
108
+
109
+ def median(values)
110
+ return 0.0 if values.empty?
111
+
112
+ sorted = values.sort
113
+ middle = sorted.length / 2
114
+ return sorted[middle] if sorted.length.odd?
115
+
116
+ (sorted[middle - 1] + sorted[middle]) / 2.0
117
+ end
118
+
119
+ def selection_ratios
120
+ @results.filter_map do |result|
121
+ next unless result.total_count.to_i.positive?
122
+
123
+ result.selected_count.to_f / result.total_count
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ class HybridPolicy
5
+ def initialize(test_map:, configuration:)
6
+ @test_map = test_map
7
+ @configuration = configuration
8
+ end
9
+
10
+ def method_level?(path)
11
+ @test_map.dependency_entity_count(path, adapter: @configuration.adapter) >= @configuration.method_level_threshold
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'listen'
4
+
5
+ module Kaisoku
6
+ class ListenWatcher
7
+ DEFAULT_PATHS = Watcher::DEFAULT_PATHS
8
+ attr_reader :configuration, :paths
9
+
10
+ def initialize(configuration: Configuration.new, paths: DEFAULT_PATHS, interval: 0.2)
11
+ @configuration = configuration
12
+ @paths = Array(paths).empty? ? DEFAULT_PATHS : Array(paths)
13
+ @interval = interval
14
+ end
15
+
16
+ def each_change
17
+ listener = Listen.to(*existing_paths, latency: @interval) do |modified, added, removed|
18
+ changed = (modified + added + removed).map { |path| configuration.relative_path(path) }.sort
19
+ yield changed unless changed.empty?
20
+ end
21
+ listener.start
22
+ sleep
23
+ ensure
24
+ listener&.stop
25
+ end
26
+
27
+ private
28
+
29
+ def existing_paths
30
+ found = paths.map { |path| configuration.absolute_path(path) }.select { |path| File.exist?(path) }
31
+ found.empty? ? [configuration.project_root] : found
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Kaisoku
6
+ module LSP
7
+ class MessageIO
8
+ def initialize(input: $stdin, output: $stdout)
9
+ @input = input
10
+ @output = output
11
+ end
12
+
13
+ def read
14
+ headers = read_headers
15
+ length = headers['content-length'].to_i
16
+ return nil if length.zero?
17
+
18
+ JSON.parse(@input.read(length))
19
+ end
20
+
21
+ def write(payload)
22
+ json = JSON.generate(payload)
23
+ @output.write("Content-Length: #{json.bytesize}\r\n\r\n#{json}")
24
+ @output.flush
25
+ end
26
+
27
+ private
28
+
29
+ def read_headers
30
+ headers = {}
31
+ while (line = @input.gets)
32
+ line = line.strip
33
+ break if line.empty?
34
+
35
+ key, value = line.split(/:\s*/, 2)
36
+ headers[key.downcase] = value
37
+ end
38
+ headers
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Kaisoku
6
+ module LSP
7
+ class Server
8
+ def initialize(configuration: Configuration.new, io: MessageIO.new)
9
+ @configuration = configuration
10
+ @io = io
11
+ end
12
+
13
+ def start
14
+ while (message = @io.read)
15
+ handle(message)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def handle(message)
22
+ case message['method']
23
+ when 'initialize'
24
+ respond(message['id'], capabilities)
25
+ when 'shutdown'
26
+ respond(message['id'], nil)
27
+ when 'textDocument/didSave'
28
+ publish_diagnostics(message)
29
+ when 'textDocument/codeLens'
30
+ respond(message['id'], code_lenses(message))
31
+ end
32
+ end
33
+
34
+ def respond(id, result)
35
+ @io.write(jsonrpc: '2.0', id: id, result: result)
36
+ end
37
+
38
+ def publish_diagnostics(message)
39
+ uri = message.dig('params', 'textDocument', 'uri')
40
+ selection = selection_for(uri_to_path(uri))
41
+ @io.write(
42
+ jsonrpc: '2.0',
43
+ method: 'textDocument/publishDiagnostics',
44
+ params: { uri: uri, diagnostics: diagnostics(selection) }
45
+ )
46
+ end
47
+
48
+ def selection_for(path)
49
+ context = RuntimeContext.new(adapter: @configuration.adapter, project_root: @configuration.project_root)
50
+ Selector.new(test_map: context.test_map, configuration: @configuration).select(
51
+ changed_paths: [path],
52
+ all_entities: context.adapter.discover
53
+ )
54
+ end
55
+
56
+ def diagnostics(selection)
57
+ [{
58
+ severity: selection.safe? ? 3 : 2,
59
+ source: 'kaisoku',
60
+ message: "selected #{selection.count} test entities: #{selection.reason}",
61
+ range: {
62
+ start: { line: 0, character: 0 },
63
+ end: { line: 0, character: 0 }
64
+ }
65
+ }]
66
+ end
67
+
68
+ def code_lenses(message)
69
+ uri = message.dig('params', 'textDocument', 'uri')
70
+ selection = selection_for(uri_to_path(uri))
71
+ [{
72
+ range: {
73
+ start: { line: 0, character: 0 },
74
+ end: { line: 0, character: 0 }
75
+ },
76
+ command: {
77
+ title: "Kaisoku: #{selection.count} selected test entities",
78
+ command: 'kaisoku.showSelection',
79
+ arguments: [selection.entities.map(&:id), selection.reason]
80
+ }
81
+ }]
82
+ end
83
+
84
+ def capabilities
85
+ {
86
+ capabilities: {
87
+ textDocumentSync: { save: true },
88
+ codeLensProvider: { resolveProvider: false }
89
+ }
90
+ }
91
+ end
92
+
93
+ def uri_to_path(uri)
94
+ return uri unless uri&.start_with?('file://')
95
+
96
+ URI.decode_www_form_component(uri.sub('file://', ''))
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'prism'
5
+
6
+ module Kaisoku
7
+ class MethodMap
8
+ TOP_LEVEL = '__toplevel__'
9
+
10
+ def initialize(configuration: Configuration.new)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def checksums(path)
15
+ absolute = @configuration.absolute_path(path)
16
+ return {} unless File.file?(absolute) && File.extname(absolute) == '.rb'
17
+
18
+ source = File.read(absolute)
19
+ lines = source.lines
20
+ ranges = method_ranges(source)
21
+ ranges.to_h do |name, range|
22
+ ["#{@configuration.relative_path(path)}##{name}", digest(lines[(range.begin - 1)..(range.end - 1)].join)]
23
+ end.merge(top_level_checksum(path, lines, ranges.values))
24
+ rescue StandardError
25
+ {}
26
+ end
27
+
28
+ def keys_for_lines(path, touched_lines)
29
+ absolute = @configuration.absolute_path(path)
30
+ return [] unless File.file?(absolute) && File.extname(absolute) == '.rb'
31
+
32
+ ranges = method_ranges(File.read(absolute))
33
+ touched = Array(touched_lines).map(&:to_i)
34
+ keys = ranges.filter_map do |name, range|
35
+ "#{@configuration.relative_path(path)}##{name}" if touched.any? { |line| range.cover?(line) }
36
+ end
37
+ keys << "#{@configuration.relative_path(path)}##{TOP_LEVEL}" if top_level_touched?(touched, ranges.values)
38
+ keys
39
+ rescue StandardError
40
+ []
41
+ end
42
+
43
+ private
44
+
45
+ def method_ranges(source)
46
+ nodes(Prism.parse(source).value).each_with_object({}) do |node, ranges|
47
+ next unless node.is_a?(Prism::DefNode)
48
+
49
+ ranges[node.name.to_s] = node.location.start_line..node.location.end_line
50
+ end
51
+ end
52
+
53
+ def nodes(root, &block)
54
+ return enum_for(:nodes, root) unless block_given?
55
+
56
+ yield root
57
+ root.child_nodes.compact.each { |child| nodes(child, &block) } if root.respond_to?(:child_nodes)
58
+ end
59
+
60
+ def top_level_checksum(path, lines, ranges)
61
+ top_lines = lines.each_with_index.filter_map do |line, index|
62
+ line unless ranges.any? { |range| range.cover?(index + 1) }
63
+ end
64
+ { "#{@configuration.relative_path(path)}##{TOP_LEVEL}" => digest(top_lines.join) }
65
+ end
66
+
67
+ def top_level_touched?(touched_lines, ranges)
68
+ touched_lines.any? { |line| ranges.none? { |range| range.cover?(line) } }
69
+ end
70
+
71
+ def digest(source)
72
+ Digest::SHA256.hexdigest(source)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'msgpack'
4
+ require 'socket'
5
+
6
+ module Kaisoku
7
+ module Preload
8
+ class Client
9
+ def initialize(socket_path:)
10
+ @socket_path = socket_path
11
+ end
12
+
13
+ def request(command, payload = {})
14
+ UNIXSocket.open(@socket_path) do |socket|
15
+ socket.write(MessagePack.pack(payload.merge('command' => command)))
16
+ socket.close_write
17
+ MessagePack.unpack(socket.read)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'msgpack'
5
+ require 'socket'
6
+
7
+ module Kaisoku
8
+ module Preload
9
+ class Daemon
10
+ def initialize(socket_path:, server:)
11
+ @socket_path = socket_path
12
+ @server = server
13
+ end
14
+
15
+ def start
16
+ FileUtils.mkdir_p(File.dirname(@socket_path))
17
+ FileUtils.rm_f(@socket_path)
18
+ UNIXServer.open(@socket_path) { |socket| loop { handle(socket.accept) } }
19
+ ensure
20
+ File.unlink(@socket_path) if @socket_path && File.socket?(@socket_path)
21
+ end
22
+
23
+ private
24
+
25
+ def handle(socket)
26
+ request = MessagePack.unpack(socket.read)
27
+ response = dispatch(request)
28
+ socket.write(MessagePack.pack(response))
29
+ rescue StandardError => e
30
+ socket.write(MessagePack.pack('ok' => false, 'error' => "#{e.class}: #{e.message}"))
31
+ ensure
32
+ socket.close
33
+ end
34
+
35
+ def dispatch(request)
36
+ case request['command']
37
+ when 'status'
38
+ { 'ok' => true, 'status' => @server.status }
39
+ when 'refresh'
40
+ changed = Array(request['changed_paths'])
41
+ { 'ok' => true, 'refreshed' => @server.refresh_if_needed(changed), 'status' => @server.status }
42
+ else
43
+ { 'ok' => false, 'error' => 'unknown preload command' }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Preload
5
+ class FallbackRunner
6
+ def initialize(preload_server:)
7
+ @preload_server = preload_server
8
+ end
9
+
10
+ def run(&)
11
+ return yield if @preload_server.disabled?
12
+
13
+ preload_result = @preload_server.run(&)
14
+ return preload_result unless suspicious_result?(preload_result)
15
+
16
+ cold_result = yield
17
+ disable_if_different(preload_result, cold_result)
18
+ cold_result
19
+ rescue StandardError => e
20
+ @preload_server.disable!("preload failed: #{e.class}: #{e.message}")
21
+ yield
22
+ end
23
+
24
+ private
25
+
26
+ def suspicious_result?(result)
27
+ Array(result).any? do |item|
28
+ item.respond_to?(:error) && item.error.to_s.match?(/Bootsnap|LoadError|NameError|Zeitwerk|ActiveRecord/)
29
+ end
30
+ end
31
+
32
+ def disable_if_different(preload_result, cold_result)
33
+ return if signature(preload_result) == signature(cold_result)
34
+
35
+ @preload_server.disable!('preload result differed from cold run')
36
+ end
37
+
38
+ def signature(result)
39
+ Array(result).map do |item|
40
+ item.respond_to?(:status) ? [item.entity&.id, item.status, item.error] : item
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+
5
+ module Kaisoku
6
+ module Preload
7
+ class Server
8
+ attr_reader :generation, :last_restart_reason, :last_heartbeat_at, :worker_failures, :disabled_reason
9
+
10
+ def initialize(configuration: Configuration.new, checksum: Checksum.new)
11
+ @configuration = configuration
12
+ @checksum = checksum
13
+ @generation = 0
14
+ @last_restart_reason = 'initial boot'
15
+ @last_heartbeat_at = nil
16
+ @worker_failures = 0
17
+ @disabled_reason = nil
18
+ @loaded_checksums = {}
19
+ boot!
20
+ end
21
+
22
+ def boot!
23
+ @generation += 1
24
+ heartbeat!
25
+ @loaded_checksums = loaded_file_checksums
26
+ end
27
+
28
+ def stale_for?(changed_paths)
29
+ Array(changed_paths).any? do |path|
30
+ relative = @configuration.relative_path(path)
31
+ @configuration.boot_impact_file?(relative) || loaded_file_changed?(relative)
32
+ end
33
+ end
34
+
35
+ def refresh_if_needed(changed_paths)
36
+ return false if disabled?
37
+ return false unless stale_for?(changed_paths)
38
+
39
+ @last_restart_reason = "changed: #{Array(changed_paths).join(', ')}"
40
+ boot!
41
+ true
42
+ end
43
+
44
+ def run
45
+ raise Error, "preload disabled: #{disabled_reason}" if disabled?
46
+
47
+ refresh_if_needed([])
48
+ heartbeat!
49
+ reader, writer = IO.pipe
50
+ pid = fork do
51
+ begin
52
+ reader.close
53
+ Marshal.dump(yield, writer)
54
+ ensure
55
+ writer.close
56
+ end
57
+ exit! 0
58
+ end
59
+ writer.close
60
+ result = Marshal.load(reader)
61
+ Process.wait(pid)
62
+ if $CHILD_STATUS.success?
63
+ @worker_failures = 0
64
+ heartbeat!
65
+ return result
66
+ end
67
+
68
+ @worker_failures += 1
69
+ @last_restart_reason = "worker failure: status #{$CHILD_STATUS.exitstatus}"
70
+ boot!
71
+ raise Error, "preload worker exited with status #{$CHILD_STATUS.exitstatus}"
72
+ rescue StandardError
73
+ raise
74
+ ensure
75
+ reader&.close
76
+ end
77
+
78
+ def status
79
+ {
80
+ generation: generation,
81
+ loaded_files: @loaded_checksums.length,
82
+ last_restart_reason: last_restart_reason,
83
+ last_heartbeat_at: last_heartbeat_at,
84
+ worker_failures: worker_failures,
85
+ disabled_reason: disabled_reason,
86
+ healthy: !disabled?
87
+ }
88
+ end
89
+
90
+ def disable!(reason)
91
+ @disabled_reason = reason
92
+ @last_restart_reason = reason
93
+ end
94
+
95
+ def disabled?
96
+ !disabled_reason.nil?
97
+ end
98
+
99
+ private
100
+
101
+ def heartbeat!
102
+ @last_heartbeat_at = Time.now.to_i
103
+ end
104
+
105
+ def loaded_file_changed?(relative)
106
+ return false unless @loaded_checksums.key?(relative)
107
+
108
+ current = @checksum.digest(@configuration.absolute_path(relative))
109
+ current != @loaded_checksums[relative]
110
+ end
111
+
112
+ def loaded_file_checksums
113
+ $LOADED_FEATURES.each_with_object({}) do |file, checksums|
114
+ absolute = File.expand_path(file)
115
+ next unless @configuration.project_file?(absolute)
116
+
117
+ digest = @checksum.digest(absolute)
118
+ checksums[@configuration.relative_path(absolute)] = digest if digest
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Preload
5
+ module SocketPath
6
+ module_function
7
+
8
+ def default(configuration)
9
+ File.join(configuration.project_root, '.kaisoku', 'preload.sock')
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ class Prioritizer
5
+ def order(entities)
6
+ entities.sort_by do |entity|
7
+ [
8
+ entity.failed? ? 0 : 1,
9
+ -(entity.last_failed_at || 0),
10
+ -entity.failure_count.to_i,
11
+ entity.avg_duration_ms || Float::INFINITY,
12
+ entity.file,
13
+ entity.identifier
14
+ ]
15
+ end
16
+ end
17
+ end
18
+ end