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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ VERSION = '0.1.0'
5
+ 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