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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTracer
4
+ # Internal Reporters — see {RSpecTracer} for the user-facing surface.
5
+ # @api private
6
+ module Reporters
7
+ # Orchestrates reporter emission at finalize-time. Called from
8
+ # `RSpecTracer#run_exit_tasks` once the Engine has persisted its
9
+ # Snapshot (architectural decision (a): wire from run_exit_tasks,
10
+ # not Engine-internal). Each configured reporter gets an isolated
11
+ # rescue; a buggy reporter warns + continues, never propagates a
12
+ # non-zero exit into the user's test suite.
13
+ #
14
+ # Reporter resolution:
15
+ # - Configuration#reporters returns `[[name_or_class, opts], ...]`
16
+ # when the user called `add_reporter`; `nil` otherwise.
17
+ # - When nil, falls back to `DEFAULTS` (`[:terminal, :json]`).
18
+ # - Symbol names resolve via `BUILT_INS` to in-tree reporter
19
+ # classes. Class values pass through as-is (custom reporters
20
+ # conforming to `Reporters::Base`).
21
+ # - Unknown symbols raise `ArgumentError` at emit time; the DSL
22
+ # validates eagerly, so this is the safety net for programmatic
23
+ # callers.
24
+ class Registry
25
+ # Symbol -> lazy class-name mapping. Strings (not Class constants)
26
+ # so the require order doesn't force load of reporter classes
27
+ # when the Registry module itself is loaded - matches how
28
+ # `storage_backend`'s Configuration DSL defers backend resolution.
29
+ BUILT_INS = {
30
+ terminal: 'RSpecTracer::Reporters::TerminalReporter',
31
+ json: 'RSpecTracer::Reporters::JsonReporter',
32
+ html: 'RSpecTracer::Reporters::HtmlReporter'
33
+ }.freeze
34
+
35
+ # Internal constant.
36
+ # @api private
37
+ DEFAULTS = %i[terminal json html].freeze
38
+
39
+ # Internal helper for the tracer pipeline.
40
+ # @api private
41
+ def self.emit_all(configuration:, snapshot:, report_dir:, run_metadata:)
42
+ new(configuration: configuration).emit_all(
43
+ snapshot: snapshot, report_dir: report_dir, run_metadata: run_metadata
44
+ )
45
+ end
46
+
47
+ # Internal method on the tracer pipeline.
48
+ # @api private
49
+ def initialize(configuration:)
50
+ @configuration = configuration
51
+ end
52
+
53
+ # Internal method on the tracer pipeline.
54
+ # @api private
55
+ def emit_all(snapshot:, report_dir:, run_metadata:)
56
+ entries = resolve_entries
57
+ return [] if entries.empty?
58
+ return [] if empty_snapshot?(snapshot)
59
+
60
+ entries.map { |klass, opts| emit_one(klass, opts, snapshot, report_dir, run_metadata) }
61
+ end
62
+
63
+ private
64
+
65
+ # Internal method on the tracer pipeline.
66
+ # @api private
67
+ def resolve_entries
68
+ declared = @configuration.respond_to?(:reporters) ? @configuration.reporters : nil
69
+ source = declared && !declared.empty? ? declared : DEFAULTS.map { |name| [name, {}] }
70
+ source.map { |name_or_class, opts| [resolve_class(name_or_class), opts || {}] }
71
+ end
72
+
73
+ # Internal method on the tracer pipeline.
74
+ # @api private
75
+ def resolve_class(name_or_class)
76
+ return name_or_class if name_or_class.is_a?(Class)
77
+
78
+ const_name = BUILT_INS.fetch(name_or_class) do
79
+ raise ArgumentError, "unknown reporter: #{name_or_class.inspect}"
80
+ end
81
+
82
+ Object.const_get(const_name)
83
+ end
84
+
85
+ # Internal method on the tracer pipeline.
86
+ # @api private
87
+ def emit_one(klass, opts, snapshot, report_dir, run_metadata)
88
+ reporter = klass.new(
89
+ snapshot: snapshot,
90
+ report_dir: report_dir,
91
+ run_metadata: run_metadata,
92
+ logger: logger,
93
+ **opts
94
+ )
95
+ reporter.generate
96
+ rescue StandardError => e
97
+ warn_continue(klass, e)
98
+ nil
99
+ end
100
+
101
+ # Internal method on the tracer pipeline.
102
+ # @api private
103
+ def warn_continue(klass, err)
104
+ logger&.warn("rspec-tracer: reporter #{klass.name} failed (#{err.class}: #{err.message})")
105
+ end
106
+
107
+ # Internal method on the tracer pipeline.
108
+ # @api private
109
+ def logger
110
+ @configuration.respond_to?(:logger) ? @configuration.logger : nil
111
+ end
112
+
113
+ # Internal method on the tracer pipeline.
114
+ # @api private
115
+ def empty_snapshot?(snapshot)
116
+ snapshot.nil? || snapshot.all_examples.nil? || snapshot.all_examples.empty?
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RSpecTracer
6
+ # Internal Reporters — see {RSpecTracer} for the user-facing surface.
7
+ # @api private
8
+ module Reporters
9
+ # Concise stdout summary printed at finalize-time. Output is
10
+ # capped at 4 lines for a typical run (5 when duplicate /
11
+ # interrupted / flaky / pending counters are non-zero). Kind-less
12
+ # taxonomy by design - the in-memory Input#kind enum does not
13
+ # survive Storage::Snapshot persistence, so breaking down
14
+ # "Changed files: Templates / Locales / Ruby" would require a
15
+ # schema bump (deferred).
16
+ #
17
+ # Color policy:
18
+ # - Respects `NO_COLOR` per https://no-color.org/ (any value
19
+ # disables, even empty string).
20
+ # - Emits ANSI codes only when the output stream reports tty?.
21
+ # - The header line paints red on any failure / interrupted,
22
+ # yellow on pending-only, green otherwise.
23
+ class TerminalReporter < Base
24
+ # Internal constant.
25
+ # @api private
26
+ COLORS = {
27
+ reset: 0,
28
+ red: 31,
29
+ green: 32,
30
+ yellow: 33,
31
+ cyan: 36
32
+ }.freeze
33
+ private_constant :COLORS
34
+
35
+ # "\u00B7" = U+00B7 MIDDLE DOT. Written as the ASCII escape form
36
+ # so mutant's US-ASCII-defaulted parser doesn't choke on
37
+ # non-ASCII bytes in the source file. Same discipline as the
38
+ # dependency_graph.rb workaround.
39
+ SEPARATOR = " \u00B7 "
40
+ private_constant :SEPARATOR
41
+
42
+ # Snapshot-set-name => human label pairs for the tally line.
43
+ # Data-driven to keep tally_line's AbcSize below rubocop's
44
+ # threshold; new counters go here without growing the method.
45
+ TALLY_FIELDS = [
46
+ [:failed_examples, 'failed'],
47
+ [:pending_examples, 'pending'],
48
+ [:flaky_examples, 'flaky'],
49
+ [:interrupted_examples, 'interrupted']
50
+ ].freeze
51
+ private_constant :TALLY_FIELDS
52
+
53
+ # Internal constant.
54
+ # @api private
55
+ BYTES_PER_MIB = 1_048_576.0
56
+ private_constant :BYTES_PER_MIB
57
+
58
+ # Concrete implementation of {RSpecTracer::Reporters::Base#generate}.
59
+ # Prints a per-run summary to the configured output stream.
60
+ #
61
+ # @return [Array<String>, nil] the lines emitted, or nil when the
62
+ # run had no examples worth reporting.
63
+ def generate
64
+ return nil if no_op?
65
+
66
+ lines = build_lines
67
+ stream = output_stream
68
+ lines.each { |line| stream.puts line }
69
+ lines
70
+ end
71
+
72
+ private
73
+
74
+ # Internal method on the tracer pipeline.
75
+ # @api private
76
+ def build_lines
77
+ [header_line, tally_line, kind_breakdown_line, cache_line, report_line].compact
78
+ end
79
+
80
+ # Internal method on the tracer pipeline.
81
+ # @api private
82
+ def header_line
83
+ total = snapshot.all_examples.size
84
+ run = run_count
85
+ skipped = snapshot.skipped_examples.size
86
+ cached_pct = cache_percent(total, skipped)
87
+
88
+ text = "rspec-tracer: #{total} examples tracked" \
89
+ "#{SEPARATOR}#{run} re-run#{SEPARATOR}#{skipped} skipped " \
90
+ "(#{cached_pct}% cached)"
91
+ paint(header_color, text)
92
+ end
93
+
94
+ # Internal method on the tracer pipeline.
95
+ # @api private
96
+ def tally_line
97
+ parts = TALLY_FIELDS.filter_map do |field, label|
98
+ ids = snapshot.send(field)
99
+ "#{ids.size} #{label}" unless ids.empty?
100
+ end
101
+ parts << "#{duplicate_count} duplicate" if duplicate_count.positive?
102
+ return nil if parts.empty?
103
+
104
+ paint(:yellow, parts.join(SEPARATOR))
105
+ end
106
+
107
+ # Internal method on the tracer pipeline.
108
+ # @api private
109
+ def cache_line
110
+ path = run_metadata[:cache_path]
111
+ return nil if path.nil? || path.to_s.empty?
112
+
113
+ size_part = cache_size_suffix(path.to_s)
114
+ "cache: #{path}#{size_part}"
115
+ end
116
+
117
+ # Per-reason breakdown of the run examples (e.g. 12 Files
118
+ # changed · 5 No cache). Sourced from snapshot.cache_hit_reason
119
+ # which the engine populated via @filtered_examples.values.tally
120
+ # at finalize. Empty {} (cold run with no engine cache) suppresses
121
+ # the line entirely. Sorted by count descending so the
122
+ # most-impactful reason leads.
123
+ def kind_breakdown_line
124
+ reasons = snapshot.cache_hit_reason
125
+ return nil if reasons.nil? || reasons.empty?
126
+
127
+ parts = reasons
128
+ .sort_by { |_reason, count| -count.to_i }
129
+ .map { |reason, count| "#{count} #{reason}" }
130
+ paint(:cyan, "by reason: #{parts.join(SEPARATOR)}")
131
+ end
132
+
133
+ # Bytes -> "12.3 MiB" / "456 KiB" / "789 B" depending on order.
134
+ # Mirrors JsonBackend#format_mib's MiB-and-up presentation but
135
+ # collapses small caches to KiB so a fixture spec writing 4 KiB
136
+ # doesn't display as "0.0 MiB."
137
+ def format_size_bytes(bytes)
138
+ return "#{bytes} B" if bytes < 1024
139
+ return "#{(bytes / 1024.0).round(1)} KiB" if bytes < BYTES_PER_MIB
140
+
141
+ "#{(bytes / BYTES_PER_MIB).round(1)} MiB"
142
+ end
143
+
144
+ # `(<size>)` or `(<size>; <delta>)` suffix for the cache line.
145
+ # Walks the current run-id dir for the size; walks the prior
146
+ # run-id dir (mtime-newest peer) for the delta. Wrapped in
147
+ # rescue so a transient FS error never blocks the surrounding
148
+ # cache_line emission.
149
+ def cache_size_suffix(cache_path)
150
+ current_id = snapshot.run_id
151
+ return '' if current_id.nil? || current_id.to_s.empty?
152
+
153
+ current_dir = File.join(cache_path, current_id)
154
+ return '' unless File.directory?(current_dir)
155
+
156
+ current_bytes = directory_size_bytes(current_dir)
157
+ prior_bytes = previous_run_dir_bytes(cache_path, current_id)
158
+ format_cache_suffix(current_bytes, prior_bytes)
159
+ rescue StandardError
160
+ ''
161
+ end
162
+
163
+ # Internal method on the tracer pipeline.
164
+ # @api private
165
+ def format_cache_suffix(current_bytes, prior_bytes)
166
+ size = format_size_bytes(current_bytes)
167
+ return " (#{size})" if prior_bytes.nil?
168
+
169
+ delta = current_bytes - prior_bytes
170
+ sign = if delta.positive?
171
+ '+'
172
+ else
173
+ (delta.negative? ? '-' : '')
174
+ end
175
+ " (#{size}; #{sign}#{format_size_bytes(delta.abs)} vs prev run)"
176
+ end
177
+
178
+ # Internal method on the tracer pipeline.
179
+ # @api private
180
+ def directory_size_bytes(dir)
181
+ Dir[File.join(dir, '**', '*')].sum do |path|
182
+ File.file?(path) ? File.size(path) : 0
183
+ end
184
+ end
185
+
186
+ # Internal method on the tracer pipeline.
187
+ # @api private
188
+ def previous_run_dir_bytes(cache_path, current_id)
189
+ peer_dirs = Dir.children(cache_path).filter_map do |name|
190
+ next if name == current_id || name.start_with?('.')
191
+
192
+ full = File.join(cache_path, name)
193
+ File.directory?(full) ? [full, File.mtime(full).to_f] : nil
194
+ end
195
+ return nil if peer_dirs.empty?
196
+
197
+ newest_peer = peer_dirs.max_by(&:last).first
198
+ directory_size_bytes(newest_peer)
199
+ end
200
+
201
+ # Internal method on the tracer pipeline.
202
+ # @api private
203
+ def report_line
204
+ return nil if report_dir.nil? || report_dir.to_s.empty?
205
+
206
+ "report: #{File.join(report_dir, JsonReporter::FILENAME)}"
207
+ end
208
+
209
+ # Internal method on the tracer pipeline.
210
+ # @api private
211
+ def header_color
212
+ return :red if snapshot.failed_examples.any? || snapshot.interrupted_examples.any?
213
+ return :yellow if snapshot.pending_examples.any?
214
+
215
+ :green
216
+ end
217
+
218
+ # Internal method on the tracer pipeline.
219
+ # @api private
220
+ def run_count
221
+ snapshot.all_examples.count do |_, meta|
222
+ meta.is_a?(::Hash) && meta[:execution_result]
223
+ end
224
+ end
225
+
226
+ # Internal method on the tracer pipeline.
227
+ # @api private
228
+ def duplicate_count
229
+ snapshot.duplicate_examples.size
230
+ end
231
+
232
+ # Internal method on the tracer pipeline.
233
+ # @api private
234
+ def cache_percent(total, skipped)
235
+ return 0 if total.zero?
236
+
237
+ ((skipped.to_f / total) * 100).round
238
+ end
239
+
240
+ # Internal method on the tracer pipeline.
241
+ # @api private
242
+ def paint(color_key, text)
243
+ return text unless use_color?
244
+
245
+ code = COLORS.fetch(color_key, COLORS[:reset])
246
+ "\e[#{code}m#{text}\e[#{COLORS[:reset]}m"
247
+ end
248
+
249
+ # Internal method on the tracer pipeline.
250
+ # @api private
251
+ def use_color?
252
+ return false if ENV.key?('NO_COLOR')
253
+
254
+ output_stream.respond_to?(:tty?) && output_stream.tty?
255
+ end
256
+
257
+ # Internal method on the tracer pipeline.
258
+ # @api private
259
+ def output_stream
260
+ options[:io] || $stdout
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,73 @@
1
+ # RSpec integration
2
+
3
+ Glue between the v2 engine and RSpec's runner lifecycle. One file per
4
+ responsibility.
5
+
6
+ | File | Role |
7
+ |------|------|
8
+ | [`installation.rb`](installation.rb) | Prepends `RunnerHook` + `ReporterHook` onto `RSpec::Core::Runner` / `RSpec::Core::Reporter`. Idempotent. Called once from `RSpecTracer.start`. |
9
+ | [`runner_hook.rb`](runner_hook.rb) | Overrides `run_specs`. Two-pass filter walk: Pass 1 reads `tracks:` metadata + registers per-example glob/env declarations on the engine; Pass 2 asks `Engine#run_example?` for filter decisions. Mutates `RSpec.world` to the filtered set, logs the `RSpec tracer is running N examples` banner. |
10
+ | [`reporter_hook.rb`](reporter_hook.rb) | Overrides `example_started`, `example_finished`, `example_passed`, `example_failed`, `example_pending`. Forwards into `Engine` for dependency attribution; coverage.json emission lives on the dedicated `Reporters::CoverageJsonReporter` finalize path. |
11
+ | [`parallel_tests.rb`](parallel_tests.rb) | `TEST_ENV_NUMBER` + `PARALLEL_TEST_GROUPS` detection, `rspec_tracer.lock` lifecycle, narrator selection for log silencing, last-process merge via `Storage::JsonBackend#merge_from_peers`. |
12
+ | [`metadata.rb`](metadata.rb) | Per-example `tracks:` DSL walker. Reads `tracks: { files: ..., env: ... }` off an example plus every ancestor group, unions the entries (RSpec's default metadata cascade would clobber on shared keys), and returns the merged `{ files:, env: }` for RunnerHook to register with the engine. |
13
+
14
+ ## Load-order contract
15
+
16
+ `RSpecTracer.start` installs the hooks at require time on the classes,
17
+ not on an already-constructed Runner instance. Any subsequent
18
+ `RSpec::Core::Runner.new` carries `RunnerHook` in its ancestors chain.
19
+ This lets `RSpecTracer.start` run before RSpec has constructed its
20
+ Runner - the 1.x ObjectSpace-based install required mid-boot timing.
21
+
22
+ The expected user-facing sequence, unchanged:
23
+
24
+ ```ruby
25
+ # spec_helper.rb (no Rails)
26
+ require 'simplecov' # optional
27
+ SimpleCov.start # optional
28
+ require 'rspec_tracer'
29
+ RSpecTracer.start
30
+ # application code loads after this point
31
+ ```
32
+
33
+ ```ruby
34
+ # rails_helper.rb (Rails)
35
+ require 'simplecov'
36
+ SimpleCov.start
37
+ require_relative '../config/environment'
38
+ require 'rspec_tracer'
39
+ RSpecTracer.start
40
+ ```
41
+
42
+ If coverage has already accumulated before `RSpecTracer.start`, the
43
+ Installation module logs a warn-level line. Users are still free to
44
+ ignore it - the tracer degrades to "attribute whatever we can observe
45
+ from now on".
46
+
47
+ ## Per-example metadata DSL
48
+
49
+ Annotate a describe / context / example with `tracks: { files: ..., env: ... }`
50
+ to declare additional dependencies that Coverage + IO observation cannot see:
51
+
52
+ ```ruby
53
+ describe 'AdminController',
54
+ tracks: { files: 'app/policies/**/*.rb', env: 'ROLE_CONFIG' } do
55
+ it 'gates on the feature flag' do
56
+ expect(enabled?).to be(true)
57
+ end
58
+ end
59
+ ```
60
+
61
+ Both keys accept a String glob / env name OR an Array of them. Nested groups
62
+ contribute additively — a child group declaring `tracks: { env: 'X' }` does
63
+ NOT clobber an ancestor's `tracks: { files: 'Y' }`; both contribute to the
64
+ example's dependency set.
65
+
66
+ Internally: `Metadata.tracks_for(example)` walks `example.example_group
67
+ .parent_groups` plus the example itself and returns the union. `RunnerHook`
68
+ hands that to `Engine#register_tracks`, which resolves file globs to
69
+ `:declared`-kind Inputs and accumulates the env names for the finalize-time
70
+ `env_snapshot.json` write. Warm runs compare the previous run's env_snapshot
71
+ to the current ENV via `Tracker::EnvSnapshot#invalidated_keys` and mark any
72
+ example whose tracked env key drifted as re-runnable
73
+ (`EXAMPLE_RUN_REASON[:env_changed] => "Environment changed"`).
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+
5
+ require_relative 'reporter_hook'
6
+ require_relative 'runner_hook'
7
+
8
+ module RSpecTracer
9
+ # Internal RSpec — see {RSpecTracer} for the user-facing surface.
10
+ # @api private
11
+ module RSpec
12
+ # Prepends RunnerHook + ReporterHook onto RSpec's runner/reporter
13
+ # classes. Called once from `RSpecTracer.start`.
14
+ #
15
+ # 1.x used `ObjectSpace.each_object(::RSpec::Core::Runner)` to find
16
+ # the already-instantiated runner and prepend onto its singleton
17
+ # class - forcing start-time ordering (RSpec had to be mid-boot).
18
+ # 2.0 prepends onto the class itself at require time, so the hooks
19
+ # apply to every subsequent Runner / Reporter instance.
20
+ #
21
+ # Consequence: `RSpecTracer.start` no longer requires RSpec to be
22
+ # running. As long as `RSpec::Core::Runner` and `RSpec::Core::Reporter`
23
+ # are loaded (the standard `require 'rspec'` in spec_helper covers
24
+ # both), install! succeeds. Side effect: the JRuby FULL_TRACE_ENABLED
25
+ # / object_space_enabled guard that 1.x needed is no longer load-
26
+ # bearing - prepend doesn't touch ObjectSpace.
27
+ #
28
+ # Idempotence: `Module#prepend` is a no-op when the module is already
29
+ # in the ancestors chain. Double-calling install! is safe.
30
+ module Installation
31
+ # `install!` is named for its side effect (prepend the hooks), not
32
+ # as a predicate - rubocop's Naming/PredicateMethod defaults to
33
+ # flagging anything that doesn't return a boolean, but the trailing
34
+ # `true` is the Ruby idiom for "side-effect method ran fine".
35
+ #
36
+ # Target classes are parameterized so unit specs can pass anonymous
37
+ # fresh classes instead of stubbing the real RSpec constants - the
38
+ # latter collides with RSpec's own reporter finalization at at_exit
39
+ # time (RSpec::Core::Time vanishes while the outer suite is still
40
+ # finalizing).
41
+ # rubocop:disable Naming/PredicateMethod
42
+ def self.install!(runner_class: ::RSpec::Core::Runner, reporter_class: ::RSpec::Core::Reporter)
43
+ warn_if_coverage_already_accumulated
44
+
45
+ prepend_hook(runner_class, RSpecTracer::RSpec::RunnerHook)
46
+ prepend_hook(reporter_class, RSpecTracer::RSpec::ReporterHook)
47
+
48
+ true
49
+ end
50
+ # rubocop:enable Naming/PredicateMethod
51
+
52
+ def self.prepend_hook(target_class, hook_module)
53
+ return if target_class.ancestors.include?(hook_module)
54
+
55
+ target_class.prepend(hook_module)
56
+ end
57
+
58
+ # Emit a single warn-level line when the user's spec_helper
59
+ # triggered substantial application-code loads before calling
60
+ # `RSpecTracer.start`. The Coverage peek_result is per-file; we
61
+ # count files that already have at least one executed line. A
62
+ # non-SimpleCov fresh boot with >10 tracked files is the signal
63
+ # that app code loaded before the tracer started, which means
64
+ # boot-set capture misses those files as transitive dependencies.
65
+ #
66
+ # Conservative by design: skip the warning when SimpleCov is
67
+ # running (SimpleCov-first is the documented load order), and
68
+ # when Coverage hasn't started (defended-library edge case).
69
+ def self.warn_if_coverage_already_accumulated
70
+ return unless defined?(::Coverage)
71
+ return unless ::Coverage.respond_to?(:running?) && ::Coverage.running?
72
+ return if defined?(::SimpleCov) && ::SimpleCov.running
73
+
74
+ accumulated = _count_accumulated_files
75
+ return if accumulated < 10
76
+
77
+ RSpecTracer.logger.warn(
78
+ "RSpec tracer: coverage has already accumulated for #{accumulated} file(s) " \
79
+ 'before RSpecTracer.start. Expected load order: RSpecTracer.start runs before ' \
80
+ 'any application code (or after SimpleCov.start if using SimpleCov). Dependency ' \
81
+ 'attribution may miss boot-time loads.'
82
+ )
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ # Internal helper for the tracer pipeline.
88
+ # @api private
89
+ def self._count_accumulated_files
90
+ ::Coverage.peek_result.count do |_, cov|
91
+ lines = cov.is_a?(::Hash) ? cov[:lines] : cov
92
+ lines.is_a?(::Array) && lines.any? { |strength| strength.is_a?(::Integer) && strength.positive? }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module RSpecTracer
6
+ # Internal RSpec — see {RSpecTracer} for the user-facing surface.
7
+ # @api private
8
+ module RSpec
9
+ # Per-example tracking DSL. Reads the `tracks:` metadata key
10
+ # off an example and its ancestor example groups and emits a
11
+ # normalized union of declared file globs + env-var names.
12
+ #
13
+ # DSL shape (user-facing):
14
+ #
15
+ # RSpec.describe 'AdminController',
16
+ # tracks: { files: 'app/policies/**/*.rb', env: 'ROLE_CONFIG' } do
17
+ # ...
18
+ # end
19
+ #
20
+ # Values for `:files` and `:env` accept either a single String or
21
+ # an Array of Strings. Nested groups each contribute their own
22
+ # tracks hash; the union (not the replace) is what the example
23
+ # inherits. RSpec's built-in metadata cascade uses Hash#merge
24
+ # which would clobber a parent `tracks:` with a child `tracks:`
25
+ # on a shared key - almost never the user's intent. `tracks_for`
26
+ # bypasses the auto-cascade and walks ancestors explicitly.
27
+ #
28
+ # Returns `{ files: Set<String>, env: Set<String> }`. Empty sets
29
+ # when nothing is declared - callers can short-circuit on
30
+ # `result[:files].empty? && result[:env].empty?` to skip the
31
+ # attribution/env-snapshot plumbing.
32
+ #
33
+ # Pure-function (module-level, no state). Safe to call from the
34
+ # RunnerHook filter loop without synchronization.
35
+ module Metadata
36
+ # Internal constant.
37
+ # @api private
38
+ TRACKS_KEY = :tracks
39
+ # Internal constant.
40
+ # @api private
41
+ FILES_KEY = :files
42
+ # Internal constant.
43
+ # @api private
44
+ ENV_KEY = :env
45
+
46
+ # Keep methods module-level via `def self.x` (not module_function)
47
+ # so mutant can observe them - module_function attaches the
48
+ # methods to an anonymous singleton that Method#source_location
49
+ # can't trace (memory: feedback_mutation_friendly_modules).
50
+ def self.tracks_for(example)
51
+ files = Set.new
52
+ envs = Set.new
53
+
54
+ collect(example.example_group.parent_groups, files, envs)
55
+ merge_hash(example.metadata[TRACKS_KEY], files, envs)
56
+
57
+ { FILES_KEY => files, ENV_KEY => envs }
58
+ end
59
+
60
+ # parent_groups returns outer-first. Walk from outer to inner so
61
+ # the set-union is order-agnostic anyway; the order is
62
+ # documented for future readers who need it deterministic.
63
+ def self.collect(parent_groups, files, envs)
64
+ parent_groups.reverse_each do |group|
65
+ merge_hash(group.metadata[TRACKS_KEY], files, envs)
66
+ end
67
+ end
68
+
69
+ # A `tracks:` value of nil, non-Hash, or an empty Hash contributes
70
+ # nothing. Non-Hash values are silently ignored - tolerating
71
+ # user typos over raising is consistent with the rest of the
72
+ # DSL surface (add_filter, coverage_track_files).
73
+ def self.merge_hash(tracks, files, envs)
74
+ return unless tracks.is_a?(Hash)
75
+
76
+ normalize(tracks[FILES_KEY]).each { |v| files << v }
77
+ normalize(tracks[ENV_KEY]).each { |v| envs << v }
78
+ end
79
+
80
+ # String -> [String]; Array -> itself; anything else -> []. nil,
81
+ # empty-string, and blank values are filtered so
82
+ # `tracks: { files: '', env: nil }` doesn't inject empty entries
83
+ # into the attribution set.
84
+ def self.normalize(value)
85
+ case value
86
+ when String
87
+ value.empty? ? [] : [value]
88
+ when Array
89
+ value.map(&:to_s).reject(&:empty?)
90
+ else
91
+ []
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end