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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Rails
5
+ class Integration
6
+ DB_PATTERNS = %w[db/schema.rb db/structure.sql db/migrate/**].freeze
7
+ ROUTE_PATTERNS = %w[config/routes.rb config/routes/**].freeze
8
+ DB_DEPENDENCY_PREFIXES = %w[app/models db/ config/database].freeze
9
+
10
+ def initialize(configuration: Configuration.new)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def rails_project?
15
+ File.file?(@configuration.absolute_path('config/application.rb'))
16
+ end
17
+
18
+ def boot_impact_file?(path)
19
+ relative = @configuration.relative_path(path)
20
+ patterns.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
21
+ end
22
+
23
+ def database_change?(path)
24
+ relative = @configuration.relative_path(path)
25
+ DB_PATTERNS.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
26
+ end
27
+
28
+ def route_change?(path)
29
+ relative = @configuration.relative_path(path)
30
+ ROUTE_PATTERNS.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
31
+ end
32
+
33
+ def db_dependency_prefixes
34
+ DB_DEPENDENCY_PREFIXES
35
+ end
36
+
37
+ def database_name(base_name, worker_index)
38
+ return base_name if worker_index.to_i <= 0
39
+
40
+ "#{base_name}_#{worker_index}"
41
+ end
42
+
43
+ def wrap_reloader(&)
44
+ return yield unless defined?(::Rails) && ::Rails.respond_to?(:application)
45
+
46
+ ::Rails.application.reloader.wrap(&)
47
+ end
48
+
49
+ private
50
+
51
+ def patterns
52
+ DB_PATTERNS + ROUTE_PATTERNS
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Reporters
5
+ class Console
6
+ def initialize(output: $stdout)
7
+ @output = output
8
+ end
9
+
10
+ def selection(selection, selected_count:, total_count:)
11
+ safety = selection.safe? ? 'safe' : 'unsafe'
12
+ fallback = selection.fallback? ? ' fallback' : ''
13
+ @output.puts "selected #{selected_count}/#{total_count} test entities (reason: #{selection.reason}) [#{safety}#{fallback}]"
14
+ selection.warnings.each { |warning| @output.puts "warning: #{warning}" }
15
+ end
16
+
17
+ def results(results)
18
+ failed = results.values.count { |payload| payload[:status] == 'failed' || payload[:error] }
19
+ total = results.length
20
+ @output.puts "finished #{total} test entities, #{failed} failed"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Reporters
5
+ module Factory
6
+ module_function
7
+
8
+ def build(format, output: $stdout)
9
+ case format.to_s
10
+ when 'console'
11
+ Console.new(output: output)
12
+ when 'json'
13
+ JSON.new(output: output)
14
+ when 'junit'
15
+ JUnit.new(output: output)
16
+ when 'tui'
17
+ TUI.new(output: output)
18
+ else
19
+ raise Error, "unknown reporter: #{format}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Kaisoku
6
+ module Reporters
7
+ class JSON
8
+ def initialize(output: $stdout)
9
+ @output = output
10
+ end
11
+
12
+ def selection(selection, selected_count:, total_count:)
13
+ @selection = {
14
+ selected: selected_count,
15
+ total: total_count,
16
+ reason: selection.reason,
17
+ safe: selection.safe?,
18
+ fallback: selection.fallback?,
19
+ warnings: selection.warnings
20
+ }
21
+ end
22
+
23
+ def results(results)
24
+ @output.puts ::JSON.pretty_generate(selection: @selection, results: results)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Kaisoku
6
+ module Reporters
7
+ class JUnit
8
+ def initialize(output: $stdout)
9
+ @output = output
10
+ end
11
+
12
+ def selection(_selection, selected_count:, total_count:)
13
+ @selected_count = selected_count
14
+ @total_count = total_count
15
+ end
16
+
17
+ def results(results)
18
+ failures = results.values.count { |payload| payload[:status] == 'failed' || payload[:error] }
19
+ @output.puts %(<?xml version="1.0" encoding="UTF-8"?>)
20
+ @output.puts %(<testsuite name="kaisoku" tests="#{@selected_count || results.length}" failures="#{failures}" skipped="#{skipped_count}">)
21
+ results.each { |id, payload| testcase(id, payload) }
22
+ @output.puts '</testsuite>'
23
+ end
24
+
25
+ private
26
+
27
+ def testcase(id, payload)
28
+ duration = (payload[:duration_ms] || 0).to_f / 1000.0
29
+ @output.puts %(<testcase classname="kaisoku" name="#{escape(id)}" time="#{format('%.3f', duration)}">)
30
+ failure(payload) if payload[:status] == 'failed' || payload[:error]
31
+ @output.puts '</testcase>'
32
+ end
33
+
34
+ def failure(payload)
35
+ message = payload[:error] || payload[:status]
36
+ @output.puts %(<failure message="#{escape(message)}">#{escape(message)}</failure>)
37
+ end
38
+
39
+ def skipped_count
40
+ return 0 unless @total_count && @selected_count
41
+
42
+ @total_count - @selected_count
43
+ end
44
+
45
+ def escape(value)
46
+ CGI.escapeHTML(value.to_s)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Reporters
5
+ class TUI < Console
6
+ def selection(selection, selected_count:, total_count:)
7
+ clear
8
+ @started_at = Time.now
9
+ super
10
+ end
11
+
12
+ def results(results)
13
+ failed = results.values.count { |payload| payload[:status] == 'failed' || payload[:error] }
14
+ elapsed = Time.now - (@started_at || Time.now)
15
+ @output.puts "status: #{failed.zero? ? 'passed' : 'failed'}"
16
+ @output.puts "elapsed: #{format('%.2fs', elapsed)}"
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ def clear
23
+ return unless @output.tty?
24
+
25
+ @output.print "\e[2J\e[H"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rails/integration'
4
+
5
+ module Kaisoku
6
+ class Runner
7
+ def initialize(adapter:, test_map:, collector: CoverageCollector.new, reporter: nil, rails_integration: nil)
8
+ @adapter = adapter
9
+ @test_map = test_map
10
+ @collector = collector
11
+ @reporter = reporter
12
+ @rails_integration = rails_integration || Rails::Integration.new(configuration: adapter.configuration)
13
+ end
14
+
15
+ def run(entities)
16
+ results = []
17
+
18
+ @rails_integration.wrap_reloader do
19
+ @adapter.run(entities, reporter: @reporter) do |event, entity, payload = {}|
20
+ case event
21
+ when :entity_started
22
+ @collector.start_entity
23
+ when :entity_finished
24
+ collection = @collector.finish_entity
25
+ result = EntityResult.new(
26
+ entity: entity,
27
+ payload: payload,
28
+ dependencies: collection.dependencies,
29
+ collection_skipped: collection.skipped?
30
+ )
31
+ persist_result(result)
32
+ results << result
33
+ end
34
+ end
35
+ end
36
+
37
+ results.to_h do |result|
38
+ [result.entity.id, result.payload]
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def persist_result(result)
45
+ Scheduler::ResultPersister.new(test_map: @test_map).persist([result])
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ class RuntimeContext
5
+ attr_reader :options
6
+
7
+ def initialize(options)
8
+ @options = options
9
+ end
10
+
11
+ def configuration
12
+ @configuration ||= Configuration.new(
13
+ project_root: options[:project_root],
14
+ map_path: options[:map_path],
15
+ adapter: options[:adapter],
16
+ method_level_threshold: options[:method_level_threshold] || 20
17
+ )
18
+ end
19
+
20
+ def test_map
21
+ @test_map ||= TestMap.new(configuration: configuration)
22
+ end
23
+
24
+ def adapter
25
+ @adapter ||= Adapters::Factory.build(options[:adapter], configuration: configuration)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+
5
+ module Kaisoku
6
+ module Scheduler
7
+ class ForkPool
8
+ def initialize(configuration:, workers: Etc.nprocessors)
9
+ @configuration = configuration
10
+ @workers = [workers.to_i, 1].max
11
+ end
12
+
13
+ def run(entities)
14
+ pending = Array(entities).dup
15
+ running = {}
16
+ results = []
17
+ before_fork
18
+
19
+ until pending.empty? && running.empty?
20
+ start_available_workers(pending, running)
21
+ pid, status = Process.wait2
22
+ reader, entity, index = running.delete(pid)
23
+ results << read_result(reader, entity, status)
24
+ @available_indexes << index
25
+ end
26
+
27
+ results
28
+ end
29
+
30
+ private
31
+
32
+ def start_available_workers(pending, running)
33
+ while running.length < @workers && !pending.empty?
34
+ entity = pending.shift
35
+ reader, writer = IO.pipe
36
+ index = @available_indexes.shift || running.length
37
+ pid = fork_worker(entity, writer, index)
38
+ writer.close
39
+ running[pid] = [reader, entity, index]
40
+ end
41
+ end
42
+
43
+ def fork_worker(entity, writer, index)
44
+ fork do
45
+ begin
46
+ reader = nil
47
+ run_child(entity, writer, index)
48
+ ensure
49
+ reader&.close
50
+ writer.close
51
+ end
52
+ exit! 0
53
+ end
54
+ end
55
+
56
+ def run_child(entity, writer, index)
57
+ configure_worker_environment(index)
58
+ configuration = Configuration.new(
59
+ project_root: @configuration.project_root,
60
+ map_path: @configuration.map_path,
61
+ adapter: @configuration.adapter
62
+ )
63
+ adapter = Adapters::Factory.build(configuration.adapter, configuration: configuration)
64
+ adapter.after_fork(index)
65
+ collector = CoverageCollector.new(configuration: configuration)
66
+ result = Worker.new(adapter: adapter, collector: collector).run(entity)
67
+ Marshal.dump(result, writer)
68
+ end
69
+
70
+ def before_fork
71
+ @available_indexes = (0...@workers).to_a
72
+ adapter = Adapters::Factory.build(@configuration.adapter, configuration: @configuration)
73
+ adapter.before_fork(workers: @workers)
74
+ end
75
+
76
+ def configure_worker_environment(index)
77
+ ENV['KAISOKU_WORKER_INDEX'] = index.to_s
78
+ ENV['TEST_ENV_NUMBER'] = index.zero? ? '' : (index + 1).to_s
79
+ end
80
+
81
+ def read_result(reader, entity, status)
82
+ result = Marshal.load(reader)
83
+ return result if status.success?
84
+
85
+ EntityResult.new(
86
+ entity: entity,
87
+ payload: { status: 'failed', duration_ms: 0 },
88
+ dependencies: result.dependencies,
89
+ collection_skipped: true,
90
+ error: "worker exited with status #{status.exitstatus}"
91
+ )
92
+ rescue StandardError => e
93
+ EntityResult.new(
94
+ entity: entity,
95
+ payload: { status: 'failed', duration_ms: 0 },
96
+ dependencies: {},
97
+ collection_skipped: true,
98
+ error: "#{e.class}: #{e.message}"
99
+ )
100
+ ensure
101
+ reader.close
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Scheduler
5
+ class ResultPersister
6
+ def initialize(test_map:)
7
+ @test_map = test_map
8
+ end
9
+
10
+ def persist(results)
11
+ results.each_with_object({}) do |result, payloads|
12
+ runtime_entity = runtime_entity(result)
13
+ @test_map.upsert_entity(runtime_entity)
14
+ persist_dependencies(runtime_entity, result)
15
+ payloads[runtime_entity.id] = result.payload.merge(error: result.error).compact
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def runtime_entity(result)
22
+ result.entity.with_runtime(
23
+ avg_duration_ms: result.payload[:duration_ms],
24
+ last_status: result.status == 'passed' ? 'passed' : 'failed',
25
+ last_failed_at: result.failed? ? Time.now.to_i : result.entity.last_failed_at,
26
+ failure_count: failure_count(result)
27
+ )
28
+ end
29
+
30
+ def failure_count(result)
31
+ return result.entity.failure_count unless result.failed?
32
+
33
+ result.entity.failure_count.to_i + 1
34
+ end
35
+
36
+ def persist_dependencies(entity, result)
37
+ return if result.collection_skipped
38
+
39
+ @test_map.replace_dependencies(entity, result.dependencies)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ Selection = Struct.new(
5
+ :entities,
6
+ :safe,
7
+ :fallback,
8
+ :invalidate_map,
9
+ :reason,
10
+ :warnings,
11
+ :ignored_files,
12
+ keyword_init: true
13
+ ) do
14
+ def initialize(
15
+ reason:, entities: [],
16
+ safe: true,
17
+ fallback: false,
18
+ invalidate_map: false,
19
+ warnings: [],
20
+ ignored_files: []
21
+ )
22
+ super
23
+ end
24
+
25
+ def self.none(ignored_files: [], reason: 'checksums unchanged')
26
+ new(entities: [], reason: reason, ignored_files: ignored_files)
27
+ end
28
+
29
+ def self.all(entities:, reason:, invalidate_map: false, warnings: [])
30
+ new(
31
+ entities: entities,
32
+ fallback: true,
33
+ invalidate_map: invalidate_map,
34
+ reason: reason,
35
+ warnings: warnings
36
+ )
37
+ end
38
+
39
+ def count
40
+ entities.length
41
+ end
42
+
43
+ def safe?
44
+ safe
45
+ end
46
+
47
+ def fallback?
48
+ fallback
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ class Selector
5
+ def initialize(
6
+ test_map:,
7
+ configuration: Configuration.new,
8
+ checksum: Checksum.new,
9
+ static_analyzer: StaticAnalysis::Analyzer.new(configuration: configuration),
10
+ rails_integration: Rails::Integration.new(configuration: configuration),
11
+ hybrid_policy: nil
12
+ )
13
+ @test_map = test_map
14
+ @configuration = configuration
15
+ @checksum = checksum
16
+ @static_analyzer = static_analyzer
17
+ @method_map = MethodMap.new(configuration: configuration)
18
+ @rails_integration = rails_integration
19
+ @hybrid_policy = hybrid_policy || HybridPolicy.new(test_map: test_map, configuration: configuration)
20
+ end
21
+
22
+ def select(changed_paths:, all_entities:)
23
+ entities = Array(all_entities)
24
+ paths = Array(changed_paths).map { |path| @configuration.relative_path(path) }.uniq
25
+
26
+ return Selection.all(entities: entities, reason: 'test map is empty') if @test_map.empty?
27
+
28
+ invalidating = paths.select { |path| @configuration.invalidates_map?(path) }
29
+ unless invalidating.empty?
30
+ return Selection.all(
31
+ entities: entities,
32
+ reason: "map invalidating file changed: #{invalidating.join(', ')}",
33
+ invalidate_map: true
34
+ )
35
+ end
36
+
37
+ boot_files = paths.select { |path| @configuration.boot_impact_file?(path) }
38
+ unless boot_files.empty?
39
+ rails_selection = rails_boot_selection(boot_files, entities)
40
+ return rails_selection if rails_selection
41
+
42
+ return Selection.all(
43
+ entities: entities,
44
+ reason: "boot-impact file changed: #{boot_files.join(', ')}"
45
+ )
46
+ end
47
+
48
+ select_from_known_changes(paths, entities)
49
+ end
50
+
51
+ private
52
+
53
+ def select_from_known_changes(paths, all_entities)
54
+ selected = []
55
+ ignored = []
56
+ reasons = []
57
+
58
+ paths.each do |path|
59
+ if @configuration.test_file?(path)
60
+ direct = direct_test_entities(path, all_entities)
61
+ selected.concat(direct)
62
+ reasons << "#{path} directly selected #{direct.length} test entities"
63
+ next
64
+ end
65
+
66
+ unless @test_map.known_file?(path)
67
+ return Selection.all(
68
+ entities: all_entities,
69
+ reason: "unknown changed file: #{path}",
70
+ warnings: static_warnings(path)
71
+ )
72
+ end
73
+
74
+ if checksum_unchanged?(path)
75
+ ignored << path
76
+ next
77
+ end
78
+
79
+ affected = affected_entities(path)
80
+ conventional = conventional_test_entities(path, all_entities) - affected
81
+ selected.concat(affected)
82
+ selected.concat(conventional)
83
+ reasons << selection_reason(path, affected, conventional)
84
+ end
85
+
86
+ unique = selected.uniq
87
+ return Selection.none(ignored_files: ignored) if unique.empty?
88
+
89
+ Selection.new(
90
+ entities: unique,
91
+ reason: reasons.join('; '),
92
+ ignored_files: ignored
93
+ )
94
+ end
95
+
96
+ def direct_test_entities(path, all_entities)
97
+ from_map = @test_map.entities_for_test_files([path], adapter: @configuration.adapter)
98
+ return from_map unless from_map.empty?
99
+
100
+ all_entities.select { |entity| entity.adapter == @configuration.adapter && entity.file == path }
101
+ end
102
+
103
+ def checksum_unchanged?(path)
104
+ stored = @test_map.checksums_for_file(path)
105
+ return false if stored.empty?
106
+
107
+ current = @checksum.digest(@configuration.absolute_path(path))
108
+ return false unless current
109
+
110
+ stored.all? { |checksum| checksum == current }
111
+ end
112
+
113
+ def affected_entities(path)
114
+ method_keys = changed_method_keys(path)
115
+ if !method_keys.empty? && @hybrid_policy.method_level?(path)
116
+ entities = @test_map.entities_for_dependency_keys(method_keys, adapter: @configuration.adapter)
117
+ return entities unless entities.empty?
118
+ end
119
+
120
+ @test_map.entities_for_changed_files([path], adapter: @configuration.adapter)
121
+ end
122
+
123
+ def conventional_test_entities(path, all_entities)
124
+ @static_analyzer.conventional_tests_for(path).flat_map do |test_path|
125
+ direct_test_entities(test_path, all_entities)
126
+ end
127
+ end
128
+
129
+ def selection_reason(path, affected, conventional)
130
+ reason = "#{path} selected #{affected.length} dependent test entities"
131
+ return reason if conventional.empty?
132
+
133
+ "#{reason} and #{conventional.length} conventional test entities"
134
+ end
135
+
136
+ def changed_method_keys(path)
137
+ stored = @test_map.method_checksums_for_file(path)
138
+ return [] if stored.empty?
139
+
140
+ current = @method_map.checksums(path)
141
+ changed = stored.keys.reject { |key| current[key] == stored[key] }
142
+ return [top_level_key(path)] if changed.empty? && !checksum_unchanged?(path)
143
+
144
+ changed
145
+ end
146
+
147
+ def top_level_key(path)
148
+ "#{@configuration.relative_path(path)}##{MethodMap::TOP_LEVEL}"
149
+ end
150
+
151
+ def static_warnings(path)
152
+ tests = @static_analyzer.conventional_tests_for(path)
153
+ return [] if tests.empty?
154
+
155
+ ["static analysis found conventional tests: #{tests.join(', ')}; falling back to all tests for safety"]
156
+ end
157
+
158
+ def rails_boot_selection(paths, all_entities)
159
+ return nil unless paths.any? { |path| @rails_integration.database_change?(path) }
160
+
161
+ db_entities = @test_map.entities_with_dependency_prefixes(
162
+ @rails_integration.db_dependency_prefixes,
163
+ adapter: @configuration.adapter
164
+ )
165
+ return nil if db_entities.empty?
166
+
167
+ Selection.new(
168
+ entities: db_entities & all_entities,
169
+ reason: "Rails database-impact file changed: #{paths.join(', ')}"
170
+ )
171
+ end
172
+ end
173
+ end