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
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RSpecTracer
|
|
4
|
+
# Internal TimeFormatter — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# Internal helper for human-friendly elapsed-time formatting in
|
|
8
|
+
# tracer log lines and the terminal reporter (e.g. "1 minute 23 seconds").
|
|
4
9
|
module TimeFormatter
|
|
10
|
+
# Internal constant.
|
|
11
|
+
# @api private
|
|
5
12
|
DEFAULT_PRECISION = 2
|
|
13
|
+
# Internal constant.
|
|
14
|
+
# @api private
|
|
6
15
|
SECONDS_PRECISION = 5
|
|
7
16
|
|
|
17
|
+
# Internal constant.
|
|
18
|
+
# @api private
|
|
8
19
|
UNITS = {
|
|
9
20
|
second: 60,
|
|
10
21
|
minute: 60,
|
|
@@ -12,9 +23,9 @@ module RSpecTracer
|
|
|
12
23
|
day: Float::INFINITY
|
|
13
24
|
}.freeze
|
|
14
25
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def format_time(seconds)
|
|
26
|
+
# Internal helper for the tracer pipeline.
|
|
27
|
+
# @api private
|
|
28
|
+
def self.format_time(seconds)
|
|
18
29
|
return pluralize(format_duration(seconds), 'second') if seconds < 60
|
|
19
30
|
|
|
20
31
|
formatted_duration = UNITS.each_pair.with_object([]) do |(unit, count), duration|
|
|
@@ -30,26 +41,34 @@ module RSpecTracer
|
|
|
30
41
|
formatted_duration.reverse.join(' ')
|
|
31
42
|
end
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
class << self
|
|
45
|
+
private
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
# Internal method on the tracer pipeline.
|
|
48
|
+
# @api private
|
|
49
|
+
def format_duration(duration)
|
|
50
|
+
return 0 if duration.negative?
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
end
|
|
52
|
+
precision = duration < 1 ? SECONDS_PRECISION : DEFAULT_PRECISION
|
|
40
53
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
end
|
|
54
|
+
strip_trailing_zeroes(format("%<duration>0.#{precision}f", duration: duration))
|
|
55
|
+
end
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"#{duration} #{unit}s"
|
|
57
|
+
# Internal method on the tracer pipeline.
|
|
58
|
+
# @api private
|
|
59
|
+
def strip_trailing_zeroes(formatted_duration)
|
|
60
|
+
formatted_duration.sub(/(?:(\..*[^0])0+|\.0+)$/, '\1')
|
|
50
61
|
end
|
|
51
|
-
end
|
|
52
62
|
|
|
53
|
-
|
|
63
|
+
# Internal method on the tracer pipeline.
|
|
64
|
+
# @api private
|
|
65
|
+
def pluralize(duration, unit)
|
|
66
|
+
if (duration.to_f - 1).abs < Float::EPSILON
|
|
67
|
+
"#{duration} #{unit}"
|
|
68
|
+
else
|
|
69
|
+
"#{duration} #{unit}s"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
54
73
|
end
|
|
55
74
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Tracker
|
|
2
|
+
|
|
3
|
+
Core engine for input identification. A test is a pure function of its
|
|
4
|
+
inputs; this layer identifies every input and hashes it.
|
|
5
|
+
|
|
6
|
+
## Responsibilities
|
|
7
|
+
|
|
8
|
+
- Observe Ruby-executed source via `Coverage`.
|
|
9
|
+
- Intercept file I/O (`File`, `IO`, `YAML`, `JSON`, `Kernel`).
|
|
10
|
+
- Subscribe to framework notifications (`ActiveSupport::Notifications`).
|
|
11
|
+
- Evaluate user-declared globs.
|
|
12
|
+
- Track whole-suite invalidators (`Gemfile.lock`, `.ruby-version`, config,
|
|
13
|
+
gem version).
|
|
14
|
+
- Build the dependency graph and expose `affected_examples` to the
|
|
15
|
+
filter.
|
|
16
|
+
|
|
17
|
+
## Public protocol
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
module RSpecTracer::Tracker
|
|
21
|
+
def setup(configuration:); end
|
|
22
|
+
def start_example(example_id); end
|
|
23
|
+
def stop_example(example_id); end
|
|
24
|
+
def affected_examples(all_example_ids); end
|
|
25
|
+
def finalize; end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Each input type has exactly one observation mechanism. Declared globs
|
|
30
|
+
take precedence over auto-interception for overlapping files.
|
|
31
|
+
|
|
32
|
+
## Status
|
|
33
|
+
|
|
34
|
+
Placeholder for 2.0. Legacy 1.x tracking logic lives in
|
|
35
|
+
`lib/rspec_tracer/` top-level files (`runner.rb`, `rspec_reporter.rb`,
|
|
36
|
+
`ruby_coverage.rb`, `cache.rb`) and migrates here during Phase 3.
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
# Wraps Ruby's built-in ::Coverage module. The first observer in
|
|
14
|
+
# the 2.0 tracker pipeline -- ingests the per-file line-coverage
|
|
15
|
+
# bitmap that MRI/JRuby maintain natively, normalizes the two
|
|
16
|
+
# possible shapes (array vs SimpleCov-style hash) and emits
|
|
17
|
+
# Tracker::Input values for the files touched between two peeks.
|
|
18
|
+
#
|
|
19
|
+
# All state lives on the instance; no reads from RSpecTracer.*.
|
|
20
|
+
# Pass root, filters, and mode at construction time.
|
|
21
|
+
#
|
|
22
|
+
# Content digest is SHA256 hex (see Input's file-level comment).
|
|
23
|
+
# Changing the algorithm is a storage schema_version bump.
|
|
24
|
+
class CoverageAdapter
|
|
25
|
+
# ::Coverage.peek_result returns one of two shapes:
|
|
26
|
+
# :array -- { path => [hit_counts | nil, ...] } (default)
|
|
27
|
+
# :hash -- { path => { lines: [...], branches: {...} } } (SimpleCov
|
|
28
|
+
# with branch
|
|
29
|
+
# coverage)
|
|
30
|
+
# :auto detects on the first peek by sniffing a value's type.
|
|
31
|
+
MODES = %i[auto array hash].freeze
|
|
32
|
+
|
|
33
|
+
# Internal attribute.
|
|
34
|
+
# @api private
|
|
35
|
+
attr_reader :root, :filters, :mode
|
|
36
|
+
|
|
37
|
+
# Internal method on the tracer pipeline.
|
|
38
|
+
# @api private
|
|
39
|
+
def initialize(root:, filters: [], mode: :auto)
|
|
40
|
+
raise ArgumentError, "invalid mode: #{mode.inspect}, allowed: #{MODES}" \
|
|
41
|
+
unless MODES.include?(mode)
|
|
42
|
+
|
|
43
|
+
@root = File.expand_path(root)
|
|
44
|
+
@root_prefix = "#{@root}/"
|
|
45
|
+
@filters = filters
|
|
46
|
+
@mode = mode
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Snapshot of the current coverage state: { absolute_path =>
|
|
50
|
+
# Array<Integer|nil> } for files under project root that survive
|
|
51
|
+
# the user filter. Hash-mode input is reduced to its :lines
|
|
52
|
+
# component -- 2.0 ignores branch coverage (same as 1.x; noted in
|
|
53
|
+
# the upgrade docs).
|
|
54
|
+
def peek
|
|
55
|
+
peek_normalized { |path| filtered?(path) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Same shape as #peek but only filters by `@root_prefix` -- skips
|
|
59
|
+
# the user `filters` filter. The coverage.json emitter
|
|
60
|
+
# (`Reporters::CoverageJsonReporter`) calls this at finalize to
|
|
61
|
+
# capture cumulative coverage matching legacy semantics: 1.x's
|
|
62
|
+
# CoverageReporter#peek_coverage applied no `filters` filter at
|
|
63
|
+
# peek time and let `coverage_filters` do the final exclusion.
|
|
64
|
+
# Routing the emitter's finalize peek through this method keeps
|
|
65
|
+
# the lib/-wide `::Coverage.peek_result` call-site count at 3
|
|
66
|
+
# (one per file: this adapter, rspec/installation, and
|
|
67
|
+
# tracker/loaded_files_tracker) instead of adding a fourth.
|
|
68
|
+
def peek_unfiltered
|
|
69
|
+
peek_normalized { false }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Same root-prefix scoping as #peek_unfiltered, but preserves
|
|
73
|
+
# Coverage's full per-file shape (Hash `{ lines: [...], branches:
|
|
74
|
+
# {...} }` in hash mode; Array<Integer|nil> in array mode) instead
|
|
75
|
+
# of reducing hash entries to their `:lines` component.
|
|
76
|
+
#
|
|
77
|
+
# Lone caller is the SimpleCov interop shim
|
|
78
|
+
# (`Reporters::CoverageJsonReporter::SimpleCovInterop`): when
|
|
79
|
+
# SimpleCov has `enable_coverage :branch`, Coverage runs in hash
|
|
80
|
+
# mode and the prepended `Coverage.result` MUST hand back the
|
|
81
|
+
# branches sub-hash so SimpleCov's branch-coverage report path
|
|
82
|
+
# has data to render. The legacy `peek_unfiltered` strips
|
|
83
|
+
# branches because the user-facing `coverage.json` shape is
|
|
84
|
+
# documented as `Array<Integer|nil>` per file (1.x compatibility);
|
|
85
|
+
# do not change `peek_unfiltered` to do otherwise without bumping
|
|
86
|
+
# the storage schema.
|
|
87
|
+
def peek_unfiltered_full
|
|
88
|
+
raw = ::Coverage.peek_result
|
|
89
|
+
@mode = detect_mode(raw) if @mode == :auto
|
|
90
|
+
|
|
91
|
+
raw.each_with_object({}) do |(path, stats), acc|
|
|
92
|
+
next unless path.start_with?(@root_prefix)
|
|
93
|
+
|
|
94
|
+
acc[path] = stats
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Pure function: returns Set<Input> for files whose line arrays
|
|
99
|
+
# changed between `before` and `after`. Handles nil line entries
|
|
100
|
+
# (unexecutable lines) correctly -- nil<->nil is not a delta, any
|
|
101
|
+
# other transition is.
|
|
102
|
+
def compute_diff(before, after)
|
|
103
|
+
changed = Set.new
|
|
104
|
+
(before.keys | after.keys).each do |path|
|
|
105
|
+
next unless delta?(before[path], after[path])
|
|
106
|
+
|
|
107
|
+
changed << Input.for_file(
|
|
108
|
+
path: path,
|
|
109
|
+
kind: :ruby,
|
|
110
|
+
digest: file_digest(path),
|
|
111
|
+
root: @root
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
changed
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Single owner of the `::Coverage.peek_result` call site for
|
|
120
|
+
# `peek` and `peek_unfiltered`. Skip block decides exclusion;
|
|
121
|
+
# everything that survives gets normalized through the array/hash
|
|
122
|
+
# mode handling.
|
|
123
|
+
def peek_normalized
|
|
124
|
+
raw = ::Coverage.peek_result
|
|
125
|
+
@mode = detect_mode(raw) if @mode == :auto
|
|
126
|
+
|
|
127
|
+
raw.each_with_object({}) do |(path, stats), acc|
|
|
128
|
+
next unless path.start_with?(@root_prefix)
|
|
129
|
+
next if yield(path)
|
|
130
|
+
|
|
131
|
+
acc[path] = @mode == :hash ? stats[:lines] : stats
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Internal method on the tracer pipeline.
|
|
136
|
+
# @api private
|
|
137
|
+
def detect_mode(raw)
|
|
138
|
+
return :array if raw.empty?
|
|
139
|
+
|
|
140
|
+
raw.each_value.first.is_a?(Hash) ? :hash : :array
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Internal method on the tracer pipeline.
|
|
144
|
+
# @api private
|
|
145
|
+
def filtered?(path)
|
|
146
|
+
return false if @filters.empty?
|
|
147
|
+
|
|
148
|
+
# Legacy filter convention: file_name is root-stripped with
|
|
149
|
+
# leading slash. Keep it so existing .rspec-tracer filter
|
|
150
|
+
# strings/regexes work under the new adapter unchanged.
|
|
151
|
+
file_name = path.sub(@root, '')
|
|
152
|
+
@filters.any? { |f| f.match?(file_name: file_name) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Internal method on the tracer pipeline.
|
|
156
|
+
# @api private
|
|
157
|
+
def delta?(before, after)
|
|
158
|
+
return true if before.nil? || after.nil?
|
|
159
|
+
return true if before.length != after.length
|
|
160
|
+
|
|
161
|
+
before.each_with_index do |bv, i|
|
|
162
|
+
return true unless bv == after[i]
|
|
163
|
+
end
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Internal method on the tracer pipeline.
|
|
168
|
+
# @api private
|
|
169
|
+
def file_digest(path)
|
|
170
|
+
FileDigest.compute(path)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require_relative 'file_digest'
|
|
6
|
+
require_relative 'input'
|
|
7
|
+
|
|
8
|
+
module RSpecTracer
|
|
9
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
10
|
+
# @api private
|
|
11
|
+
module Tracker
|
|
12
|
+
# Observer #3 in the 2.0 tracker pipeline (CoverageAdapter = #1,
|
|
13
|
+
# IOHooks = #2). Walks user-declared glob patterns at boot, digests
|
|
14
|
+
# each matching file, and emits :declared Inputs. Declared globs
|
|
15
|
+
# cover inputs that cannot be auto-observed - files no app code
|
|
16
|
+
# reads (Gemfile.lock, .rspec-tracer), or files that might not be
|
|
17
|
+
# rendered during a given run (a newly-added template).
|
|
18
|
+
#
|
|
19
|
+
# Exposes #covers? so IOHooks can skip paths the declared-globs
|
|
20
|
+
# walk already observed. ARCHITECTURE rule (Input taxonomy): "if
|
|
21
|
+
# the user declares a glob that covers the same files we auto-
|
|
22
|
+
# intercept, the declared glob takes precedence."
|
|
23
|
+
#
|
|
24
|
+
# Graceful degradation (CLAUDE.md): an unreadable declared file is
|
|
25
|
+
# skipped silently - the tracer never propagates failure into the
|
|
26
|
+
# user's test suite. Identity-based Set dedup collapses overlap
|
|
27
|
+
# when two globs match the same file.
|
|
28
|
+
class DeclaredGlobs
|
|
29
|
+
# FNM_PATHNAME keeps `*` from eating `/` so globs walk directory
|
|
30
|
+
# boundaries predictably. FNM_EXTGLOB enables `{a,b}` alternation
|
|
31
|
+
# so `coverage_track_files '{app,lib}/**/*.rb'` (a real pattern
|
|
32
|
+
# in the sample projects) matches the same files `Dir.glob` would.
|
|
33
|
+
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
34
|
+
|
|
35
|
+
# Internal attribute.
|
|
36
|
+
# @api private
|
|
37
|
+
attr_reader :root, :globs
|
|
38
|
+
|
|
39
|
+
# Internal method on the tracer pipeline.
|
|
40
|
+
# @api private
|
|
41
|
+
def initialize(root:, globs: [])
|
|
42
|
+
@root = File.expand_path(root)
|
|
43
|
+
@root_prefix = "#{@root}/"
|
|
44
|
+
@globs = Array(globs).flatten.compact.map(&:to_s).uniq.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Memoized across calls - the architecture constraint is "glob
|
|
48
|
+
# walk at boot is a single pass; result cached for the life of
|
|
49
|
+
# the tracker instance."
|
|
50
|
+
def walk
|
|
51
|
+
@walk ||= compute_walk
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# O(N_globs) per call; N is typically 1-10. Absolute paths only -
|
|
55
|
+
# the callers (IOHooks precedence, NewFileDetector diff) always
|
|
56
|
+
# hold absolute paths. Paths outside root never match.
|
|
57
|
+
def covers?(path)
|
|
58
|
+
return false if @globs.empty?
|
|
59
|
+
return false unless path.start_with?(@root_prefix)
|
|
60
|
+
|
|
61
|
+
rel = path[@root_prefix.length..]
|
|
62
|
+
@globs.any? { |glob| File.fnmatch?(glob, rel, FNMATCH_FLAGS) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Per-example attribution. Declared inputs attach to every
|
|
66
|
+
# example in the suite (per-example narrowing is available via
|
|
67
|
+
# the per-example `tracks:` DSL). Each example gets its own Set
|
|
68
|
+
# copy so downstream mutation of one example's input set does
|
|
69
|
+
# not leak into siblings.
|
|
70
|
+
def attribute_to(example_ids)
|
|
71
|
+
inputs = walk
|
|
72
|
+
example_ids.to_h { |id| [id, Set.new(inputs)] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Internal method on the tracer pipeline.
|
|
78
|
+
# @api private
|
|
79
|
+
def compute_walk
|
|
80
|
+
@globs.each_with_object(Set.new) do |glob, acc|
|
|
81
|
+
Dir.glob(glob, base: @root).each do |rel|
|
|
82
|
+
abs = File.expand_path(rel, @root)
|
|
83
|
+
next unless abs.start_with?(@root_prefix) && File.file?(abs)
|
|
84
|
+
|
|
85
|
+
digest = file_digest(abs)
|
|
86
|
+
next if digest.nil?
|
|
87
|
+
|
|
88
|
+
acc << Input.for_file(path: abs, kind: :declared, digest: digest, root: @root)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Internal method on the tracer pipeline.
|
|
94
|
+
# @api private
|
|
95
|
+
def file_digest(path)
|
|
96
|
+
FileDigest.compute(path)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module Tracker
|
|
9
|
+
# Directed bipartite graph over (example_id, file path). The
|
|
10
|
+
# forward map answers "which files does this example depend on?";
|
|
11
|
+
# the memoized inverse answers "which examples depend on this
|
|
12
|
+
# file?" in O(1) per lookup.
|
|
13
|
+
#
|
|
14
|
+
# The graph is append-only during a run. `register_example` is
|
|
15
|
+
# called once per example at example-finished time with the Input
|
|
16
|
+
# set that CoverageAdapter + IOHooks + DeclaredGlobs collected.
|
|
17
|
+
# The inverse is invalidated on every register_example and lazily
|
|
18
|
+
# rebuilt on the next `examples_depending_on` call.
|
|
19
|
+
#
|
|
20
|
+
# Keyed by file path, not Input identity
|
|
21
|
+
# -------------------------------------
|
|
22
|
+
# An earlier design framed the inverse as "input_identity -> Set<example_id>",
|
|
23
|
+
# which embeds the Input#kind (e.g. "ruby:lib/foo.rb" vs
|
|
24
|
+
# "declared:lib/foo.rb"). In practice the change-set that drives
|
|
25
|
+
# filtering comes from diffing Snapshot.all_files digests, which
|
|
26
|
+
# is keyed by path only - the kind that observed the file is not
|
|
27
|
+
# recoverable from a digest mismatch. Keying the graph by path
|
|
28
|
+
# eliminates that recovery burden and matches 1.x's
|
|
29
|
+
# dependency.json shape exactly (Hash[example_id => Set<path>]),
|
|
30
|
+
# keeping the Snapshot byte-compatible. Kind-aware filtering, if
|
|
31
|
+
# ever needed, can layer on top without changing the graph shape.
|
|
32
|
+
#
|
|
33
|
+
# API accepts Set<Input>, Set<String>, or any mix - anything that
|
|
34
|
+
# responds to :path is treated as an Input; otherwise `.to_s`.
|
|
35
|
+
# This keeps observer call sites terse (pass the Input set
|
|
36
|
+
# directly) without forcing Snapshot-load sites to reconstruct
|
|
37
|
+
# Input objects from paths.
|
|
38
|
+
class DependencyGraph
|
|
39
|
+
# Internal method on the tracer pipeline.
|
|
40
|
+
# @api private
|
|
41
|
+
def initialize
|
|
42
|
+
@forward = {}
|
|
43
|
+
@inverse_index = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Internal method on the tracer pipeline.
|
|
47
|
+
# @api private
|
|
48
|
+
def register_example(example_id, inputs)
|
|
49
|
+
@forward[example_id] = coerce_paths(inputs)
|
|
50
|
+
@inverse_index = nil
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Internal method on the tracer pipeline.
|
|
55
|
+
# @api private
|
|
56
|
+
def paths_for(example_id)
|
|
57
|
+
@forward[example_id] || Set.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Internal method on the tracer pipeline.
|
|
61
|
+
# @api private
|
|
62
|
+
def example_ids
|
|
63
|
+
@forward.keys
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Internal method on the tracer pipeline.
|
|
67
|
+
# @api private
|
|
68
|
+
def empty?
|
|
69
|
+
@forward.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# O(|change_set| x avg-examples-per-file). `change_set` can be
|
|
73
|
+
# a Set<Input>, Set<String>, or any mix - coerced the same way
|
|
74
|
+
# `register_example` coerces its inputs arg.
|
|
75
|
+
def examples_depending_on(change_set)
|
|
76
|
+
return Set.new if @forward.empty?
|
|
77
|
+
|
|
78
|
+
paths = coerce_paths(change_set)
|
|
79
|
+
return Set.new if paths.empty?
|
|
80
|
+
|
|
81
|
+
affected = Set.new
|
|
82
|
+
paths.each do |path|
|
|
83
|
+
ids = inverse_index[path]
|
|
84
|
+
affected.merge(ids) if ids
|
|
85
|
+
end
|
|
86
|
+
affected
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Snapshot projection. `dependency_hash` feeds
|
|
90
|
+
# Snapshot.dependency; `reverse_dependency_hash` feeds
|
|
91
|
+
# Snapshot.reverse_dependency. Both return fresh Sets per value
|
|
92
|
+
# so downstream mutation can't leak into the graph's state.
|
|
93
|
+
def dependency_hash
|
|
94
|
+
@forward.transform_values(&:dup)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Internal method on the tracer pipeline.
|
|
98
|
+
# @api private
|
|
99
|
+
def reverse_dependency_hash
|
|
100
|
+
inverse_index.transform_values(&:dup)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Internal method on the tracer pipeline.
|
|
106
|
+
# @api private
|
|
107
|
+
def coerce_paths(collection)
|
|
108
|
+
return Set.new if collection.nil?
|
|
109
|
+
|
|
110
|
+
collection.each_with_object(Set.new) do |entry, acc|
|
|
111
|
+
acc << (entry.respond_to?(:path) ? entry.path : entry.to_s)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Internal method on the tracer pipeline.
|
|
116
|
+
# @api private
|
|
117
|
+
def inverse_index
|
|
118
|
+
@inverse_index ||= build_inverse_index
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Internal method on the tracer pipeline.
|
|
122
|
+
# @api private
|
|
123
|
+
def build_inverse_index
|
|
124
|
+
map = {}
|
|
125
|
+
@forward.each do |example_id, paths|
|
|
126
|
+
paths.each do |path|
|
|
127
|
+
(map[path] ||= Set.new) << example_id
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
map
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Tracker
|
|
7
|
+
# Wildcard env matching helper.
|
|
8
|
+
#
|
|
9
|
+
# Lives outside Configuration so configure's alias loop does not
|
|
10
|
+
# leak its private helpers as public _name DSL surface
|
|
11
|
+
# (memory: feedback_configure_dsl_private_leak). Pure utility
|
|
12
|
+
# module; def self.x style for mutant observability
|
|
13
|
+
# (memory: feedback_mutation_friendly_modules). ASCII-only source
|
|
14
|
+
# (memory: feedback_mutant_non_ascii_source).
|
|
15
|
+
#
|
|
16
|
+
# Patterns accepted:
|
|
17
|
+
# - Literal env name "AUTH_TOKEN"
|
|
18
|
+
# - Single trailing wildcard "RAILS_*"
|
|
19
|
+
# - Single leading wildcard "*_TOKEN"
|
|
20
|
+
# - Bare wildcard (matches all) "*"
|
|
21
|
+
#
|
|
22
|
+
# Patterns rejected (raise ArgumentError):
|
|
23
|
+
# - Multi-segment / embedded * "RAILS_*_ENV"
|
|
24
|
+
# - Multiple * "RAILS_*_*"
|
|
25
|
+
# - Character classes "RAILS_[A-Z]*"
|
|
26
|
+
# - Negation / glob escape "RAILS_!ENV", "RAILS_\X"
|
|
27
|
+
# - Question mark "RAILS_?ENV"
|
|
28
|
+
# - Empty / nil
|
|
29
|
+
module EnvMatcher
|
|
30
|
+
# Internal constant.
|
|
31
|
+
# @api private
|
|
32
|
+
WILDCARD = '*'
|
|
33
|
+
# Internal constant.
|
|
34
|
+
# @api private
|
|
35
|
+
DISALLOWED_CHARS = %w[? [ ] ! \\].freeze
|
|
36
|
+
|
|
37
|
+
# Boolean: does the pattern contain at least one wildcard?
|
|
38
|
+
def self.wildcard?(pattern)
|
|
39
|
+
pattern.to_s.include?(WILDCARD)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Expand a list of patterns against env keys. Literals pass
|
|
43
|
+
# through; wildcards are anchored + grepped against env.keys.
|
|
44
|
+
# Returns a deduped Array<String> in input order.
|
|
45
|
+
#
|
|
46
|
+
# `env` is injectable for testability; defaults to ::ENV. Reads
|
|
47
|
+
# only env.keys, never values.
|
|
48
|
+
def self.expand(patterns, env: ::ENV)
|
|
49
|
+
result = []
|
|
50
|
+
patterns.each do |pattern|
|
|
51
|
+
str = pattern.to_s
|
|
52
|
+
validate!(str)
|
|
53
|
+
if wildcard?(str)
|
|
54
|
+
re = glob_to_regex(str)
|
|
55
|
+
env.each_key { |k| result << k if re.match?(k) }
|
|
56
|
+
else
|
|
57
|
+
result << str
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
result.uniq
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Boolean: does `name` match `pattern`? Single-pattern variant
|
|
64
|
+
# of expand for ad-hoc checks (specs, future call sites). Named
|
|
65
|
+
# `match_glob?` (predicate suffix) per Naming/PredicateMethod.
|
|
66
|
+
def self.match_glob?(pattern, name)
|
|
67
|
+
str = pattern.to_s
|
|
68
|
+
validate!(str)
|
|
69
|
+
return str == name.to_s unless wildcard?(str)
|
|
70
|
+
|
|
71
|
+
glob_to_regex(str).match?(name.to_s)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Raise ArgumentError on unsupported pattern syntax. Called
|
|
75
|
+
# before any regex build so users see a clear message at
|
|
76
|
+
# config-load time (Engine#setup) or at RunnerHook Pass 1
|
|
77
|
+
# (per-example metadata) rather than a regex parse crash.
|
|
78
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
79
|
+
def self.validate!(pattern)
|
|
80
|
+
if pattern.nil? || pattern.empty?
|
|
81
|
+
raise ArgumentError,
|
|
82
|
+
"track_env pattern must be a non-empty String (got #{pattern.inspect})"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
DISALLOWED_CHARS.each do |c|
|
|
86
|
+
next unless pattern.include?(c)
|
|
87
|
+
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"track_env pattern #{pattern.inspect} contains unsupported character " \
|
|
90
|
+
"#{c.inspect} (allowed: literals, single trailing/leading *)"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return if pattern == WILDCARD
|
|
94
|
+
|
|
95
|
+
stars = pattern.count(WILDCARD)
|
|
96
|
+
return if stars.zero?
|
|
97
|
+
|
|
98
|
+
if stars > 1
|
|
99
|
+
raise ArgumentError,
|
|
100
|
+
"track_env pattern #{pattern.inspect} contains multiple wildcards " \
|
|
101
|
+
'(only one * is allowed, at the start or end)'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return if pattern.start_with?(WILDCARD) || pattern.end_with?(WILDCARD)
|
|
105
|
+
|
|
106
|
+
raise ArgumentError,
|
|
107
|
+
"track_env pattern #{pattern.inspect} has an embedded wildcard " \
|
|
108
|
+
'(only single trailing/leading * is supported, e.g. PREFIX_* or *_SUFFIX)'
|
|
109
|
+
end
|
|
110
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
111
|
+
|
|
112
|
+
# Build an anchored regex from a wildcard pattern. `*` becomes
|
|
113
|
+
# `[^=]*` (env names contain no `=` by Posix; defensive). Anchors
|
|
114
|
+
# are mandatory or "RAILS_*" would match "MY_RAILS_THING".
|
|
115
|
+
# Private singleton helper - exposed only via match_glob / expand.
|
|
116
|
+
class << self
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Internal method on the tracer pipeline.
|
|
120
|
+
# @api private
|
|
121
|
+
def glob_to_regex(pattern)
|
|
122
|
+
/\A#{Regexp.escape(pattern).gsub('\\*', '[^=]*')}\z/
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|