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