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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. 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
- module_function
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
- def format_duration(duration)
34
- return 0 if duration.negative?
44
+ class << self
45
+ private
35
46
 
36
- precision = duration < 1 ? SECONDS_PRECISION : DEFAULT_PRECISION
47
+ # Internal method on the tracer pipeline.
48
+ # @api private
49
+ def format_duration(duration)
50
+ return 0 if duration.negative?
37
51
 
38
- strip_trailing_zeroes(format("%<duration>0.#{precision}f", duration: duration))
39
- end
52
+ precision = duration < 1 ? SECONDS_PRECISION : DEFAULT_PRECISION
40
53
 
41
- def strip_trailing_zeroes(formatted_duration)
42
- formatted_duration.sub(/(?:(\..*[^0])0+|\.0+)$/, '\1')
43
- end
54
+ strip_trailing_zeroes(format("%<duration>0.#{precision}f", duration: duration))
55
+ end
44
56
 
45
- def pluralize(duration, unit)
46
- if (duration.to_f - 1).abs < Float::EPSILON
47
- "#{duration} #{unit}"
48
- else
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
- private_class_method :format_duration, :strip_trailing_zeroes, :pluralize
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