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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: edd8cb6fea57c23e069b7547ff435d61dad7b2e990fc6ab6993bd26b269d31c1
4
+ data.tar.gz: bd1b9a13f9e99b880bcf9637b9c158242d1d501ba137e0ab40da6a98ca9b2949
5
+ SHA512:
6
+ metadata.gz: d498ab221286f31761d145022fea6b74be59c2ae594718ec369080715303d24c45ab0098c6c9e3655524802d133a18d4ba1ef400231f7d19b64a23492850fd68
7
+ data.tar.gz: a8499104174f91185d344f5c60ac0aaf7a6dc106125b29f3aeb0df54087dd457c386b3c2661b19822b0cd90a122abd40a78fc7241f079cc3118b21dc4c278eb6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # Kaisoku
2
+
3
+ Kaisoku is a research-backed Ruby test feedback engine for safe regression test
4
+ selection. It records dynamic dependencies per test entity, selects the tests
5
+ affected by a change, and falls back to a full run when it cannot preserve
6
+ safety.
7
+
8
+ ## Installation
9
+
10
+ Install the gem and add it to your application's Gemfile by executing:
11
+
12
+ ```bash
13
+ bundle add kaisoku
14
+ ```
15
+
16
+ If bundler is not being used to manage dependencies, install the gem by
17
+ executing:
18
+
19
+ ```bash
20
+ gem install kaisoku
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Build the initial dependency map:
26
+
27
+ ```bash
28
+ kaisoku map build
29
+ ```
30
+
31
+ Run tests affected by changed files:
32
+
33
+ ```bash
34
+ kaisoku run app/models/user.rb
35
+ ```
36
+
37
+ Run all tests or previous failures:
38
+
39
+ ```bash
40
+ kaisoku run --all
41
+ kaisoku run --failed
42
+ ```
43
+
44
+ Start watch mode:
45
+
46
+ ```bash
47
+ kaisoku watch app lib spec
48
+ ```
49
+
50
+ Inspect map health and preload state:
51
+
52
+ ```bash
53
+ kaisoku doctor --preload
54
+ ```
55
+
56
+ Export and import dependency maps for CI or cache reuse:
57
+
58
+ ```bash
59
+ kaisoku map export > .kaisoku/map.json
60
+ kaisoku map import .kaisoku/map.json
61
+ ```
62
+
63
+ Evaluate selection quality:
64
+
65
+ ```bash
66
+ kaisoku eval --total 100 --selected 12 --ci
67
+ kaisoku eval --commits 20 --json-report .kaisoku/eval-report.json
68
+ ```
69
+
70
+ Unsafe predictive mode is explicit:
71
+
72
+ ```bash
73
+ kaisoku run --predictive
74
+ ```
75
+
76
+ ## Supported Frameworks
77
+
78
+ - RSpec
79
+ - minitest
80
+ - test-unit
81
+ - Cucumber
82
+
83
+ Select an adapter with `--adapter`:
84
+
85
+ ```bash
86
+ kaisoku run lib/user.rb --adapter minitest
87
+ ```
88
+
89
+ ## Features
90
+
91
+ - Dynamic file-level dependency maps stored in SQLite
92
+ - Smart Ruby checksums that ignore comments while preserving semantic magic comments
93
+ - Safe fallback rules for unknown files, boot-impact files, Ruby/Gemfile changes, and stale maps
94
+ - Fork-based parallel scheduling and failure-first prioritization
95
+ - Watch mode, daemon RPC, console/TUI/JSON/JUnit reporters, and map export/import
96
+ - Hot-file diagnostics and method-level HyRTS policy
97
+ - Evaluation commands for selection metrics, JSON reports, CI history, commit replay, and mutation seeding
98
+ - Preload integrity tracking, Rails-specific hooks, and inline snapshot matcher support
99
+
100
+ ## Development
101
+
102
+ After checking out the repo, run:
103
+
104
+ ```bash
105
+ bundle install
106
+ ```
107
+
108
+ Run the test suite:
109
+
110
+ ```bash
111
+ bundle exec rake
112
+ ```
113
+
114
+ Run the executable from the checkout:
115
+
116
+ ```bash
117
+ bundle exec ruby -Ilib exe/kaisoku --help
118
+ ```
119
+
120
+ Build the gem locally:
121
+
122
+ ```bash
123
+ gem build kaisoku.gemspec
124
+ ```
125
+
126
+ ## Contributing
127
+
128
+ Bug reports and pull requests are welcome on GitHub at
129
+ https://github.com/ydah/kaisoku.
130
+
131
+ ## License
132
+
133
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/kaisoku ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'kaisoku'
5
+
6
+ exit Kaisoku::CLI.start(ARGV)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ class Adapter
5
+ attr_reader :configuration
6
+
7
+ def initialize(configuration: Configuration.new)
8
+ @configuration = configuration
9
+ end
10
+
11
+ def discover(_paths = nil)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def run(_entities, reporter:, &_lifecycle)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def entity_granularity
20
+ :group
21
+ end
22
+
23
+ def before_fork(_context); end
24
+
25
+ def after_fork(_worker_index); end
26
+
27
+ def supports_dry_run?
28
+ false
29
+ end
30
+
31
+ private
32
+
33
+ def ruby_test_files(paths, default_dir:, suffix:)
34
+ targets = Array(paths).compact
35
+ targets = [default_dir] if targets.empty?
36
+
37
+ targets.flat_map do |path|
38
+ absolute = configuration.absolute_path(path)
39
+
40
+ if File.directory?(absolute)
41
+ Dir.glob(File.join(absolute, '**', "*#{suffix}")).select { |file| File.file?(file) }
42
+ elsif File.file?(absolute) && absolute.end_with?(suffix)
43
+ [absolute]
44
+ else
45
+ []
46
+ end
47
+ end.uniq.sort
48
+ end
49
+
50
+ def file_entity(file, adapter:)
51
+ relative = configuration.relative_path(file)
52
+ Entity.new(
53
+ adapter: adapter,
54
+ identifier: "#{relative}[file]",
55
+ file: relative,
56
+ line: 1
57
+ )
58
+ end
59
+ end
60
+
61
+ module Adapters
62
+ module Factory
63
+ module_function
64
+
65
+ def build(name, configuration:)
66
+ case name.to_s
67
+ when 'rspec'
68
+ RSpecAdapter.new(configuration: configuration)
69
+ when 'minitest'
70
+ MinitestAdapter.new(configuration: configuration)
71
+ when 'test-unit'
72
+ TestUnitAdapter.new(configuration: configuration)
73
+ when 'cucumber'
74
+ CucumberAdapter.new(configuration: configuration)
75
+ else
76
+ raise Error, "unknown adapter: #{name}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Kaisoku
6
+ module Adapters
7
+ class CucumberAdapter < Adapter
8
+ ADAPTER_NAME = 'cucumber'
9
+
10
+ def discover(paths = nil)
11
+ feature_files(paths).flat_map { |file| scenarios_in(file) }
12
+ end
13
+
14
+ def run(entities, reporter:, &lifecycle)
15
+ lifecycle ||= proc {}
16
+
17
+ entities.each do |entity|
18
+ started_at = monotonic_milliseconds
19
+ lifecycle.call(:entity_started, entity, {})
20
+ ok = run_cucumber(execution_target(entity))
21
+ lifecycle.call(:entity_finished, entity, result_payload(ok, started_at))
22
+ end
23
+ end
24
+
25
+ def entity_granularity
26
+ :scenario
27
+ end
28
+
29
+ private
30
+
31
+ def feature_files(paths)
32
+ targets = Array(paths).compact
33
+ targets = ['features'] if targets.empty?
34
+ targets.flat_map { |path| feature_files_for(path) }.uniq.sort
35
+ end
36
+
37
+ def feature_files_for(path)
38
+ absolute = configuration.absolute_path(path)
39
+ return Dir.glob(File.join(absolute, '**', '*.feature')) if File.directory?(absolute)
40
+ return [absolute] if File.file?(absolute) && absolute.end_with?('.feature')
41
+
42
+ []
43
+ end
44
+
45
+ def scenarios_in(file)
46
+ relative = configuration.relative_path(file)
47
+ scenarios = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
48
+ scenario_entity(relative, line, number)
49
+ end
50
+ scenarios.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : scenarios
51
+ end
52
+
53
+ def scenario_entity(relative, line, number)
54
+ match = line.match(/^\s*Scenario(?: Outline)?:\s*(.+)$/)
55
+ return nil unless match
56
+
57
+ Entity.new(adapter: ADAPTER_NAME, identifier: "#{relative}[#{match[1]}]", file: relative, line: number)
58
+ end
59
+
60
+ def execution_target(entity)
61
+ "#{configuration.absolute_path(entity.file)}:#{entity.line || 1}"
62
+ end
63
+
64
+ def run_cucumber(target)
65
+ return system('cucumber', target) unless cucumber_cli_available?
66
+
67
+ output = StringIO.new
68
+ error = StringIO.new
69
+ status = ::Cucumber::Cli::Main.execute([target], output, error)
70
+ status.respond_to?(:zero?) ? status.zero? : status == true
71
+ end
72
+
73
+ def cucumber_cli_available?
74
+ return true if defined?(::Cucumber::Cli::Main)
75
+
76
+ require 'cucumber/cli/main'
77
+ true
78
+ rescue LoadError
79
+ false
80
+ end
81
+
82
+ def result_payload(ok, started_at)
83
+ { status: ok ? 'passed' : 'failed', exit_status: ok ? 0 : 1, duration_ms: monotonic_milliseconds - started_at }
84
+ end
85
+
86
+ def monotonic_milliseconds
87
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require 'stringio'
5
+
6
+ module Kaisoku
7
+ module Adapters
8
+ class MinitestAdapter < Adapter
9
+ ADAPTER_NAME = 'minitest'
10
+
11
+ def discover(paths = nil)
12
+ ruby_test_files(paths, default_dir: 'test', suffix: '_test.rb').flat_map { |file| classes_in(file) }
13
+ end
14
+
15
+ def run(entities, reporter:, &lifecycle)
16
+ lifecycle ||= proc {}
17
+
18
+ entities.each do |entity|
19
+ started_at = monotonic_milliseconds
20
+ lifecycle.call(:entity_started, entity, {})
21
+ result = run_entity(entity)
22
+ lifecycle.call(:entity_finished, entity, result_payload(result, started_at))
23
+ end
24
+ end
25
+
26
+ def entity_granularity
27
+ :class
28
+ end
29
+
30
+ def supports_dry_run?
31
+ true
32
+ end
33
+
34
+ private
35
+
36
+ def classes_in(file)
37
+ relative = configuration.relative_path(file)
38
+ classes = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
39
+ class_entity(relative, line, number)
40
+ end
41
+
42
+ classes.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : classes
43
+ end
44
+
45
+ def class_entity(relative, line, number)
46
+ match = line.match(/^\s*class\s+([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*<\s*(?:Minitest::Test|ActiveSupport::TestCase)/)
47
+ return nil unless match
48
+
49
+ Entity.new(
50
+ adapter: ADAPTER_NAME,
51
+ identifier: "#{relative}[#{match[1]}]",
52
+ file: relative,
53
+ line: number
54
+ )
55
+ end
56
+
57
+ def run_entity(entity)
58
+ require 'minitest'
59
+ Minitest.seed ||= srand % 0xFFFF
60
+ before = Minitest::Runnable.runnables.dup
61
+ load configuration.absolute_path(entity.file)
62
+ classes = selected_classes(before, entity)
63
+ reporter = Minitest::CompositeReporter.new(Minitest::SummaryReporter.new(StringIO.new, {}))
64
+ reporter.start
65
+ classes.each do |klass|
66
+ klass.runnable_methods.each { |method_name| klass.run(klass, method_name, reporter) }
67
+ end
68
+ reporter.report
69
+ reporter.passed?
70
+ end
71
+
72
+ def class_name(entity)
73
+ entity.identifier[/\[(.+)\]\z/, 1]
74
+ end
75
+
76
+ def selected_classes(before, entity)
77
+ candidates = Minitest::Runnable.runnables - before
78
+ return candidates unless class_name(entity)
79
+
80
+ candidates.select { |klass| klass.name == class_name(entity) }
81
+ end
82
+
83
+ def result_payload(ok, started_at)
84
+ {
85
+ status: ok ? 'passed' : 'failed',
86
+ exit_status: ok ? 0 : 1,
87
+ duration_ms: monotonic_milliseconds - started_at
88
+ }
89
+ end
90
+
91
+ def monotonic_milliseconds
92
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stringio'
5
+
6
+ module Kaisoku
7
+ module Adapters
8
+ class RSpecAdapter < Adapter
9
+ ADAPTER_NAME = 'rspec'
10
+
11
+ def discover(paths = nil)
12
+ ruby_test_files(paths, default_dir: 'spec', suffix: '_spec.rb').flat_map { |file| groups_in(file) }
13
+ end
14
+
15
+ def run(entities, reporter:, &lifecycle)
16
+ require 'rspec/core'
17
+
18
+ lifecycle ||= proc {}
19
+
20
+ entities.each do |entity|
21
+ started_at = monotonic_milliseconds
22
+ lifecycle.call(:entity_started, entity, {})
23
+ status_code = ::RSpec::Core::Runner.run([execution_target(entity)], $stderr, $stdout)
24
+ lifecycle.call(:entity_finished, entity, result_payload(status_code, started_at))
25
+ end
26
+ end
27
+
28
+ def entity_granularity
29
+ :group
30
+ end
31
+
32
+ def supports_dry_run?
33
+ true
34
+ end
35
+
36
+ private
37
+
38
+ def groups_in(file)
39
+ dry_run = dry_run_examples_in(file)
40
+ return dry_run unless dry_run.empty?
41
+
42
+ relative = configuration.relative_path(file)
43
+ groups = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
44
+ group_entity(relative, line, number)
45
+ end
46
+
47
+ groups.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : groups
48
+ end
49
+
50
+ def group_entity(relative, line, number)
51
+ match = line.match(/^\s*(?:RSpec\.)?(describe|context)\s+(.+?)(?:\s+do|\s+\{|\z)/)
52
+ return nil unless match
53
+
54
+ name = match[2].gsub(/\s+/, ' ').strip
55
+ Entity.new(
56
+ adapter: ADAPTER_NAME,
57
+ identifier: "#{relative}[#{name}]",
58
+ file: relative,
59
+ line: number
60
+ )
61
+ end
62
+
63
+ def dry_run_examples_in(file)
64
+ return [] unless ENV['KAISOKU_RSPEC_DRY_RUN'] == '1'
65
+
66
+ require 'rspec/core'
67
+ output = StringIO.new
68
+ error = StringIO.new
69
+ status = ::RSpec::Core::Runner.run([configuration.absolute_path(file), '--dry-run', '--format', 'json'], error,
70
+ output)
71
+ return [] unless status.zero?
72
+
73
+ examples_from_json(file, output.string)
74
+ rescue StandardError
75
+ []
76
+ end
77
+
78
+ def examples_from_json(file, payload)
79
+ relative = configuration.relative_path(file)
80
+ ::JSON.parse(payload).fetch('examples', []).map do |example|
81
+ Entity.new(
82
+ adapter: ADAPTER_NAME,
83
+ identifier: "#{relative}[#{example.fetch('full_description', example['id'])}]",
84
+ file: relative,
85
+ line: example['line_number']
86
+ )
87
+ end
88
+ rescue ::JSON::ParserError
89
+ []
90
+ end
91
+
92
+ def execution_target(entity)
93
+ return configuration.absolute_path(entity.file) unless entity.line
94
+
95
+ "#{configuration.absolute_path(entity.file)}:#{entity.line}"
96
+ end
97
+
98
+ def result_payload(status_code, started_at)
99
+ {
100
+ status: status_code.zero? ? 'passed' : 'failed',
101
+ exit_status: status_code,
102
+ duration_ms: monotonic_milliseconds - started_at
103
+ }
104
+ end
105
+
106
+ def monotonic_milliseconds
107
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Adapters
5
+ class TestUnitAdapter < MinitestAdapter
6
+ ADAPTER_NAME = 'test-unit'
7
+
8
+ def discover(paths = nil)
9
+ ruby_test_files(paths, default_dir: 'test', suffix: '_test.rb').flat_map { |file| classes_in(file) }
10
+ end
11
+
12
+ def entity_granularity
13
+ :class
14
+ end
15
+
16
+ private
17
+
18
+ def classes_in(file)
19
+ relative = configuration.relative_path(file)
20
+ classes = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
21
+ class_entity(relative, line, number)
22
+ end
23
+
24
+ classes.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : classes
25
+ end
26
+
27
+ def class_entity(relative, line, number)
28
+ match = line.match(/^\s*class\s+([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*<\s*(?:Test::Unit::TestCase|Minitest::Test|ActiveSupport::TestCase)/)
29
+ return nil unless match
30
+
31
+ Entity.new(adapter: ADAPTER_NAME, identifier: "#{relative}[#{match[1]}]", file: relative, line: number)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'prism'
5
+
6
+ module Kaisoku
7
+ class Checksum
8
+ RUBY_EXTENSION = '.rb'
9
+ SKIPPED_RUBY_TOKENS = %i[COMMENT IGNORED_NEWLINE NEWLINE].freeze
10
+
11
+ def digest(path)
12
+ return nil unless File.file?(path)
13
+
14
+ if File.extname(path) == RUBY_EXTENSION
15
+ ruby_digest(path)
16
+ else
17
+ raw_digest(path)
18
+ end
19
+ rescue StandardError
20
+ raw_digest(path)
21
+ end
22
+
23
+ private
24
+
25
+ def ruby_digest(path)
26
+ result = Prism.lex_file(path)
27
+ raise Error, "Prism failed to lex #{path}" if result.failure?
28
+
29
+ normalized = result.value.filter_map do |token, _state|
30
+ normalize_token(token)
31
+ end
32
+
33
+ Digest::SHA256.hexdigest(normalized.join("\n"))
34
+ end
35
+
36
+ def normalize_token(token)
37
+ return normalize_magic_comment(token.value) if token.type == :COMMENT && magic_comment?(token.value)
38
+ return nil if SKIPPED_RUBY_TOKENS.include?(token.type)
39
+
40
+ "#{token.type}:#{token.value}"
41
+ end
42
+
43
+ def magic_comment?(value)
44
+ value.match?(/\A#.*\b(frozen_string_literal|encoding|coding):/i)
45
+ end
46
+
47
+ def normalize_magic_comment(value)
48
+ "COMMENT:#{value.strip}"
49
+ end
50
+
51
+ def raw_digest(path)
52
+ Digest::SHA256.file(path).hexdigest
53
+ end
54
+ end
55
+ end