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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c55000e91497b314c04f6a3139416b257e63af8c16deb7c1d7ddbb657e4d2443
4
+ data.tar.gz: '028a4517c2bfc6fe8d0f3fa1341fb1f00b79852cc13999d38cc176edf3f4b92d'
5
+ SHA512:
6
+ metadata.gz: 5622bd3c3c4cd2a056711798b435b928a0bc927bd74c358a4afe6764903983682c68e0ce958a0ba3d5e793644a4ae206c19ead323ab7f1456df0eaf7c9546b9f
7
+ data.tar.gz: 46661f6f0882c81122d401a68424b927dd57bc3154628cf838fb33bf6ac642c34f8563cbbf1b594617c61193a79478d4e1c2a5df800f08ebd765495d5869c993
data/AGENTS.md ADDED
@@ -0,0 +1,93 @@
1
+ # Spectracer - Agent Instructions
2
+
3
+ ## Overview
4
+
5
+ Spectracer is a Ruby gem that traces RSpec/Minitest dependencies and determines which specs to run based on git changes. It integrates with Buildkite CI for parallel test optimization.
6
+
7
+ ## Commands
8
+
9
+ ### Testing
10
+ ```bash
11
+ bundle exec rspec # Run all tests
12
+ bundle exec rspec spec/core/ # Run specific directory
13
+ ```
14
+
15
+ ### Linting
16
+ ```bash
17
+ bundle exec standardrb # Check style
18
+ bundle exec standardrb --fix # Auto-fix issues
19
+ ```
20
+
21
+ ### Full CI Check
22
+ ```bash
23
+ bundle exec rake # Runs spec + standardrb
24
+ ```
25
+
26
+ ### Build Gem
27
+ ```bash
28
+ bundle exec rake build # Build .gem file
29
+ bundle exec rake install # Install locally
30
+ ```
31
+
32
+ ## Architecture
33
+
34
+ ```
35
+ lib/spectracer/
36
+ ├── core/ # Pure business logic (no I/O)
37
+ │ ├── paths.rb # Path computation from env
38
+ │ └── spec_selector.rb # Spec selection algorithm
39
+ ├── io/ # I/O adapters (mockable)
40
+ │ ├── command_runner.rb # Shell command wrapper
41
+ │ ├── config_loader.rb # YAML config loading
42
+ │ ├── dependency_store.rb # Gzip JSON read/write
43
+ │ └── git_adapter.rb # Git gem wrapper
44
+ ├── providers/ # Data providers
45
+ │ ├── git_changed_files.rb # Changed file detection
46
+ │ └── repository.rb # Repo root detection
47
+ ├── orchestrators/ # Coordinate components
48
+ │ ├── dependency_collector.rb
49
+ │ ├── dependency_tracer.rb
50
+ │ └── spec_run_determiner.rb
51
+ ├── integrations/ # Test framework hooks
52
+ │ ├── minitest.rb
53
+ │ ├── railtie.rb
54
+ │ └── rspec.rb
55
+ ├── tasks/
56
+ │ └── spectracer.rake
57
+ ├── logger.rb
58
+ └── version.rb
59
+ ```
60
+
61
+ ## Code Conventions
62
+
63
+ - All classes use dependency injection for testability
64
+ - Core classes are pure functions (no side effects)
65
+ - I/O classes wrap external dependencies and are mockable
66
+ - Use `frozen_string_literal: true` in all files
67
+ - Follow StandardRB style (double quotes, no trailing commas)
68
+ - RBS type signatures in `sig/spectracer.rbs`
69
+
70
+ ## Key Design Decisions
71
+
72
+ 1. **No ENV at require-time**: All environment variables read at runtime via injected `env` parameter
73
+ 2. **Dependency injection**: All collaborators passed via `initialize` with sensible defaults
74
+ 3. **Pure core logic**: `SpecSelector` has no I/O, fully testable with plain data
75
+ 4. **Git gem over shelling out**: Uses `ruby-git` gem for git operations
76
+
77
+ ## Testing Patterns
78
+
79
+ - Unit tests mock collaborators using `instance_double`
80
+ - Integration tests use real git operations
81
+ - Test files mirror source structure: `lib/spectracer/core/paths.rb` → `spec/core/paths_spec.rb`
82
+
83
+ ## Environment Variables
84
+
85
+ | Variable | Purpose |
86
+ |----------|---------|
87
+ | `WITH_SPECTRACER_TRACING` | Enable dependency tracing (`"true"`) |
88
+ | `WITH_SPECTRACER_DEBUG` | Enable debug logging (`"true"`) |
89
+ | `SPECTRACER_TMP_DIRECTORY` | Override output directory |
90
+ | `BUILDKITE_BUILD_ID` | Buildkite build identifier |
91
+ | `BUILDKITE_JOB_ID` | Buildkite job identifier |
92
+ | `BUILDKITE_BRANCH` | Current branch name |
93
+ | `BUILDKITE_PIPELINE_DEFAULT_BRANCH` | Default branch (e.g., `main`) |
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0] - 2026-01-31
4
+
5
+ ### Added
6
+
7
+ - Intelligent test selection based on git changes
8
+ - RSpec and Minitest integration via TracePoint
9
+ - Dependency tracing with gzip-compressed JSON storage
10
+ - Buildkite CI integration for parallel test optimization
11
+ - Git adapter for changed file detection
12
+ - Configurable via `.spectracer.yml`
13
+
14
+ ### Changed
15
+
16
+ - Switch TracePoint from `:line` to `:call` events for dramatically improved performance
17
+ - Renamed gem from `spectacle` to `spectracer`
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Mitch Smith
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,177 @@
1
+ # Spectracer
2
+
3
+ > spectracer (noun): something exhibited to view as unusual, notable, or entertaining
4
+
5
+ Spectracer is a Ruby gem that intelligently determines which specs to run based on the files you've changed. By tracing dependencies during test execution, Spectracer builds a map of which source files are used by which specs, then uses this data to run only the relevant tests.
6
+
7
+ ## Features
8
+
9
+ - **Dependency tracing** - Automatically tracks which files each spec depends on
10
+ - **Smart test selection** - Only runs specs affected by your changes
11
+ - **CI integration** - Built for Buildkite with parallel job support
12
+ - **Framework support** - Works with both RSpec and Minitest
13
+ - **Rails compatible** - Includes Railtie for automatic setup
14
+
15
+ ## Installation
16
+
17
+ Add Spectracer to your Gemfile:
18
+
19
+ ```ruby
20
+ group :development, :test do
21
+ gem "spectracer", github: "mitchbne/spectracer", branch: "main"
22
+ end
23
+ ```
24
+
25
+ Then run:
26
+
27
+ ```bash
28
+ bundle install
29
+ bundle exec rake spectracer:install
30
+ ```
31
+
32
+ This creates a `.spectracer.yml` configuration file in your project root.
33
+
34
+ ## How It Works
35
+
36
+ Spectracer operates in three phases:
37
+
38
+ ### 1. Tracing Phase
39
+
40
+ Run your full test suite with tracing enabled:
41
+
42
+ ```bash
43
+ WITH_SPECTRACER_TRACING=true bundle exec rspec
44
+ ```
45
+
46
+ This uses Ruby's TracePoint to record which files each spec touches, outputting a gzipped JSON file mapping specs to their dependencies.
47
+
48
+ ### 2. Collection Phase
49
+
50
+ After running specs across parallel jobs, collect all tracing artifacts:
51
+
52
+ ```bash
53
+ bundle exec rake spectracer:collect_dependencies
54
+ ```
55
+
56
+ This combines individual trace files into a single inverse dependency map: for each source file, which specs depend on it.
57
+
58
+ ### 3. Selection Phase
59
+
60
+ When running tests on a branch, determine which specs to run:
61
+
62
+ ```bash
63
+ SPECS=$(bundle exec rake spectracer:spec_determiner)
64
+ bundle exec rspec $SPECS
65
+ ```
66
+
67
+ ## Configuration
68
+
69
+ Create a `.spectracer.yml` file in your project root:
70
+
71
+ ```yaml
72
+ defaults:
73
+ all_specs: "spec/**/*_spec.rb"
74
+ no_specs: ""
75
+
76
+ on_empty_spec_set: "{{all_specs}}"
77
+
78
+ globs_matcher:
79
+ "Gemfile": "{{all_specs}}"
80
+ "Gemfile.lock": "{{all_specs}}"
81
+ "db/schema.rb": "spec/models/**/*_spec.rb"
82
+ "config/routes.rb": "spec/routing/**/*_spec.rb"
83
+ "app/views/**/*": "spec/views/**/*_spec.rb,spec/features/**/*_spec.rb"
84
+ ```
85
+
86
+ ### Configuration Options
87
+
88
+ | Option | Description |
89
+ |--------|-------------|
90
+ | `defaults` | Named patterns that can be referenced in other options |
91
+ | `on_empty_spec_set` | Pattern to run when no specs are affected by changes |
92
+ | `globs_matcher` | Map of file globs to spec patterns to run when matched |
93
+
94
+ ## Buildkite Integration
95
+
96
+ Example `pipeline.yml`:
97
+
98
+ ```yaml
99
+ steps:
100
+ # Step 1: Run full suite with tracing (weekly/nightly)
101
+ - label: ":rspec: Full Suite with Tracing"
102
+ command: |
103
+ WITH_SPECTRACER_TRACING=true bundle exec rspec --format progress
104
+ artifact_paths:
105
+ - "tmp/spectracer/**/*"
106
+ branches: main
107
+
108
+ # Step 2: Collect dependencies
109
+ - label: ":package: Collect Dependencies"
110
+ command: |
111
+ buildkite-agent artifact download "tmp/spectracer/**/*" .
112
+ bundle exec rake spectracer:collect_dependencies
113
+ artifact_paths:
114
+ - "tmp/spectracer/dependencies.json.gz"
115
+ depends_on: "full-suite"
116
+
117
+ # Step 3: Run affected specs on feature branches
118
+ - label: ":rspec: Affected Specs"
119
+ command: |
120
+ buildkite-agent artifact download "tmp/spectracer/dependencies.json.gz" .
121
+ SPECS=$(bundle exec rake spectracer:spec_determiner)
122
+ bundle exec rspec $SPECS
123
+ branches: "!main"
124
+ ```
125
+
126
+ ## RSpec Integration
127
+
128
+ Spectracer automatically configures RSpec when required. No additional setup needed:
129
+
130
+ ```ruby
131
+ # Gemfile
132
+ gem "spectracer"
133
+
134
+ # That's it! Tracing is enabled via WITH_SPECTRACER_TRACING=true
135
+ ```
136
+
137
+ ## Minitest Integration
138
+
139
+ Spectracer also supports Minitest:
140
+
141
+ ```ruby
142
+ # test/test_helper.rb
143
+ require "spectracer"
144
+
145
+ # Tracing is automatically enabled when WITH_SPECTRACER_TRACING=true
146
+ ```
147
+
148
+ ## Debug Mode
149
+
150
+ Enable debug logging to see what Spectracer is doing:
151
+
152
+ ```bash
153
+ WITH_SPECTRACER_DEBUG=true bundle exec rake spectracer:spec_determiner
154
+ ```
155
+
156
+ ## Development
157
+
158
+ After checking out the repo:
159
+
160
+ ```bash
161
+ bin/setup # Install dependencies
162
+ bundle exec rake # Run tests + linting
163
+ bundle exec rspec # Run tests only
164
+ bundle exec standardrb # Run linting only
165
+ ```
166
+
167
+ ## Architecture
168
+
169
+ See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
170
+
171
+ ## Contributing
172
+
173
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mitchbne/spectracer.
174
+
175
+ ## License
176
+
177
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,15 @@
1
+ # This file was generated by the 'spectracer:install' Rake task.
2
+ # You can modify this file to customize the behavior of Spectracer.
3
+ defaults:
4
+ all_specs_glob: "spec/**/*_spec.rb"
5
+ no_specs_glob: "{}"
6
+
7
+ # When no spec files are detected to run, Spectracer will run the specs defined in the 'on_empty_spec_set' key.
8
+ on_empty_spec_set: "{{no_specs_glob}}"
9
+
10
+ # If a file changes and it matches the glob, the corresponding pattern will be added to the list of spec files to run.
11
+ # These will take precedence over the 'on_empty_spec_set' key.
12
+ globs_matcher:
13
+ "Gemfile": "{{all_specs_glob}}"
14
+ "Gemfile.lock": "{{all_specs_glob}}"
15
+ "config/**": "{{all_specs_glob}}"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Core
5
+ class Paths
6
+ DEFAULT_OUTPUT_DIR = "tmp/spectracer"
7
+
8
+ def initialize(env: ENV)
9
+ @env = env
10
+ end
11
+
12
+ def build_id
13
+ @env["BUILDKITE_BUILD_ID"] || "local"
14
+ end
15
+
16
+ def job_id
17
+ @env["BUILDKITE_JOB_ID"] || "local"
18
+ end
19
+
20
+ def output_directory
21
+ @env["SPECTRACER_TMP_DIRECTORY"] || DEFAULT_OUTPUT_DIR
22
+ end
23
+
24
+ def spec_artifact_output_file
25
+ File.join(output_directory, "tracing_output", build_id, "#{job_id}.json.gz")
26
+ end
27
+
28
+ def spec_artifacts_download_glob
29
+ File.join(output_directory, "tracing_output", build_id, "*.json.gz")
30
+ end
31
+
32
+ def collected_dependencies_file
33
+ File.join(output_directory, "dependencies.json.gz")
34
+ end
35
+
36
+ def normalize(file_path, repo_root:)
37
+ relative = file_path.sub(/\A#{Regexp.escape(repo_root)}/, ".")
38
+ relative.start_with?("./") ? relative : "./#{relative}"
39
+ end
40
+
41
+ def strip_dot_prefix(path)
42
+ path.sub(%r{\A\./}, "")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Core
5
+ class SpecSelector
6
+ def call(changed_files:, inverse_deps:, globs:, on_empty:)
7
+ spec_set = Set.new
8
+
9
+ changed_files.each do |file|
10
+ spec_set.add(file) if file.end_with?("_spec.rb")
11
+
12
+ file_key = file.start_with?("./") ? file : "./#{file}"
13
+ if (specs = inverse_deps[file_key])
14
+ spec_set.merge(specs)
15
+ end
16
+
17
+ globs.each do |glob, pattern|
18
+ normalized_glob = glob.sub(%r{\A\./}, "")
19
+ spec_set.add(pattern) if File.fnmatch?(normalized_glob, file)
20
+ end
21
+ end
22
+
23
+ files = spec_set.to_a.sort
24
+ files.empty? ? on_empty : files.join(",")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Integrations
5
+ module Minitest
6
+ class << self
7
+ attr_accessor :tracer
8
+
9
+ def install!
10
+ return unless defined?(::Minitest)
11
+ return unless ENV["WITH_SPECTRACER_TRACING"] == "true"
12
+
13
+ logger = Spectracer::Logger.new(
14
+ enabled: ENV["WITH_SPECTRACER_DEBUG"] == "true",
15
+ level: :debug
16
+ )
17
+
18
+ self.tracer = Spectracer::Orchestrators::DependencyTracer.new(logger: logger)
19
+
20
+ ::Minitest.singleton_class.prepend(RunPatch)
21
+ end
22
+ end
23
+
24
+ module RunPatch
25
+ def run(args = [])
26
+ result = super
27
+ Spectracer::Integrations::Minitest.tracer&.write_output!
28
+ result
29
+ end
30
+ end
31
+
32
+ module TestCasePlugin
33
+ def before_setup
34
+ super
35
+ tracer = Spectracer::Integrations::Minitest.tracer
36
+ return unless tracer
37
+
38
+ file_path = method(name).source_location&.first
39
+ tracer.current_spec_file = file_path if file_path
40
+ end
41
+
42
+ def run
43
+ tracer = Spectracer::Integrations::Minitest.tracer
44
+ return super unless tracer
45
+
46
+ tracer.with_tracing { super }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ if defined?(::Minitest::Test) && ENV["WITH_SPECTRACER_TRACING"] == "true"
54
+ ::Minitest::Test.prepend(Spectracer::Integrations::Minitest::TestCasePlugin)
55
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Spectracer
6
+ module Integrations
7
+ class Railtie < Rails::Railtie
8
+ rake_tasks do
9
+ load "spectracer/tasks/spectracer.rake"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectracer
4
+ module Integrations
5
+ module RSpec
6
+ def self.install!
7
+ return unless defined?(::RSpec) && ::RSpec.respond_to?(:configure)
8
+ return unless ENV["WITH_SPECTRACER_TRACING"] == "true"
9
+
10
+ logger = Spectracer::Logger.new(
11
+ enabled: ENV["WITH_SPECTRACER_DEBUG"] == "true",
12
+ level: :debug
13
+ )
14
+
15
+ tracer = Spectracer::Orchestrators::DependencyTracer.new(logger: logger)
16
+
17
+ ::RSpec.configure do |config|
18
+ config.around(:example) do |example|
19
+ tracer.current_spec_file = example.file_path
20
+ tracer.with_tracing { example.run }
21
+ end
22
+
23
+ config.after(:suite) do
24
+ tracer.write_output!
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Spectracer
6
+ module IO
7
+ class CommandRunner
8
+ def initialize(logger: nil)
9
+ @logger = logger
10
+ end
11
+
12
+ def run(command)
13
+ @logger&.debug("Running command: '#{command}'")
14
+
15
+ stdout, stderr, status = Open3.capture3(command)
16
+
17
+ unless status.success?
18
+ @logger&.warn("Command failed with status #{status.exitstatus}: #{stderr}")
19
+ end
20
+
21
+ stdout.chomp
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Spectracer
6
+ module IO
7
+ class ConfigLoader
8
+ FILE_PATH = ".spectracer.yml"
9
+ DEFAULT_FILE_PATH = File.expand_path("../../spectracer.default.yml", __dir__)
10
+
11
+ def initialize(logger: nil)
12
+ @logger = logger
13
+ end
14
+
15
+ def load
16
+ raw_config = load_raw_config
17
+ return default_config if raw_config.nil?
18
+
19
+ parse_config(raw_config)
20
+ rescue => e
21
+ @logger&.error("Error loading configuration: #{e.message}")
22
+ default_config
23
+ end
24
+
25
+ private
26
+
27
+ def load_raw_config
28
+ if File.exist?(FILE_PATH)
29
+ YAML.safe_load_file(FILE_PATH)
30
+ else
31
+ @logger&.debug("No configuration file found at #{FILE_PATH.inspect}. Using defaults.")
32
+ YAML.safe_load_file(DEFAULT_FILE_PATH)
33
+ end
34
+ end
35
+
36
+ def parse_config(raw)
37
+ defaults = raw.fetch("defaults", {})
38
+ on_empty = raw.fetch("on_empty_spec_set", "")
39
+ globs_matcher = raw.fetch("globs_matcher", {})
40
+
41
+ {
42
+ on_empty_spec_set: resolve_templates(on_empty, defaults),
43
+ globs: globs_matcher.transform_values { |v| resolve_templates(v, defaults) }
44
+ }
45
+ end
46
+
47
+ def resolve_templates(template, defaults)
48
+ template.gsub(/\{\{(.*?)\}\}/) { defaults[$1] || "" }
49
+ end
50
+
51
+ def default_config
52
+ {on_empty_spec_set: "", globs: {}}
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "zlib"
6
+
7
+ module Spectracer
8
+ module IO
9
+ class DependencyStore
10
+ def initialize(logger: nil)
11
+ @logger = logger
12
+ end
13
+
14
+ def read(file_path)
15
+ return {} unless File.exist?(file_path)
16
+
17
+ case File.extname(file_path)
18
+ when ".gz"
19
+ read_gzipped_json(file_path)
20
+ when ".json"
21
+ read_json(file_path)
22
+ else
23
+ @logger&.warn("Unknown file format: #{file_path}")
24
+ {}
25
+ end
26
+ rescue JSON::ParserError => e
27
+ @logger&.error("Failed to parse JSON file '#{file_path}': #{e.message}")
28
+ {}
29
+ end
30
+
31
+ def write(data, file_path)
32
+ FileUtils.mkdir_p(File.dirname(file_path))
33
+
34
+ @logger&.debug("Writing to #{file_path}")
35
+ @logger&.debug(JSON.pretty_generate(data))
36
+
37
+ File.open(file_path, "wb") do |f|
38
+ Zlib::GzipWriter.wrap(f) do |gz|
39
+ gz.write(data.to_json)
40
+ end
41
+ end
42
+ end
43
+
44
+ def glob(pattern)
45
+ Dir.glob(pattern)
46
+ end
47
+
48
+ private
49
+
50
+ def read_gzipped_json(file_path)
51
+ Zlib::GzipReader.open(file_path) do |gz|
52
+ JSON.parse(gz.read)
53
+ end
54
+ end
55
+
56
+ def read_json(file_path)
57
+ JSON.parse(File.read(file_path))
58
+ end
59
+ end
60
+ end
61
+ end