spectracer 1.0.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.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "git"
4
+
5
+ module Spectracer
6
+ module IO
7
+ class GitAdapter
8
+ def initialize(working_dir: Dir.pwd, logger: nil)
9
+ @working_dir = working_dir
10
+ @logger = logger
11
+ @git = nil
12
+ end
13
+
14
+ def repository_root
15
+ git.dir.path
16
+ end
17
+
18
+ def current_branch
19
+ git.current_branch
20
+ end
21
+
22
+ def commit_sha(ref)
23
+ git.object(ref).sha
24
+ end
25
+
26
+ def changed_files_in_commit(sha)
27
+ commit = git.object(sha)
28
+ return [] unless commit.respond_to?(:diff_parent)
29
+
30
+ commit.diff_parent.stats[:files].keys
31
+ rescue Git::Error => e
32
+ @logger&.warn("Failed to get changed files for commit #{sha}: #{e.message}")
33
+ []
34
+ end
35
+
36
+ def changed_files_against(target_branch, cached: false)
37
+ target_ref = "origin/#{target_branch}"
38
+
39
+ diff = if cached
40
+ git.diff(target_ref, "HEAD")
41
+ else
42
+ git.diff(target_ref)
43
+ end
44
+
45
+ diff.stats[:files].keys
46
+ rescue Git::Error => e
47
+ @logger&.warn("Failed to diff against #{target_branch}: #{e.message}")
48
+ []
49
+ end
50
+
51
+ private
52
+
53
+ def git
54
+ @git ||= Git.open(@working_dir, log: nil)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ class Logger
5
+ LEVELS = {debug: 0, info: 1, warn: 2, error: 3}.freeze
6
+
7
+ def initialize(output: $stderr, level: :info, enabled: false)
8
+ @output = output
9
+ @level = LEVELS.fetch(level, 1)
10
+ @enabled = enabled
11
+ end
12
+
13
+ def debug(message)
14
+ log(:debug, message)
15
+ end
16
+
17
+ def info(message)
18
+ log(:info, message)
19
+ end
20
+
21
+ def warn(message)
22
+ log(:warn, message)
23
+ end
24
+
25
+ def error(message)
26
+ log(:error, message)
27
+ end
28
+
29
+ private
30
+
31
+ def log(level, message)
32
+ return unless @enabled
33
+ return if LEVELS[level] < @level
34
+
35
+ @output.puts "[Spectracer] #{level.upcase}: #{message}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Orchestrators
5
+ class DependencyCollector
6
+ def initialize(
7
+ paths: Spectracer::Core::Paths.new,
8
+ store: Spectracer::IO::DependencyStore.new,
9
+ logger: nil
10
+ )
11
+ @paths = paths
12
+ @store = store
13
+ @logger = logger
14
+ end
15
+
16
+ def self.collect!(...)
17
+ new(...).collect!
18
+ end
19
+
20
+ def collect!
21
+ inverse_deps = build_inverse_dependencies
22
+
23
+ inverse_deps.each_value(&:sort!)
24
+
25
+ @store.write(inverse_deps, @paths.collected_dependencies_file)
26
+
27
+ nil
28
+ end
29
+
30
+ private
31
+
32
+ def build_inverse_dependencies
33
+ inverse = Hash.new { |h, k| h[k] = [] }
34
+
35
+ artifact_files.each do |file|
36
+ @logger&.debug("Processing artifact: #{file}")
37
+ data = @store.read(file)
38
+
39
+ data.each do |spec_file, dependencies|
40
+ dependencies.each do |dep|
41
+ inverse[dep] << spec_file unless inverse[dep].include?(spec_file)
42
+ end
43
+ end
44
+ end
45
+
46
+ inverse
47
+ end
48
+
49
+ def artifact_files
50
+ @store.glob(@paths.spec_artifacts_download_glob)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Orchestrators
5
+ class DependencyTracer
6
+ def initialize(
7
+ paths: Spectracer::Core::Paths.new,
8
+ store: Spectracer::IO::DependencyStore.new,
9
+ repository: Spectracer::Providers::Repository.new,
10
+ logger: nil
11
+ )
12
+ @paths = paths
13
+ @store = store
14
+ @repository = repository
15
+ @logger = logger
16
+ @current_spec_file = nil
17
+ @spec_file_dependencies = Hash.new { |h, k| h[k] = Set.new }
18
+ end
19
+
20
+ attr_writer :current_spec_file
21
+
22
+ def with_tracing(&block)
23
+ tracepoint.enable
24
+ block.call
25
+ ensure
26
+ tracepoint.disable
27
+ end
28
+
29
+ def write_output!
30
+ output = build_output
31
+
32
+ if output.empty?
33
+ @logger&.debug("No dependencies found.")
34
+ return
35
+ end
36
+
37
+ @store.write(output, @paths.spec_artifact_output_file)
38
+ end
39
+
40
+ private
41
+
42
+ def tracepoint
43
+ @tracepoint ||= TracePoint.new(:call) do |tp|
44
+ path = tp.path
45
+ next unless path.start_with?(repository_root)
46
+ next if @current_spec_file.nil?
47
+
48
+ file_path = normalize_path(path)
49
+ next if @current_spec_file == file_path
50
+
51
+ @spec_file_dependencies[@current_spec_file].add(file_path)
52
+ end
53
+ end
54
+
55
+ def repository_root
56
+ @repository_root ||= @repository.root
57
+ end
58
+
59
+ def normalize_path(path)
60
+ @paths.normalize(path, repo_root: repository_root)
61
+ end
62
+
63
+ def build_output
64
+ @spec_file_dependencies.each_with_object({}) do |(spec_file, files), acc|
65
+ next if files.empty?
66
+ acc[spec_file] = files.to_a.sort
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Orchestrators
5
+ class SpecRunDeterminer
6
+ def initialize(
7
+ paths: Spectracer::Core::Paths.new,
8
+ store: Spectracer::IO::DependencyStore.new,
9
+ config_loader: Spectracer::IO::ConfigLoader.new,
10
+ changed_files_provider: Spectracer::Providers::GitChangedFiles.new,
11
+ selector: Spectracer::Core::SpecSelector.new,
12
+ logger: nil
13
+ )
14
+ @paths = paths
15
+ @store = store
16
+ @config_loader = config_loader
17
+ @changed_files_provider = changed_files_provider
18
+ @selector = selector
19
+ @logger = logger
20
+ end
21
+
22
+ def self.determine!(...)
23
+ new(...).determine!
24
+ end
25
+
26
+ def determine!
27
+ dependencies = load_dependencies
28
+ config = @config_loader.load
29
+ changed_files = @changed_files_provider.call
30
+
31
+ log_debug_info(config, changed_files)
32
+
33
+ @selector.call(
34
+ changed_files: changed_files,
35
+ inverse_deps: dependencies,
36
+ globs: config[:globs],
37
+ on_empty: config[:on_empty_spec_set]
38
+ )
39
+ rescue => e
40
+ @logger&.error("Error determining specs: #{e.message}")
41
+ @logger&.error(e.backtrace&.join("\n"))
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ def load_dependencies
48
+ deps_file = @paths.collected_dependencies_file
49
+
50
+ unless File.exist?(deps_file)
51
+ @logger&.debug("No dependencies file found at #{deps_file.inspect}")
52
+ return {}
53
+ end
54
+
55
+ @store.read(deps_file)
56
+ end
57
+
58
+ def log_debug_info(config, changed_files)
59
+ @logger&.debug("Configuration: #{config.inspect}")
60
+ @logger&.debug("Changed files: #{changed_files.inspect}")
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Providers
5
+ class GitChangedFiles
6
+ def initialize(git_adapter: Spectracer::IO::GitAdapter.new, env: ENV, logger: nil)
7
+ @git_adapter = git_adapter
8
+ @env = env
9
+ @logger = logger
10
+ end
11
+
12
+ def call
13
+ default_branch = @env.fetch("BUILDKITE_PIPELINE_DEFAULT_BRANCH", "main")
14
+ current_branch = @env.fetch("BUILDKITE_BRANCH") { @git_adapter.current_branch }
15
+
16
+ files = if current_branch == default_branch
17
+ changed_files_for_latest_commit(current_branch)
18
+ else
19
+ changed_files_against_default_branch(default_branch)
20
+ end
21
+
22
+ @logger&.debug("Changed files: #{files.inspect}")
23
+ files
24
+ end
25
+
26
+ private
27
+
28
+ def changed_files_for_latest_commit(branch)
29
+ sha = @git_adapter.commit_sha(branch)
30
+ @logger&.debug("Latest commit SHA: #{sha}")
31
+
32
+ @git_adapter.changed_files_in_commit(sha)
33
+ end
34
+
35
+ def changed_files_against_default_branch(default_branch)
36
+ @git_adapter.changed_files_against(default_branch, cached: true)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Providers
5
+ class Repository
6
+ def initialize(git_adapter: Spectracer::IO::GitAdapter.new)
7
+ @git_adapter = git_adapter
8
+ end
9
+
10
+ def root
11
+ @root ||= @git_adapter.repository_root
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :spectracer do
4
+ desc "Install Spectracer configuration file"
5
+ task :install do
6
+ config_path = Spectracer::IO::ConfigLoader::FILE_PATH
7
+
8
+ if File.exist?(config_path)
9
+ warn "Spectracer is already installed."
10
+ exit 0
11
+ end
12
+
13
+ warn "Creating '#{config_path}' file."
14
+
15
+ default_content = File.read(Spectracer::IO::ConfigLoader::DEFAULT_FILE_PATH)
16
+ File.write(config_path, default_content)
17
+ end
18
+
19
+ desc "Collect spec dependencies from tracing artifacts"
20
+ task :collect_dependencies do
21
+ logger = Spectracer::Logger.new(
22
+ enabled: ENV["WITH_SPECTACLE_DEBUG"] == "true",
23
+ level: :debug
24
+ )
25
+ Spectracer::Orchestrators::DependencyCollector.collect!(logger: logger)
26
+ end
27
+
28
+ desc "Print the list of specs to run based on changed files"
29
+ task :spec_determiner do
30
+ logger = Spectracer::Logger.new(
31
+ enabled: ENV["WITH_SPECTACLE_DEBUG"] == "true",
32
+ level: :debug
33
+ )
34
+ result = Spectracer::Orchestrators::SpecRunDeterminer.determine!(logger: logger)
35
+ $stdout.puts "'#{result}'"
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ VERSION = "1.0.0"
5
+ end
data/lib/spectracer.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spectracer/version"
4
+ require_relative "spectracer/logger"
5
+
6
+ require_relative "spectracer/core/paths"
7
+ require_relative "spectracer/core/spec_selector"
8
+
9
+ require_relative "spectracer/io/command_runner"
10
+ require_relative "spectracer/io/dependency_store"
11
+ require_relative "spectracer/io/config_loader"
12
+ require_relative "spectracer/io/git_adapter"
13
+
14
+ require_relative "spectracer/providers/repository"
15
+ require_relative "spectracer/providers/git_changed_files"
16
+
17
+ require_relative "spectracer/orchestrators/dependency_tracer"
18
+ require_relative "spectracer/orchestrators/dependency_collector"
19
+ require_relative "spectracer/orchestrators/spec_run_determiner"
20
+
21
+ require_relative "spectracer/integrations/rspec"
22
+ require_relative "spectracer/integrations/minitest"
23
+ require_relative "spectracer/integrations/railtie" if defined?(Rails)
24
+
25
+ module Spectracer
26
+ class Error < StandardError; end
27
+
28
+ class << self
29
+ def logger
30
+ @logger ||= Logger.new(
31
+ enabled: ENV["WITH_SPECTRACER_DEBUG"] == "true",
32
+ level: :debug
33
+ )
34
+ end
35
+
36
+ def paths
37
+ @paths ||= Core::Paths.new
38
+ end
39
+ end
40
+ end
41
+
42
+ Spectracer::Integrations::RSpec.install!
43
+ Spectracer::Integrations::Minitest.install!
@@ -0,0 +1,195 @@
1
+ module Spectracer
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ def self.logger: () -> Logger
8
+ def self.paths: () -> Core::Paths
9
+
10
+ class Logger
11
+ LEVELS: Hash[Symbol, Integer]
12
+
13
+ def initialize: (?output: IO, ?level: Symbol, ?enabled: bool) -> void
14
+ def debug: (String message) -> void
15
+ def info: (String message) -> void
16
+ def warn: (String message) -> void
17
+ def error: (String message) -> void
18
+
19
+ private
20
+
21
+ def log: (Symbol level, String message) -> void
22
+ end
23
+
24
+ module Core
25
+ class Paths
26
+ DEFAULT_OUTPUT_DIR: String
27
+
28
+ def initialize: (?env: Hash[String, String?]) -> void
29
+ def build_id: () -> String
30
+ def job_id: () -> String
31
+ def output_directory: () -> String
32
+ def spec_artifact_output_file: () -> String
33
+ def spec_artifacts_download_glob: () -> String
34
+ def collected_dependencies_file: () -> String
35
+ def normalize: (String file_path, repo_root: String) -> String
36
+ def strip_dot_prefix: (String path) -> String
37
+ end
38
+
39
+ class SpecSelector
40
+ def call: (
41
+ changed_files: Array[String],
42
+ inverse_deps: Hash[String, Array[String]],
43
+ globs: Hash[String, String],
44
+ on_empty: String
45
+ ) -> String
46
+ end
47
+ end
48
+
49
+ module IO
50
+ class CommandRunner
51
+ def initialize: (?logger: Logger?) -> void
52
+ def run: (String command) -> String
53
+ end
54
+
55
+ class DependencyStore
56
+ def initialize: (?logger: Logger?) -> void
57
+ def read: (String file_path) -> Hash[String, untyped]
58
+ def write: (Hash[String, untyped] data, String file_path) -> void
59
+ def glob: (String pattern) -> Array[String]
60
+
61
+ private
62
+
63
+ def read_gzipped_json: (String file_path) -> Hash[String, untyped]
64
+ def read_json: (String file_path) -> Hash[String, untyped]
65
+ end
66
+
67
+ class ConfigLoader
68
+ FILE_PATH: String
69
+ DEFAULT_FILE_PATH: String
70
+
71
+ def initialize: (?logger: Logger?) -> void
72
+ def load: () -> Hash[Symbol, untyped]
73
+
74
+ private
75
+
76
+ def load_raw_config: () -> Hash[String, untyped]?
77
+ def parse_config: (Hash[String, untyped] raw) -> Hash[Symbol, untyped]
78
+ def resolve_templates: (String template, Hash[String, String] defaults) -> String
79
+ def default_config: () -> Hash[Symbol, untyped]
80
+ end
81
+
82
+ class GitAdapter
83
+ def initialize: (?working_dir: String, ?logger: Logger?) -> void
84
+ def repository_root: () -> String
85
+ def current_branch: () -> String
86
+ def commit_sha: (String ref) -> String
87
+ def changed_files_in_commit: (String sha) -> Array[String]
88
+ def changed_files_against: (String target_branch, ?cached: bool) -> Array[String]
89
+
90
+ private
91
+
92
+ def git: () -> untyped
93
+ end
94
+ end
95
+
96
+ module Providers
97
+ class Repository
98
+ def initialize: (?git_adapter: IO::GitAdapter) -> void
99
+ def root: () -> String
100
+ end
101
+
102
+ class GitChangedFiles
103
+ def initialize: (
104
+ ?git_adapter: IO::GitAdapter,
105
+ ?env: Hash[String, String?],
106
+ ?logger: Logger?
107
+ ) -> void
108
+ def call: () -> Array[String]
109
+
110
+ private
111
+
112
+ def changed_files_for_latest_commit: (String branch) -> Array[String]
113
+ def changed_files_against_default_branch: (String default_branch) -> Array[String]
114
+ end
115
+ end
116
+
117
+ module Orchestrators
118
+ class DependencyTracer
119
+ attr_writer current_spec_file: String?
120
+
121
+ def initialize: (
122
+ ?paths: Core::Paths,
123
+ ?store: IO::DependencyStore,
124
+ ?repository: Providers::Repository,
125
+ ?logger: Logger?
126
+ ) -> void
127
+ def with_tracing: () { () -> void } -> void
128
+ def write_output!: () -> void
129
+
130
+ private
131
+
132
+ def tracepoint: () -> TracePoint
133
+ def repository_root: () -> String
134
+ def normalize_path: (String path) -> String
135
+ def build_output: () -> Hash[String, Array[String]]
136
+ end
137
+
138
+ class DependencyCollector
139
+ def initialize: (
140
+ ?paths: Core::Paths,
141
+ ?store: IO::DependencyStore,
142
+ ?logger: Logger?
143
+ ) -> void
144
+ def self.collect!: (**untyped) -> nil
145
+ def collect!: () -> nil
146
+
147
+ private
148
+
149
+ def build_inverse_dependencies: () -> Hash[String, Array[String]]
150
+ def artifact_files: () -> Array[String]
151
+ end
152
+
153
+ class SpecRunDeterminer
154
+ def initialize: (
155
+ ?paths: Core::Paths,
156
+ ?store: IO::DependencyStore,
157
+ ?config_loader: IO::ConfigLoader,
158
+ ?changed_files_provider: Providers::GitChangedFiles,
159
+ ?selector: Core::SpecSelector,
160
+ ?logger: Logger?
161
+ ) -> void
162
+ def self.determine!: (**untyped) -> String?
163
+ def determine!: () -> String?
164
+
165
+ private
166
+
167
+ def load_dependencies: () -> Hash[String, Array[String]]
168
+ def log_debug_info: (Hash[Symbol, untyped] config, Array[String] changed_files) -> void
169
+ end
170
+ end
171
+
172
+ module Integrations
173
+ module RSpec
174
+ def self.install!: () -> void
175
+ end
176
+
177
+ module Minitest
178
+ attr_accessor self.tracer: Orchestrators::DependencyTracer?
179
+
180
+ def self.install!: () -> void
181
+
182
+ module RunPatch
183
+ def run: (?Array[String] args) -> untyped
184
+ end
185
+
186
+ module TestCasePlugin
187
+ def before_setup: () -> void
188
+ def run: () -> untyped
189
+ end
190
+ end
191
+
192
+ class Railtie < Rails::Railtie
193
+ end
194
+ end
195
+ end