rspec-tracer 1.2.3 → 2.0.0.pre.2
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 +4 -4
- data/CHANGELOG.md +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- data/lib/rspec_tracer/runner.rb +0 -278
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require_relative 'file_digest'
|
|
6
|
+
require_relative 'input'
|
|
7
|
+
# Sub-module hooks loaded eagerly: $LOADED_FEATURES makes require
|
|
8
|
+
# idempotent so placement doesn't change observable behavior, and
|
|
9
|
+
# Engine always calls IOHooks.install at boot - the previous
|
|
10
|
+
# install-time require_relative deferred a load that always fires
|
|
11
|
+
# anyway, paying the same total cost ~5 ms later at the cost of
|
|
12
|
+
# the standard requires-at-top Ruby convention.
|
|
13
|
+
require_relative 'io_hooks/file'
|
|
14
|
+
require_relative 'io_hooks/io'
|
|
15
|
+
require_relative 'io_hooks/yaml'
|
|
16
|
+
require_relative 'io_hooks/json'
|
|
17
|
+
require_relative 'io_hooks/kernel'
|
|
18
|
+
|
|
19
|
+
module RSpecTracer
|
|
20
|
+
module Tracker
|
|
21
|
+
# Observer #2 in the 2.0 tracker pipeline (CoverageAdapter is #1).
|
|
22
|
+
# Intercepts Ruby's file-reading primitives via Module#prepend and
|
|
23
|
+
# emits Tracker::Input values for files touched by the currently-
|
|
24
|
+
# active example.
|
|
25
|
+
#
|
|
26
|
+
# Lifecycle:
|
|
27
|
+
# 1. IOHooks.install(root:, filter:, extensions:) - called once
|
|
28
|
+
# at Tracker.setup time. Prepends hook modules onto
|
|
29
|
+
# File/IO/YAML/JSON/Kernel singleton classes.
|
|
30
|
+
# 2. Example execution runs inside IOHooks.with_bucket(bucket)
|
|
31
|
+
# {...}. Hooks push Inputs into the thread-local bucket;
|
|
32
|
+
# outside a with_bucket call every hook fast-rejects.
|
|
33
|
+
# 3. IOHooks.uninstall clears state - prepended modules stay in
|
|
34
|
+
# the ancestry (Ruby has no public API to remove a prepend),
|
|
35
|
+
# but every hook fast-rejects on the nil @root_prefix guard,
|
|
36
|
+
# so post-uninstall they're functionally no-ops.
|
|
37
|
+
#
|
|
38
|
+
# Hot-path rejection order (cheapest first):
|
|
39
|
+
# 1. @root_prefix present (install state)
|
|
40
|
+
# 2. thread-local bucket present (inside an example)
|
|
41
|
+
# 3. path is String / to_s-able
|
|
42
|
+
# 4. path.start_with?(@root_prefix)
|
|
43
|
+
# 5. allow-predicate (extensions + filter, or .rb for Kernel)
|
|
44
|
+
# 6. bucket.key?(identity) (dedup before SHA256)
|
|
45
|
+
#
|
|
46
|
+
# Digest is SHA256 hex (same as CoverageAdapter; schema_version
|
|
47
|
+
# bump to change). Computed only *after* dedup, so a file read N
|
|
48
|
+
# times in one example pays the SHA256 cost exactly once.
|
|
49
|
+
module IOHooks
|
|
50
|
+
# Non-Ruby extensions the hook is interested in. .rb is covered
|
|
51
|
+
# by CoverageAdapter, so it's excluded from the default :data
|
|
52
|
+
# allow-set. Kernel#load uses a separate predicate (.rb only).
|
|
53
|
+
DEFAULT_EXTENSIONS = %w[
|
|
54
|
+
.yml .yaml .json .erb .haml .slim .builder .jbuilder .ru .rake
|
|
55
|
+
].to_set.freeze
|
|
56
|
+
|
|
57
|
+
BUCKET_KEY = :rspec_tracer_io_bucket
|
|
58
|
+
# Re-entry guard: Digest::SHA256.file internally opens the file,
|
|
59
|
+
# which re-fires the File.open hook. Without this flag, the
|
|
60
|
+
# first hooked read blows the stack via infinite recursion.
|
|
61
|
+
REENTRY_KEY = :rspec_tracer_io_in_hook
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
attr_reader :root
|
|
65
|
+
|
|
66
|
+
def install(root:, filter: ->(_path) { true }, extensions: DEFAULT_EXTENSIONS)
|
|
67
|
+
@root = File.expand_path(root)
|
|
68
|
+
@root_prefix = "#{@root}/"
|
|
69
|
+
@extensions = extensions
|
|
70
|
+
@filter = filter
|
|
71
|
+
|
|
72
|
+
::File.singleton_class.prepend(FileReads)
|
|
73
|
+
::IO.singleton_class.prepend(IOReads)
|
|
74
|
+
::YAML.singleton_class.prepend(YAMLReads) if defined?(::YAML)
|
|
75
|
+
::JSON.singleton_class.prepend(JSONReads) if defined?(::JSON)
|
|
76
|
+
# Two prepends: singleton_class catches `Kernel.load('x')`,
|
|
77
|
+
# the module itself catches implicit `load 'x'` in method
|
|
78
|
+
# bodies (via Object's ancestor chain). `module_function`
|
|
79
|
+
# on Kernel creates two separate method objects, so both
|
|
80
|
+
# dispatch paths must be instrumented independently.
|
|
81
|
+
::Kernel.singleton_class.prepend(KernelReads)
|
|
82
|
+
::Kernel.prepend(KernelReads)
|
|
83
|
+
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def uninstall
|
|
88
|
+
@root = nil
|
|
89
|
+
@root_prefix = nil
|
|
90
|
+
@extensions = nil
|
|
91
|
+
@filter = nil
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def installed?
|
|
96
|
+
!@root_prefix.nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def current_bucket
|
|
100
|
+
Thread.current[BUCKET_KEY]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def with_bucket(bucket)
|
|
104
|
+
prev = Thread.current[BUCKET_KEY]
|
|
105
|
+
Thread.current[BUCKET_KEY] = bucket
|
|
106
|
+
begin
|
|
107
|
+
yield
|
|
108
|
+
ensure
|
|
109
|
+
Thread.current[BUCKET_KEY] = prev
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Non-block lifecycle for integration with RSpec hooks (which
|
|
114
|
+
# can't wrap the example body in a Ruby block). The Tracker
|
|
115
|
+
# coordinator calls set_bucket at example_started time and
|
|
116
|
+
# clear_bucket at example_finished time. Unlike with_bucket,
|
|
117
|
+
# these do not save/restore a prior bucket - the coordinator
|
|
118
|
+
# owns the Thread.current slot for the span of an example.
|
|
119
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
120
|
+
def set_bucket(bucket)
|
|
121
|
+
Thread.current[BUCKET_KEY] = bucket
|
|
122
|
+
end
|
|
123
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
124
|
+
|
|
125
|
+
def clear_bucket
|
|
126
|
+
Thread.current[BUCKET_KEY] = nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Record a :data input (File/IO/YAML/JSON hooks). The
|
|
130
|
+
# allow-predicate is the coordinator's default extension +
|
|
131
|
+
# filter combo.
|
|
132
|
+
def record(path)
|
|
133
|
+
_record(path, :data) do |p|
|
|
134
|
+
@extensions.include?(File.extname(p)) && @filter.call(p)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Record a :ruby input (Kernel#load hook). Belt-and-suspenders
|
|
139
|
+
# for dynamically-constructed load paths that might bypass the
|
|
140
|
+
# Coverage module's require-graph observation. The example
|
|
141
|
+
# registry dedupes overlap with CoverageAdapter.
|
|
142
|
+
def record_ruby_load(path)
|
|
143
|
+
_record(path, :ruby) { |p| p.end_with?('.rb') }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# All hook entrypoints funnel through here. Fast-path order is
|
|
149
|
+
# tuned for the common case (hook fires, there's no bucket):
|
|
150
|
+
# do the cheapest checks first and bail before touching any
|
|
151
|
+
# allocation. Only on the slow path do we set the re-entry
|
|
152
|
+
# guard - `Digest::SHA256.file` internally opens the file and
|
|
153
|
+
# would otherwise blow the stack.
|
|
154
|
+
#
|
|
155
|
+
# Any error is swallowed - hooks never propagate failure into
|
|
156
|
+
# the user's test suite (CLAUDE.md "graceful degradation").
|
|
157
|
+
#
|
|
158
|
+
# The early-return ladder looks complex to RuboCop's metric
|
|
159
|
+
# but is actually the simplest shape for a hot path: each
|
|
160
|
+
# guard rejects in ~1-2 machine ops.
|
|
161
|
+
# rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
162
|
+
def _record(path, kind)
|
|
163
|
+
return if @root_prefix.nil?
|
|
164
|
+
|
|
165
|
+
bucket = Thread.current[BUCKET_KEY]
|
|
166
|
+
return if bucket.nil?
|
|
167
|
+
return unless path.is_a?(String) || path.respond_to?(:to_s)
|
|
168
|
+
|
|
169
|
+
path_str = path.to_s
|
|
170
|
+
return unless path_str.start_with?(@root_prefix)
|
|
171
|
+
return unless yield(path_str)
|
|
172
|
+
|
|
173
|
+
identity = "#{kind}:#{path_str[@root_prefix.length..]}"
|
|
174
|
+
return if bucket.key?(identity)
|
|
175
|
+
return if Thread.current[REENTRY_KEY]
|
|
176
|
+
|
|
177
|
+
Thread.current[REENTRY_KEY] = true
|
|
178
|
+
begin
|
|
179
|
+
digest = FileDigest.compute(path_str)
|
|
180
|
+
if digest
|
|
181
|
+
bucket[identity] = Input.for_file(
|
|
182
|
+
path: path_str, kind: kind, digest: digest, root: @root
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
ensure
|
|
186
|
+
Thread.current[REENTRY_KEY] = false
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
# rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'coverage'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
require_relative 'file_digest'
|
|
7
|
+
require_relative 'input'
|
|
8
|
+
|
|
9
|
+
module RSpecTracer
|
|
10
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
11
|
+
# @api private
|
|
12
|
+
module Tracker
|
|
13
|
+
# Observer #5 in the 2.0 tracker pipeline. Closes the constants-
|
|
14
|
+
# lookup blind spot in 1.x's coverage-diff approach.
|
|
15
|
+
#
|
|
16
|
+
# The bug: when file A defines constants at load time and example E2
|
|
17
|
+
# references them without triggering a re-require, E2's coverage
|
|
18
|
+
# diff is empty for A. If A changes, the filter incorrectly skips
|
|
19
|
+
# E2 on the next run.
|
|
20
|
+
#
|
|
21
|
+
# The fix: track every project file Ruby has ever loaded during the
|
|
22
|
+
# process, and attribute them as transitive dependencies of every
|
|
23
|
+
# example that runs afterward.
|
|
24
|
+
#
|
|
25
|
+
# - `@boot_set`: frozen Set<String> captured at Tracker.setup.
|
|
26
|
+
# Files loaded before any example runs (spec_helper requires,
|
|
27
|
+
# gem boot code, constant autoloads). Changes to these are
|
|
28
|
+
# whole-suite invalidators - any modification re-runs every
|
|
29
|
+
# example.
|
|
30
|
+
# - `@loaded_set`: append-only Set<String>. Grows as examples run
|
|
31
|
+
# and Coverage observes new files. Before each example, the
|
|
32
|
+
# filter treats the entire @loaded_set as input to that example;
|
|
33
|
+
# after each example, any newly-loaded path is attributed
|
|
34
|
+
# specifically to the just-completed example *and* added to
|
|
35
|
+
# @loaded_set for the benefit of subsequent examples.
|
|
36
|
+
#
|
|
37
|
+
# Rationale - why not smarter?
|
|
38
|
+
# ----------------------------
|
|
39
|
+
# The cheaper alternatives all fail correctness or cost:
|
|
40
|
+
# - TracePoint(:class, :c_return) fires on every C method call;
|
|
41
|
+
# orders-of-magnitude overhead.
|
|
42
|
+
# - Ruby-AST scans for constant references are unreliable under
|
|
43
|
+
# metaprogramming (const_get, send, autoload blocks).
|
|
44
|
+
# - Constant-table introspection doesn't tell us which *example*
|
|
45
|
+
# used which constant.
|
|
46
|
+
# - Stack-trace sampling is probabilistic - inappropriate for
|
|
47
|
+
# cache correctness.
|
|
48
|
+
# The "loaded set" approach is the cheapest correct solution. Cost:
|
|
49
|
+
# the test cache is slightly less selective (a lib/constants.rb
|
|
50
|
+
# change re-runs every example that ran after it loaded rather than
|
|
51
|
+
# just some subset). Correctness is the win.
|
|
52
|
+
#
|
|
53
|
+
# Input kind reuse
|
|
54
|
+
# ----------------
|
|
55
|
+
# Emits Input values with `kind: :ruby` - every file in the
|
|
56
|
+
# loaded-set is a Ruby source file (`::Coverage` tracks Ruby only),
|
|
57
|
+
# and the dependency graph keys on path (ignoring kind) so a
|
|
58
|
+
# separate `:transitive_load` kind would buy nothing observable.
|
|
59
|
+
# Overlap with CoverageAdapter's `:ruby` emissions dedupes naturally
|
|
60
|
+
# at graph registration.
|
|
61
|
+
#
|
|
62
|
+
# Digest cache
|
|
63
|
+
# ------------
|
|
64
|
+
# Each path is digested at most once per run (first time it appears
|
|
65
|
+
# in either the boot set or a stop_example diff). The cache backs
|
|
66
|
+
# both `loaded_set_inputs` and `boot_set_digest_snapshot`, so
|
|
67
|
+
# boot-set invalidation comparison is free after the initial capture.
|
|
68
|
+
#
|
|
69
|
+
# Enablement flag
|
|
70
|
+
# ---------------
|
|
71
|
+
# `enabled:` (default true) threads through from
|
|
72
|
+
# Configuration#transitive_load_tracking. When false, every method
|
|
73
|
+
# degrades to a no-op that returns empty collections. This gives
|
|
74
|
+
# teams an opt-out for pathological suites where the transitive
|
|
75
|
+
# over-approximation is too aggressive.
|
|
76
|
+
class LoadedFilesTracker
|
|
77
|
+
# Internal constant.
|
|
78
|
+
# @api private
|
|
79
|
+
DEFAULT_PEEK = -> { ::Coverage.peek_result.keys }
|
|
80
|
+
|
|
81
|
+
# boot_set is exposed as an attr_reader rather than a hand-
|
|
82
|
+
# rolled method so RuboCop's Style/TrivialAccessors stays quiet;
|
|
83
|
+
# nil is a valid "not yet captured" state callers rely on.
|
|
84
|
+
attr_reader :root, :boot_set
|
|
85
|
+
|
|
86
|
+
# Internal method on the tracer pipeline.
|
|
87
|
+
# @api private
|
|
88
|
+
def initialize(root:, peek: DEFAULT_PEEK, enabled: true)
|
|
89
|
+
@root = File.expand_path(root)
|
|
90
|
+
@root_prefix = "#{@root}/"
|
|
91
|
+
@peek = peek
|
|
92
|
+
@enabled = enabled
|
|
93
|
+
@boot_set = nil
|
|
94
|
+
@loaded_set = Set.new
|
|
95
|
+
@input_cache = {}
|
|
96
|
+
# Steady-state fast-path cache for stop_example: ::Coverage's
|
|
97
|
+
# tracked-file set grows monotonically; if peek_result.length
|
|
98
|
+
# is unchanged since the last stop_example call, no new project
|
|
99
|
+
# files can have appeared. Skip the per-path filter loop
|
|
100
|
+
# entirely. Initialized to nil so the first call always falls
|
|
101
|
+
# through to full work + populates the cache.
|
|
102
|
+
@last_peek_length = nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Internal method on the tracer pipeline.
|
|
106
|
+
# @api private
|
|
107
|
+
def enabled?
|
|
108
|
+
@enabled
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Capture the boot set once. Idempotent: subsequent calls return
|
|
112
|
+
# the frozen Set captured on the first call. When disabled,
|
|
113
|
+
# returns an empty frozen Set without touching ::Coverage.
|
|
114
|
+
#
|
|
115
|
+
# Paths whose digest fails (unreadable files) are dropped on the
|
|
116
|
+
# floor - they stay absent from @boot_set, @loaded_set, and
|
|
117
|
+
# @input_cache, preserving the "every tracked path has an Input"
|
|
118
|
+
# invariant. Downstream filtering accepts the slight under-count
|
|
119
|
+
# (a truly-unreadable boot file was never going to be a useful
|
|
120
|
+
# invalidation signal anyway).
|
|
121
|
+
def capture_boot_set!
|
|
122
|
+
return @boot_set unless @boot_set.nil?
|
|
123
|
+
|
|
124
|
+
if @enabled
|
|
125
|
+
successful_paths = build_inputs(filtered_peek_paths).each_with_object(Set.new) do |input, acc|
|
|
126
|
+
acc << input.path
|
|
127
|
+
end
|
|
128
|
+
# Construct @loaded_set and @boot_set from distinct Set
|
|
129
|
+
# instances so freezing one can't poison the other -
|
|
130
|
+
# stop_example must be able to mutate @loaded_set forever
|
|
131
|
+
# while @boot_set stays frozen for invalidator comparison.
|
|
132
|
+
@loaded_set = successful_paths
|
|
133
|
+
@boot_set = Set.new(successful_paths).freeze
|
|
134
|
+
else
|
|
135
|
+
@loaded_set = Set.new
|
|
136
|
+
@boot_set = Set.new.freeze
|
|
137
|
+
end
|
|
138
|
+
@boot_set
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Hash[relative_path => sha256_hex] for every file in the boot
|
|
142
|
+
# set. The engine compares this against the previous run's
|
|
143
|
+
# stored `Snapshot.boot_set` - any inequality is a whole-suite
|
|
144
|
+
# invalidator.
|
|
145
|
+
#
|
|
146
|
+
# Invariant (enforced by capture_boot_set!): every path in
|
|
147
|
+
# `@boot_set` has a matching `@input_cache` entry, so the fetch
|
|
148
|
+
# never raises. Disabled trackers produce an empty `@boot_set`,
|
|
149
|
+
# so the enumeration naturally returns {} without a guard.
|
|
150
|
+
def boot_set_digest_snapshot
|
|
151
|
+
return {} if @boot_set.nil?
|
|
152
|
+
|
|
153
|
+
@boot_set.to_h { |path| [relative_path(path), @input_cache.fetch(path).digest] }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Compare the current boot set's digest snapshot against a
|
|
157
|
+
# previously-stored one. `nil` previous_snapshot (first run, no
|
|
158
|
+
# cache) is treated as "not invalidated by this signal" - first
|
|
159
|
+
# run is already a cold run for unrelated reasons.
|
|
160
|
+
#
|
|
161
|
+
# Disabled tracker never invalidates - the engine ORs this with
|
|
162
|
+
# WholeSuiteInvalidators.invalidated?, so returning false keeps
|
|
163
|
+
# the tracker silent when the feature is off.
|
|
164
|
+
def boot_set_invalidated?(previous_snapshot)
|
|
165
|
+
return false unless @enabled
|
|
166
|
+
return false if previous_snapshot.nil?
|
|
167
|
+
|
|
168
|
+
boot_set_digest_snapshot != previous_snapshot
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Set<Input> covering the full @loaded_set. Callers merge this
|
|
172
|
+
# into an example's Input bucket at start_example time - every
|
|
173
|
+
# file loaded up to this point is a transitive dependency.
|
|
174
|
+
# Returns a fresh Set per call so mutation stays local.
|
|
175
|
+
#
|
|
176
|
+
# Disabled trackers never populate @input_cache (capture_boot_set!
|
|
177
|
+
# skips the build_inputs pass), so no explicit enabled guard is
|
|
178
|
+
# needed - the enumeration naturally yields Set.new.
|
|
179
|
+
def loaded_set_inputs
|
|
180
|
+
@input_cache.values.to_set
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Diff-and-grow. Called after an example finishes: peeks
|
|
184
|
+
# ::Coverage, finds paths the tracker hadn't seen yet, digests
|
|
185
|
+
# them, adds them to @loaded_set + @input_cache, and returns the
|
|
186
|
+
# new-paths-only Input set so the caller can attribute them to
|
|
187
|
+
# the just-completed example.
|
|
188
|
+
#
|
|
189
|
+
# Paths whose digest fails are dropped from both @loaded_set and
|
|
190
|
+
# the returned set - the next stop_example will retry them
|
|
191
|
+
# (useful if the failure was transient) and keeps the
|
|
192
|
+
# "@loaded_set => @input_cache has an entry" invariant.
|
|
193
|
+
#
|
|
194
|
+
# Steady state (no new files loaded this example) is ~O(|peek|)
|
|
195
|
+
# with no digest work.
|
|
196
|
+
def stop_example(_example_id)
|
|
197
|
+
return Set.new unless @enabled
|
|
198
|
+
|
|
199
|
+
new_paths = new_filtered_paths
|
|
200
|
+
return Set.new if new_paths.empty?
|
|
201
|
+
|
|
202
|
+
new_inputs = build_inputs(new_paths)
|
|
203
|
+
@loaded_set.merge(new_inputs.map(&:path))
|
|
204
|
+
new_inputs
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Defensive copy for external callers / property specs. Callers
|
|
208
|
+
# that want read-only size should use `loaded_set_size` instead -
|
|
209
|
+
# avoids the dup allocation.
|
|
210
|
+
def loaded_set
|
|
211
|
+
@loaded_set.dup
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Internal method on the tracer pipeline.
|
|
215
|
+
# @api private
|
|
216
|
+
def loaded_set_size
|
|
217
|
+
@loaded_set.size
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# Full filtered peek - returns a Set of every under-root String
|
|
223
|
+
# path in the peek result. Used by capture_boot_set! (no prior
|
|
224
|
+
# `@loaded_set` to diff against).
|
|
225
|
+
def filtered_peek_paths
|
|
226
|
+
@peek.call.each_with_object(Set.new) do |path, acc|
|
|
227
|
+
acc << path if path.is_a?(String) && path.start_with?(@root_prefix)
|
|
228
|
+
end
|
|
229
|
+
rescue StandardError
|
|
230
|
+
Set.new
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Diff-filtered peek - returns only paths not yet in @loaded_set.
|
|
234
|
+
# One hash lookup per peek entry (vs. the two a Set.new + Set - Set
|
|
235
|
+
# pipeline would pay). Halves stop_example steady-state cost.
|
|
236
|
+
#
|
|
237
|
+
# Steady-state fast-path: ::Coverage's tracked-file set grows
|
|
238
|
+
# monotonically across the run, so when peek_result.length matches
|
|
239
|
+
# the cached length from the previous call no new project paths
|
|
240
|
+
# can have appeared. Returns [] without iterating - cuts the
|
|
241
|
+
# per-call cost from ~70 us (full filter loop over ~500 paths) to
|
|
242
|
+
# one Array#length comparison. A profile pass identified this
|
|
243
|
+
# loop as the dominant per-example cost in the engine microbench
|
|
244
|
+
# (16% TOTAL); the fast-path drops it to <1%.
|
|
245
|
+
def new_filtered_paths
|
|
246
|
+
paths = @peek.call
|
|
247
|
+
return [] if @last_peek_length == paths.length
|
|
248
|
+
|
|
249
|
+
@last_peek_length = paths.length
|
|
250
|
+
paths.each_with_object([]) do |path, acc|
|
|
251
|
+
next unless path.is_a?(String)
|
|
252
|
+
next unless path.start_with?(@root_prefix)
|
|
253
|
+
next if @loaded_set.include?(path)
|
|
254
|
+
|
|
255
|
+
acc << path
|
|
256
|
+
end
|
|
257
|
+
rescue StandardError
|
|
258
|
+
[]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Builds Input objects for paths not yet cached; returns the Set
|
|
262
|
+
# of Inputs produced for `paths` (may exclude entries whose
|
|
263
|
+
# digest failed). Side effect: populates @input_cache.
|
|
264
|
+
#
|
|
265
|
+
# Callers are responsible for passing only paths absent from
|
|
266
|
+
# `@input_cache` (capture_boot_set! on empty cache, stop_example
|
|
267
|
+
# on `filtered_peek_keys - @loaded_set` where `@loaded_set`
|
|
268
|
+
# mirrors `@input_cache`'s keys modulo digest failures).
|
|
269
|
+
def build_inputs(paths)
|
|
270
|
+
paths.each_with_object(Set.new) do |path, acc|
|
|
271
|
+
digest = file_digest(path)
|
|
272
|
+
next if digest.nil?
|
|
273
|
+
|
|
274
|
+
input = Input.for_file(path: path, kind: :ruby, digest: digest, root: @root)
|
|
275
|
+
@input_cache[path] = input
|
|
276
|
+
acc << input
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Internal method on the tracer pipeline.
|
|
281
|
+
# @api private
|
|
282
|
+
def file_digest(path)
|
|
283
|
+
FileDigest.compute(path)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Internal method on the tracer pipeline.
|
|
287
|
+
# @api private
|
|
288
|
+
def relative_path(abs_path)
|
|
289
|
+
return abs_path unless abs_path.start_with?(@root_prefix)
|
|
290
|
+
|
|
291
|
+
abs_path[@root_prefix.length..]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require_relative 'declared_globs'
|
|
6
|
+
|
|
7
|
+
module RSpecTracer
|
|
8
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
9
|
+
# @api private
|
|
10
|
+
module Tracker
|
|
11
|
+
# Observer #5 (composition of DeclaredGlobs + cache diff). Fixes
|
|
12
|
+
# a 1.x gap: fetch_changed_files only iterated the previous run's
|
|
13
|
+
# cache, so newly-added source files were never discovered and
|
|
14
|
+
# never triggered re-runs.
|
|
15
|
+
#
|
|
16
|
+
# The fix: at boot, walk the union of user-declared globs and a
|
|
17
|
+
# pure-Ruby default set (lib/**/*.rb). Every match on disk that is
|
|
18
|
+
# not in the loaded cache's known_paths emits an Input, which the
|
|
19
|
+
# filter engine treats as "added" and re-runs any example whose
|
|
20
|
+
# dependency graph could plausibly include it.
|
|
21
|
+
#
|
|
22
|
+
# Kind is :declared for every emission. The pure-Ruby default is
|
|
23
|
+
# logically a pre-declared glob on the user's behalf - the
|
|
24
|
+
# attribution semantics are identical to an explicit
|
|
25
|
+
# `track_files 'lib/**/*.rb'`.
|
|
26
|
+
#
|
|
27
|
+
# The Rails preset (app/**/*.rb) flows through
|
|
28
|
+
# Configuration#track_rails_defaults; the default list here stays
|
|
29
|
+
# framework-agnostic.
|
|
30
|
+
class NewFileDetector
|
|
31
|
+
# Internal constant.
|
|
32
|
+
# @api private
|
|
33
|
+
DEFAULT_GLOBS = %w[lib/**/*.rb].freeze
|
|
34
|
+
|
|
35
|
+
# Internal attribute.
|
|
36
|
+
# @api private
|
|
37
|
+
attr_reader :root
|
|
38
|
+
|
|
39
|
+
# Input contract: declared_globs and default_globs are Arrays of
|
|
40
|
+
# String glob patterns. Configuration#declared_globs already
|
|
41
|
+
# normalizes user input (flatten / compact / to_s / uniq / freeze),
|
|
42
|
+
# and DeclaredGlobs#initialize re-applies the same coercion
|
|
43
|
+
# downstream (`Array(globs).flatten.compact.map(&:to_s).uniq.freeze`),
|
|
44
|
+
# so this constructor stays narrow on purpose - any defensive
|
|
45
|
+
# coercion here would be triple-applied dead code.
|
|
46
|
+
def initialize(root:, declared_globs: [], default_globs: DEFAULT_GLOBS)
|
|
47
|
+
@root = File.expand_path(root)
|
|
48
|
+
@walker = DeclaredGlobs.new(root: @root, globs: declared_globs + default_globs)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Set<Input> for every on-disk match not present in the supplied
|
|
52
|
+
# known_paths Set. Called once per suite boot; the walker's
|
|
53
|
+
# underlying digest work is memoized on the DeclaredGlobs
|
|
54
|
+
# instance so repeated calls within a single suite don't re-hash.
|
|
55
|
+
# Engine#compute_change_set passes a Set directly; Set#include?
|
|
56
|
+
# is O(1) so no upstream `to_set` is needed.
|
|
57
|
+
def new_files(known_paths:)
|
|
58
|
+
@walker.walk.reject { |input| known_paths.include?(input.path) }.to_set
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
require_relative 'file_digest'
|
|
6
|
+
require_relative '../version'
|
|
7
|
+
|
|
8
|
+
module RSpecTracer
|
|
9
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
10
|
+
# @api private
|
|
11
|
+
module Tracker
|
|
12
|
+
# Observer #4 in the 2.0 tracker pipeline. Emits the binary
|
|
13
|
+
# "blow it all up" signal that runs before any per-example
|
|
14
|
+
# filtering - when any watched file changes, the filter engine
|
|
15
|
+
# treats every example as affected.
|
|
16
|
+
#
|
|
17
|
+
# Watch list is deliberately hard-coded (not config-overridable):
|
|
18
|
+
# these are the files whose semantics are universal across any
|
|
19
|
+
# rspec-tracer user.
|
|
20
|
+
#
|
|
21
|
+
# - Gemfile.lock : dependency changes ripple through every spec
|
|
22
|
+
# - .ruby-version : Ruby version changes can shift any behavior
|
|
23
|
+
# - .rspec-tracer : tracer config changes (filters, declared
|
|
24
|
+
# globs) affect what the cache considers fresh
|
|
25
|
+
#
|
|
26
|
+
# Plus a synthetic entry for the rspec-tracer gem identity itself -
|
|
27
|
+
# a gem upgrade that changes invalidation semantics has to
|
|
28
|
+
# invalidate the cache, which lockfile tracking alone doesn't catch
|
|
29
|
+
# (the gem path is version-stamped but Gemfile.lock only sees the
|
|
30
|
+
# constraint, not the resolved install).
|
|
31
|
+
#
|
|
32
|
+
# Graceful degradation (CLAUDE.md): absent watch files are skipped
|
|
33
|
+
# silently. Key-presence asymmetry is the invalidation signal
|
|
34
|
+
# (snapshot A has key, snapshot B does not => invalidated).
|
|
35
|
+
class WholeSuiteInvalidators
|
|
36
|
+
# Internal constant.
|
|
37
|
+
# @api private
|
|
38
|
+
WATCH_FILES = %w[Gemfile.lock .ruby-version .rspec-tracer].freeze
|
|
39
|
+
# Internal constant.
|
|
40
|
+
# @api private
|
|
41
|
+
GEM_IDENTITY_KEY = 'rspec-tracer-gem'
|
|
42
|
+
|
|
43
|
+
# Internal attribute.
|
|
44
|
+
# @api private
|
|
45
|
+
attr_reader :root, :gem_version
|
|
46
|
+
|
|
47
|
+
# Internal method on the tracer pipeline.
|
|
48
|
+
# @api private
|
|
49
|
+
def initialize(root:, gem_version: RSpecTracer::VERSION)
|
|
50
|
+
@root = File.expand_path(root)
|
|
51
|
+
@gem_version = gem_version
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Fresh snapshot on every call - callers typically take one at
|
|
55
|
+
# boot (stored as the "current" snapshot) and compare against a
|
|
56
|
+
# previously-loaded snapshot (returned by the storage backend).
|
|
57
|
+
# Unlike DeclaredGlobs.walk, this is not memoized: the caller
|
|
58
|
+
# chooses when to sample.
|
|
59
|
+
def digest_snapshot
|
|
60
|
+
snapshot = {}
|
|
61
|
+
WATCH_FILES.each do |rel|
|
|
62
|
+
digest = file_digest(File.join(@root, rel))
|
|
63
|
+
snapshot[rel] = digest unless digest.nil?
|
|
64
|
+
end
|
|
65
|
+
snapshot[GEM_IDENTITY_KEY] = gem_identity_digest
|
|
66
|
+
snapshot
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# nil previous_snapshot = first-run case (no cache); treat as
|
|
70
|
+
# invalidated so the filter engine runs every example. Any
|
|
71
|
+
# subsequent run with a stored snapshot compares value-equality
|
|
72
|
+
# across the whole Hash, which captures added/removed/changed
|
|
73
|
+
# watch files in one check.
|
|
74
|
+
def invalidated?(previous_snapshot)
|
|
75
|
+
return true if previous_snapshot.nil?
|
|
76
|
+
|
|
77
|
+
digest_snapshot != previous_snapshot
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# SystemCallError on missing / non-file paths is treated as
|
|
83
|
+
# "not present" via the central FileDigest cache, which returns
|
|
84
|
+
# nil. Mirrors every other tracker's digest path.
|
|
85
|
+
def file_digest(path)
|
|
86
|
+
FileDigest.compute(path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Internal method on the tracer pipeline.
|
|
90
|
+
# @api private
|
|
91
|
+
def gem_identity_digest
|
|
92
|
+
Digest::SHA256.hexdigest("rspec-tracer-#{@gem_version}")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/rspec_tracer/version.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RSpecTracer
|
|
4
|
-
|
|
4
|
+
# The currently installed gem version, in `MAJOR.MINOR.PATCH[.pre.N]`
|
|
5
|
+
# form. Bumped per release; CI's release workflow asserts the tag
|
|
6
|
+
# matches this constant before pushing to RubyGems.
|
|
7
|
+
VERSION = '2.0.0.pre.2'
|
|
5
8
|
end
|